35
Why 0 bundles were ever submitted, and six other things that failed silently (#164)
12f1e35 unverified | """ | |
| modules/jito_tip_engine.py — dynamic Jito tip bidding + redundant regional | |
| bundle submission (v1.0). | |
| Operator request (2026-07-28), backed by their own "Jito MEV Dynamic Tip | |
| Report": replace the static SOLANA_JITO_TIP_LAMPORTS bid with the report's | |
| auction model, and stop pointing every bundle at a single block engine. | |
| ──────────────────────────────────────────────────────────────────────────── | |
| 1. WHAT THIS REPLACES | |
| ──────────────────────────────────────────────────────────────────────────── | |
| Before this module, solana_executor.py did two things with Jito: | |
| • tipped a FIXED _JITO_TIP_LAMPORTS (default 100,000 = 0.0001 SOL) on | |
| every bundle, regardless of what the auction was actually clearing at | |
| that second, and regardless of how much edge the trade had to spend; | |
| • submitted to exactly ONE block engine (_JITO_BLOCK_ENGINE, default | |
| mainnet.block-engine.jito.wtf). | |
| Both are losing positions in a sealed-bid auction. A fixed tip is either | |
| too low to ever win a contested slot (silent, invisible, looks exactly | |
| like "no opportunities") or too high on an uncontested one (paying 0.0001 | |
| SOL when the 25th percentile cleared at 0.00001). A single engine means | |
| one regional relay's latency and health decide whether the bundle is seen | |
| by the leader at all. | |
| ──────────────────────────────────────────────────────────────────────────── | |
| 2. THE BIDDING FUNCTION (verbatim from the operator's report) | |
| ──────────────────────────────────────────────────────────────────────────── | |
| Tip_bid = min( Tip_max , max( Tip_floor , γ · (R_expected − C_base) ) ) | |
| subject to the strict atomic profitability condition | |
| ΔNet = R_expected − (Tip_bid + C_base) > 0 | |
| where | |
| R_expected gross expected yield of the round trip, lamports | |
| C_base signature fee + compute-unit priority budget, lamports | |
| Tip_floor live floor from Jito's tip-floor feed | |
| Tip_max the searcher's own risk ceiling | |
| γ aggressiveness ∈ [0.90, 0.999] — the share of net revenue | |
| handed to the validator to outbid competing searchers. | |
| ONE HONEST DEVIATION, and it matters. The report writes the profitability | |
| condition as a constraint but never says what to do when the constraint | |
| and the `max(Tip_floor, …)` clause disagree — and they can, easily: a | |
| 0.3 bps round trip on 10,000 USDC has an R−C of maybe 25,000 lamports | |
| while the auction's 50th percentile sits at 100,000. Taken literally, | |
| `max()` wins and the bot bids a tip larger than the entire trade's | |
| profit. If that bundle then LANDS, the trade is a guaranteed, realised | |
| loss — the exact opposite of what the model is for. | |
| So the constraint is enforced LAST here, not as a footnote: | |
| afford = R_expected − C_base − MIN_KEEP (MIN_KEEP > 0) | |
| tip = min(Tip_max, max(Tip_floor, γ·(R−C)), afford) | |
| and if `afford` lands below the floor by more than | |
| JITO_FLOOR_TOLERANCE_FRACTION, the trade is reported PRICED_OUT and no | |
| bundle is sent at all. That is not timidity: a bundle bid under the | |
| clearing floor simply doesn't land, so skipping costs nothing but a | |
| wasted RPC round trip, while landing an unaffordable bid costs real | |
| money. `decide()` returns that verdict explicitly so the caller can log | |
| "the auction was too expensive for this edge" instead of a silent miss. | |
| ──────────────────────────────────────────────────────────────────────────── | |
| 3. γ IS NOT A CONSTANT (the report's own "Architectural Recommendation") | |
| ──────────────────────────────────────────────────────────────────────────── | |
| The report closes by recommending searchers "dynamically scale γ based on | |
| real-time mempool density and validator slot leaders." This bot has no | |
| mempool-density feed, but it has something better-grounded and free: its | |
| own landing record. `note_outcome()` feeds every submitted bundle's fate | |
| back in, and γ walks between JITO_GAMMA_MIN and JITO_GAMMA_MAX against | |
| the trailing landing rate — | |
| landing rate low → we are being outbid → γ up (bid harder) | |
| landing rate high → we are overpaying → γ down (keep more edge) | |
| with JITO_GAMMA_TARGET_LAND_RATE as the setpoint. This is a plain | |
| integral controller on a bounded variable, not a model; it cannot run | |
| away, and with adaptation disabled (JITO_GAMMA_ADAPTIVE=false) γ stays | |
| pinned at JITO_GAMMA and the behaviour is exactly the static report | |
| formula. | |
| A word on the report's γ range while we are here. γ ∈ [0.90, 0.999] | |
| means surrendering 90–99.9% of net revenue, which is rational in a | |
| contested winner-takes-all race — the report's example (bidding 10.20 SOL | |
| to keep 0.15) is a real strategy, and "guaranteed 0.15 SOL beats a | |
| guaranteed zero" is sound game theory. It is a poor fit for the routes | |
| THIS bot scans: public Jupiter round trips clearing 0.1–3 bps, where the | |
| competition is usually not another searcher racing the same bundle but | |
| simply the spread closing. Default γ here is therefore | |
| JITO_GAMMA=0.90 — the bottom of the report's own range — and the | |
| adaptive controller is left free to climb toward 0.999 if, and only if, | |
| this deployment's real landing rate shows we are actually being outbid. | |
| Set JITO_GAMMA_ADAPTIVE=false and JITO_GAMMA=0.985 for the report's | |
| verbatim aggressive regime. | |
| ──────────────────────────────────────────────────────────────────────────── | |
| 4. REDUNDANT REGIONAL SUBMISSION | |
| ──────────────────────────────────────────────────────────────────────────── | |
| The same signed bundle goes to every configured block engine at once and | |
| the first acceptance wins. This is free — Jito dedupes identical bundles | |
| by content, so N submissions of one bundle is one auction entry at one | |
| tip, not N tips. The only cost is N HTTP requests. | |
| Engine order defaults to Frankfurt first because this deployment runs on | |
| an eu-central-1 box (i-08cb563ef1f9118a6, "garden-angel-frankfurt"), and | |
| the relay nearest the searcher is the one whose queue the bundle reaches | |
| soonest. Override with SOLANA_JITO_BLOCK_ENGINES (comma-separated). | |
| Everything here is best-effort: a dead tip-floor feed falls back to the | |
| last good reading, then to the static SOLANA_JITO_TIP_LAMPORTS the | |
| executor used before this module existed. No path in this file can raise | |
| into the executor's send loop. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import logging | |
| import os | |
| import time | |
| from dataclasses import dataclass, field | |
| from typing import Any, Optional | |
| import httpx | |
| logger = logging.getLogger(__name__) | |
| # v1.71 — strong references to in-flight background floor refreshes. asyncio | |
| # holds only a weak reference to a running task, so without this set the | |
| # garbage collector may destroy a refresh mid-flight and the floor silently | |
| # never updates. Entries remove themselves on completion. | |
| _BG_REFRESHES: set = set() | |
| LAMPORTS_PER_SOL = 1_000_000_000 | |
| def _env_float(name: str, default: float) -> float: | |
| try: | |
| raw = os.getenv(name, "").strip() | |
| return float(raw) if raw else default | |
| except (TypeError, ValueError): | |
| return default | |
| def _env_int(name: str, default: int) -> int: | |
| try: | |
| raw = os.getenv(name, "").strip() | |
| return int(float(raw)) if raw else default | |
| except (TypeError, ValueError): | |
| return default | |
| def _env_bool(name: str, default: bool) -> bool: | |
| raw = os.getenv(name, "").strip().lower() | |
| if not raw: | |
| return default | |
| return raw in ("1", "true", "yes", "on") | |
| # ── The report's parameters, all env-overridable ───────────────────────────── | |
| # γ — aggressiveness coefficient. See section 3 above for why the default | |
| # is the BOTTOM of the report's [0.90, 0.999] range rather than the 0.985 | |
| # its worked example uses. | |
| _GAMMA_DEFAULT = max(0.0, min(0.999, _env_float("JITO_GAMMA", 0.90))) | |
| _GAMMA_MIN = max(0.0, min(0.999, _env_float("JITO_GAMMA_MIN", 0.50))) | |
| _GAMMA_MAX = max(0.0, min(0.999, _env_float("JITO_GAMMA_MAX", 0.985))) | |
| _GAMMA_ADAPTIVE = _env_bool("JITO_GAMMA_ADAPTIVE", True) | |
| # Setpoint for the controller: the trailing landing rate we steer toward. | |
| # 0.55 rather than something near 1.0 deliberately — a landing rate of 1.0 | |
| # is not a triumph, it means every bid was larger than it needed to be. | |
| _GAMMA_TARGET_LAND_RATE = max(0.05, min(0.95, _env_float("JITO_GAMMA_TARGET_LAND_RATE", 0.55))) | |
| # Per-observation step. Small: this is an integral controller with no | |
| # damping term, and the bounded range does the rest of the work. | |
| _GAMMA_STEP = max(0.001, min(0.2, _env_float("JITO_GAMMA_STEP", 0.02))) | |
| # Don't move γ at all until there's a real sample to move it on. | |
| _GAMMA_MIN_SAMPLES = max(3, _env_int("JITO_GAMMA_MIN_SAMPLES", 8)) | |
| # v1.1 — size scaling. R_ref is the edge at which full aggression is | |
| # warranted; below it γ interpolates down toward _GAMMA_MIN. Default | |
| # 0.5 SOL (~$150 of net edge) — comfortably above anything this bot has | |
| # ever produced, so today every trade sits near γ_min and keeps its | |
| # margin, and the aggressive regime only engages if edges ever get big | |
| # enough for the winner-takes-all argument to be true. | |
| _GAMMA_SIZE_SCALING = _env_bool("JITO_GAMMA_SIZE_SCALING", True) | |
| _R_REF_LAMPORTS = max(0, _env_int("JITO_GAMMA_R_REF_LAMPORTS", 500_000_000)) | |
| _GAMMA_WINDOW = max(_GAMMA_MIN_SAMPLES, _env_int("JITO_GAMMA_WINDOW", 40)) | |
| # v1.2 — FIRST BLOOD. Until this many bundles have LANDED, bid the ceiling | |
| # and skip size scaling. γ adapts toward an observed landing rate and this | |
| # deployment has never observed one (0/0 bundles), so the controller is | |
| # open-loop and stays wherever it started. Overpaying deliberately is the | |
| # only way to buy the first observations. See in_first_blood(); every | |
| # profitability guard in decide() still applies, so this can never bid away | |
| # more than the trade actually earns. | |
| _FIRST_BLOOD_BUNDLES = max(0, _env_int("JITO_FIRST_BLOOD_BUNDLES", 15)) | |
| _FIRST_BLOOD_ENABLED = _FIRST_BLOOD_BUNDLES > 0 | |
| # Tip_max — the searcher's hard risk ceiling, lamports. 5,000,000 (0.005 | |
| # SOL, ~$1.50) matches the clamp solana_executor.py already applied to the | |
| # static SOLANA_JITO_TIP_LAMPORTS, so enabling this engine can never bid | |
| # above what the previous code path could. | |
| _TIP_MAX_LAMPORTS = max(1_000, _env_int("JITO_TIP_MAX_LAMPORTS", 5_000_000)) | |
| # Absolute lower bound on any bid we actually submit. Below Jito's own | |
| # 1,000-lamport minimum the block engine rejects the bundle outright. | |
| _TIP_MIN_LAMPORTS = max(1_000, _env_int("JITO_TIP_MIN_LAMPORTS", 1_000)) | |
| # Which percentile of the live tip-floor feed counts as "the floor". | |
| # Jito publishes 25th/50th/75th/95th/99th plus an EMA of the 50th. | |
| # 25th is the honest reading of "the price of entry" — the 50th is the | |
| # price of a comfortable win, and bidding it on every uncontested slot is | |
| # exactly the overpayment this module exists to stop. | |
| _TIP_FLOOR_PERCENTILE = os.getenv("JITO_TIP_FLOOR_PERCENTILE", "25").strip() or "25" | |
| # v1.72 — how far ABOVE the clearing price to bid. The bid is anchored to | |
| # the floor (see decide()), and this is the headroom that turns "matches the | |
| # clearing price" into "beats it". 1.5 is deliberately modest: on an | |
| # uncontested slot anything over the floor wins, and the money saved by not | |
| # overbidding is the entire edge on trades this size. Raise it only once | |
| # bundles are being SUBMITTED and LOSING. | |
| _TIP_FLOOR_MULTIPLIER = max(1.0, _env_float("JITO_TIP_FLOOR_MULTIPLIER", 1.5)) | |
| # How far below the floor an affordable bid may sit before we call the | |
| # trade priced out. 0.5 == "bid anyway if we can afford at least half the | |
| # floor" — a losing bundle costs nothing, so a little optimism here is | |
| # free, but a bid at 2% of the floor is pure wasted latency. | |
| _FLOOR_TOLERANCE_FRACTION = max(0.0, min(1.0, _env_float("JITO_FLOOR_TOLERANCE_FRACTION", 0.5))) | |
| # Profit that must survive the tip no matter what the formula says, in | |
| # lamports. Enforces the report's strict ΔNet > 0 with a real, non-zero | |
| # margin instead of a knife-edge equality. | |
| _MIN_KEEP_LAMPORTS = max(0, _env_int("JITO_MIN_KEEP_LAMPORTS", 5_000)) | |
| _TIP_FLOOR_URL = os.getenv( | |
| "JITO_TIP_FLOOR_URL", "https://bundles.jito.wtf/api/v1/bundles/tip_floor", | |
| ).strip() | |
| _TIP_FLOOR_TTL_SECS = max(1.0, _env_float("JITO_TIP_FLOOR_TTL_SECS", 20.0)) | |
| # Regional block engines, Frankfurt first — see section 4. Jito's canonical | |
| # mainnet relays; the bare `mainnet.block-engine.jito.wtf` global endpoint | |
| # stays in the list as a last resort. | |
| _DEFAULT_BLOCK_ENGINES = ( | |
| "https://frankfurt.mainnet.block-engine.jito.wtf", | |
| "https://amsterdam.mainnet.block-engine.jito.wtf", | |
| "https://london.mainnet.block-engine.jito.wtf", | |
| "https://ny.mainnet.block-engine.jito.wtf", | |
| ) | |
| def block_engines() -> list[str]: | |
| """Configured block-engine base URLs, in submission-preference order. | |
| SOLANA_JITO_BLOCK_ENGINES (comma-separated) wins. Falls back to the | |
| legacy single-value SOLANA_JITO_BLOCK_ENGINE — so an existing .env | |
| that only ever set the old variable keeps working unchanged, just | |
| with the regional relays appended behind it rather than replacing it. | |
| """ | |
| raw = os.getenv("SOLANA_JITO_BLOCK_ENGINES", "").strip() | |
| if raw: | |
| urls = [u.strip().rstrip("/") for u in raw.split(",") if u.strip()] | |
| if urls: | |
| return urls | |
| legacy = os.getenv("SOLANA_JITO_BLOCK_ENGINE", "").strip().rstrip("/") | |
| urls = list(_DEFAULT_BLOCK_ENGINES) | |
| if legacy: | |
| # Operator's explicit choice leads; the regionals become redundancy | |
| # behind it instead of overriding a deliberate setting. | |
| urls = [legacy] + [u for u in urls if u != legacy] | |
| return urls | |
| class TipDecision: | |
| """Everything the caller needs to log an auditable bid — or to explain, | |
| in one line, why no bundle was sent.""" | |
| tip_lamports: int | |
| should_bundle: bool | |
| reason: str | |
| gamma: float | |
| floor_lamports: int | |
| affordable_lamports: int | |
| expected_revenue_lamports: int | |
| base_cost_lamports: int | |
| floor_source: str = "none" | |
| def tip_sol(self) -> float: | |
| return self.tip_lamports / LAMPORTS_PER_SOL | |
| def describe(self) -> str: | |
| if not self.should_bundle: | |
| return f"no bundle — {self.reason}" | |
| return ( | |
| f"tip {self.tip_lamports:,} lamports ({self.tip_sol:.6f} SOL) " | |
| f"— γ={self.gamma:.3f}, floor {self.floor_lamports:,} " | |
| f"({self.floor_source}), affordable {self.affordable_lamports:,}" | |
| ) | |
| class _FloorCache: | |
| lamports: int = 0 | |
| fetched_at: float = 0.0 | |
| source: str = "none" | |
| percentiles: dict[str, int] = field(default_factory=dict) | |
| class JitoTipEngine: | |
| """Stateful across the process: caches the tip floor, tracks the landing | |
| rate, and walks γ. One instance per bot (see `get_tip_engine()`).""" | |
| def __init__(self) -> None: | |
| self._gamma = _GAMMA_DEFAULT | |
| self._floor = _FloorCache() | |
| self._lock = asyncio.Lock() | |
| # Bounded outcome ring — True == the bundle landed. Only ever read | |
| # as a rate, never as a history, so a plain list sliced to | |
| # _GAMMA_WINDOW is the whole data structure needed. | |
| self._outcomes: list[bool] = [] | |
| self._bundles_submitted = 0 | |
| self._bundles_landed = 0 | |
| self._tip_lamports_paid = 0 | |
| self._priced_out = 0 | |
| # v1.71 — set while a background floor refresh is in flight, so | |
| # decide_fast() starts at most one. Without it, a burst of attempts | |
| # against a stale floor would each schedule their own refresh of the | |
| # same rate-limited feed. Plain bool rather than a lock: it is only | |
| # ever touched from the event loop thread, and the cost of a rare | |
| # duplicate refresh is far below the cost of awaiting a lock on the | |
| # send path. | |
| self._refresh_inflight = False | |
| # ── γ ──────────────────────────────────────────────────────────────── | |
| def gamma(self) -> float: | |
| return self._gamma | |
| def in_first_blood(self) -> bool: | |
| """True while this bot has never landed a bundle (v1.2). | |
| Operator: "i dont have problem to pay for jito and make profit at | |
| the first nothing i pay so give them high profit." | |
| Right, and there is a control-theory reason as well as a | |
| willingness one. γ adapts toward a target LANDING RATE, and | |
| _landing_rate() returns None until _GAMMA_MIN_SAMPLES bundles have | |
| resolved. With zero bundles ever submitted — which is this | |
| deployment's actual state, `Bundles: 0/0 landed` on /jito — the | |
| controller has no feedback at all and γ sits wherever it was | |
| initialised, forever. It cannot learn its way to a winning bid | |
| because it has never made one. | |
| The only way out of that is to overpay deliberately until there | |
| are observations to adapt from. A landed bundle at a bad price is | |
| worth far more than an unlanded one at a good price: it converts | |
| the landing rate from unknown to measured, and everything after it | |
| is a real control loop instead of an open one. | |
| Ends by itself. Once JITO_FIRST_BLOOD_BUNDLES have landed, γ goes | |
| back to being whatever the controller has learned. | |
| """ | |
| if not _FIRST_BLOOD_ENABLED: | |
| return False | |
| return self._bundles_landed < _FIRST_BLOOD_BUNDLES | |
| def _scaled_gamma(self, net_lamports: int) -> float: | |
| """γ(R) — see decide()'s own comment for why size matters here. | |
| Interpolates from _GAMMA_MIN at a negligible edge up to the | |
| controller's current γ at _R_REF and above. With | |
| JITO_GAMMA_SIZE_SCALING=false this returns the controller's γ | |
| unchanged, which is the pre-v1.1 behaviour exactly. | |
| """ | |
| # v1.2 — first blood: bid the ceiling, and do NOT scale it down for | |
| # a small edge. Size scaling is the right policy once landing is a | |
| # known quantity; while it is unknown, shading a small trade's bid | |
| # down is how a bot never lands anything and therefore never finds | |
| # out what landing costs. The profitability constraint in decide() | |
| # is untouched — this can still never bid away more than the trade | |
| # earns. See in_first_blood(). | |
| if self.in_first_blood(): | |
| return _GAMMA_MAX | |
| if not _GAMMA_SIZE_SCALING or _R_REF_LAMPORTS <= 0: | |
| return self._gamma | |
| ratio = min(1.0, max(0.0, net_lamports / _R_REF_LAMPORTS)) | |
| low = min(_GAMMA_MIN, self._gamma) | |
| return low + (self._gamma - low) * ratio | |
| def _landing_rate(self) -> Optional[float]: | |
| if len(self._outcomes) < _GAMMA_MIN_SAMPLES: | |
| return None | |
| return sum(1 for o in self._outcomes if o) / len(self._outcomes) | |
| def note_outcome(self, landed: bool, tip_lamports: int = 0) -> None: | |
| """Feed one submitted bundle's fate back into the controller. | |
| Called from the executor after _confirm() resolves. Never raises; | |
| an unknown/ambiguous outcome should simply not be reported rather | |
| than guessed at, since a wrong sample moves γ the wrong way. | |
| """ | |
| # v1.3 — attach the fate to the remembered bid, so the landing rate | |
| # survives a restart. These counters are RAM: the operator restarts | |
| # several times an hour, and "0/0 landed" on /jito was a statement | |
| # about the last few minutes of uptime that read identically to | |
| # "we have never won". | |
| try: | |
| from modules.tip_memory import record_outcome | |
| record_outcome(bool(landed)) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.debug("[JitoTip] outcome not remembered: %s", exc) | |
| self._outcomes.append(bool(landed)) | |
| if len(self._outcomes) > _GAMMA_WINDOW: | |
| del self._outcomes[: len(self._outcomes) - _GAMMA_WINDOW] | |
| self._bundles_submitted += 1 | |
| if landed: | |
| self._bundles_landed += 1 | |
| self._tip_lamports_paid += max(0, int(tip_lamports)) | |
| if not _GAMMA_ADAPTIVE: | |
| return | |
| rate = self._landing_rate() | |
| if rate is None: | |
| return | |
| before = self._gamma | |
| if rate < _GAMMA_TARGET_LAND_RATE: | |
| # Losing more auctions than we want — the report's core claim is | |
| # that a conservative γ guarantees a loss against aggressive | |
| # searchers. Bid harder. | |
| self._gamma = min(_GAMMA_MAX, self._gamma + _GAMMA_STEP) | |
| elif rate > _GAMMA_TARGET_LAND_RATE: | |
| # Winning nearly everything means the bids were larger than they | |
| # needed to be; every lamport above the clearing price was pure | |
| # donation. Keep more of the edge. | |
| self._gamma = max(_GAMMA_MIN, self._gamma - _GAMMA_STEP) | |
| if abs(self._gamma - before) > 1e-9: | |
| logger.info( | |
| "[JitoTip] γ %.3f -> %.3f (landing rate %.0f%% over last %d bundles, " | |
| "target %.0f%%)", | |
| before, self._gamma, rate * 100, len(self._outcomes), | |
| _GAMMA_TARGET_LAND_RATE * 100, | |
| ) | |
| # ── Tip floor ──────────────────────────────────────────────────────── | |
| async def refresh_tip_floor(self, client: httpx.AsyncClient) -> int: | |
| """Live tip floor in lamports, cached for _TIP_FLOOR_TTL_SECS. | |
| Jito's feed returns a single-element array of SOL-denominated | |
| percentiles. Any failure serves the last good reading (however | |
| stale) and, failing that, 0 — which `decide()` reads as "no floor | |
| known", not as "the floor is free". | |
| """ | |
| now = time.monotonic() | |
| if self._floor.lamports and (now - self._floor.fetched_at) < _TIP_FLOOR_TTL_SECS: | |
| return self._floor.lamports | |
| async with self._lock: | |
| # Re-check after acquiring: a concurrent caller may have just | |
| # refreshed it, and this feed is rate-limited like any other. | |
| now = time.monotonic() | |
| if self._floor.lamports and (now - self._floor.fetched_at) < _TIP_FLOOR_TTL_SECS: | |
| return self._floor.lamports | |
| try: | |
| r = await client.get(_TIP_FLOOR_URL, timeout=5.0) | |
| r.raise_for_status() | |
| body = r.json() | |
| row = body[0] if isinstance(body, list) and body else body | |
| if not isinstance(row, dict): | |
| raise ValueError(f"unexpected tip_floor payload: {type(row).__name__}") | |
| percentiles: dict[str, int] = {} | |
| for key, value in row.items(): | |
| if not isinstance(value, (int, float)): | |
| continue | |
| # Feed is denominated in SOL, e.g. 0.000012 — convert once. | |
| percentiles[key] = int(float(value) * LAMPORTS_PER_SOL) | |
| wanted = ( | |
| f"landed_tips_{_TIP_FLOOR_PERCENTILE}th_percentile", | |
| f"ema_landed_tips_{_TIP_FLOOR_PERCENTILE}th_percentile", | |
| "landed_tips_25th_percentile", | |
| "landed_tips_50th_percentile", | |
| ) | |
| lamports, source = 0, "none" | |
| for key in wanted: | |
| if percentiles.get(key): | |
| lamports, source = percentiles[key], key | |
| break | |
| if lamports <= 0: | |
| raise ValueError("tip_floor payload carried no usable percentile") | |
| self._floor = _FloorCache( | |
| lamports=lamports, fetched_at=time.monotonic(), | |
| source=source, percentiles=percentiles, | |
| ) | |
| logger.debug( | |
| "[JitoTip] tip floor %s = %d lamports (%.6f SOL)", | |
| source, lamports, lamports / LAMPORTS_PER_SOL, | |
| ) | |
| # v1.3 — persist it. The percentiles were fetched, used for | |
| # one decision and dropped, so /jito could only ever | |
| # describe this process's uptime. See modules/tip_memory.py. | |
| try: | |
| from modules.tip_memory import record_floor | |
| record_floor(percentiles) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.debug("[JitoTip] floor not remembered: %s", exc) | |
| except Exception as exc: # noqa: BLE001 — best-effort by design | |
| # v1.3 — a REMEMBERED floor beats no floor. | |
| # | |
| # When the feed failed and this process had no cached floor | |
| # — every restart, and any outage — the engine bid into a | |
| # live auction with floor=0. `max(Tip_floor, γ·(R−C))` then | |
| # has nothing to hold the bid up, which is how a bundle | |
| # goes out at the minimum without anyone deciding that. | |
| # | |
| # An hour-old 25th percentile is a far better prior than | |
| # zero, and it is labelled `remembered` so no reader | |
| # mistakes it for live. | |
| if not self._floor.lamports: | |
| try: | |
| from modules.tip_memory import remembered_floor | |
| lam, age_secs = remembered_floor() | |
| if lam: | |
| self._floor = _FloorCache( | |
| lamports=lam, fetched_at=time.monotonic(), | |
| source=f"remembered ({age_secs / 60:.0f}m old)", | |
| ) | |
| logger.info( | |
| "[JitoTip] feed down — using remembered floor " | |
| "%d lamports (%.0fm old) rather than bidding " | |
| "with none", lam, age_secs / 60, | |
| ) | |
| except Exception: # noqa: BLE001 | |
| pass | |
| age = time.monotonic() - self._floor.fetched_at if self._floor.fetched_at else None | |
| logger.debug( | |
| "[JitoTip] tip-floor fetch failed (%s) — %s", | |
| str(exc)[:120], | |
| f"serving cached floor {self._floor.lamports} lamports " | |
| f"({age:.0f}s old)" if self._floor.lamports else | |
| "no cached floor, bidding without one", | |
| ) | |
| return self._floor.lamports | |
| # ── The bid ────────────────────────────────────────────────────────── | |
| def decide( | |
| self, | |
| expected_revenue_lamports: int, | |
| base_cost_lamports: int, | |
| floor_lamports: Optional[int] = None, | |
| min_keep_lamports: Optional[int] = None, | |
| ) -> TipDecision: | |
| """Evaluate the report's bidding function for one trade. | |
| `expected_revenue_lamports` is R_expected — the round trip's GROSS | |
| expected yield, already converted to lamports by the caller (the | |
| executor knows the live SOL/USD figure; this module deliberately | |
| does not, so it can never disagree with the margin gate about what | |
| a dollar is worth). | |
| `base_cost_lamports` is C_base — signature fee plus the | |
| compute-unit priority budget this attempt will actually bid. It | |
| must NOT include the tip; that is what this function returns. | |
| """ | |
| R = max(0, int(expected_revenue_lamports)) | |
| C = max(0, int(base_cost_lamports)) | |
| floor = int(self._floor.lamports if floor_lamports is None else floor_lamports) | |
| floor = max(0, floor) | |
| # v1.4 — THE TIP MUST LEAVE ENOUGH FOR THE MARGIN GATE. | |
| # | |
| # _MIN_KEEP_LAMPORTS defaults to 5,000 (~$0.00036 at $73/SOL). The | |
| # executor's margin gate requires SOLANA_MIN_REAL_NET_MARGIN, which | |
| # on this deployment is $0.002 — about 27,400 lamports, five times | |
| # larger. So this engine was computing a bid that left less than the | |
| # gate demands, and the gate was then obliged to refuse the trade the | |
| # engine had just priced. | |
| # | |
| # The operator's own decline message is the arithmetic: | |
| # | |
| # net $+0.0654 - $0.0645 network fee (… Jito tip, 888,254 lamports) | |
| # < margin $0.002 — aborted before broadcasting anything | |
| # | |
| # edge $0.0654 | |
| # tip $0.0586 90% of it, exactly gamma | |
| # base+priority $0.0062 | |
| # left $0.0006 against a $0.0020 gate -> REJECT | |
| # | |
| # At gamma 0.90 that is not bad luck, it is arithmetic: bidding 90% | |
| # of the edge cannot leave the gate its 3%. Every such trade was | |
| # rejected by construction, which is why 20 attempts produced 0 | |
| # sends and /tune then recommended raising gamma to 0.985 — advice | |
| # that would have made the guarantee stronger. | |
| # | |
| # The caller passes the gate's own requirement so the two cannot | |
| # drift apart. Without it the constant is used, exactly as before. | |
| keep = _MIN_KEEP_LAMPORTS if min_keep_lamports is None else max( | |
| _MIN_KEEP_LAMPORTS, int(min_keep_lamports)) | |
| net_before_tip = R - C | |
| # v1.1 (2026-07-29, operator: "make γ scale with size instead of a | |
| # flat constant … giving up 98% only makes sense when what's left is | |
| # still worth having") — correct, and it fixes a real defect in the | |
| # flat version. | |
| # | |
| # γ(R) = γ_min + (γ_max − γ_min) × min(1, R / R_ref) | |
| # | |
| # The flat γ treated a $1.15 edge and a $3,000 edge identically, | |
| # which is precisely the mistake in transplanting the report's | |
| # worked example onto this bot's actual trade sizes. At γ = 0.985 a | |
| # $1.15 edge keeps $0.017 — 1,465 landed trades to clear $25 — while | |
| # the same γ on a $3,000 edge keeps $46, which is worth having and | |
| # is what that number was calibrated for. | |
| # | |
| # Size-scaling makes the aggression EARNED rather than assumed: | |
| # small edges keep the margin that exists, and only edges above | |
| # JITO_GAMMA_R_REF climb toward the aggressive end where the | |
| # winner-takes-all argument actually applies. | |
| # | |
| # The landing-rate controller still moves the BAND (self._gamma); | |
| # this scales within it by trade size. The two compose: "how | |
| # contested is the auction lately" times "is this edge big enough to | |
| # be worth fighting for". | |
| gamma = self._scaled_gamma(net_before_tip) | |
| # The strict condition, applied before anything else: a trade whose | |
| # gross yield doesn't already cover its own base cost has nothing to | |
| # bid with, and no tip can rescue it. | |
| if net_before_tip <= keep: | |
| self._priced_out += 1 | |
| return TipDecision( | |
| tip_lamports=0, should_bundle=False, | |
| reason=( | |
| f"expected revenue {R:,} lamports does not clear base cost " | |
| f"{C:,} + minimum keep {keep:,} — nothing to bid with" | |
| ), | |
| gamma=gamma, floor_lamports=floor, affordable_lamports=0, | |
| expected_revenue_lamports=R, base_cost_lamports=C, | |
| floor_source=self._floor.source, | |
| ) | |
| affordable = net_before_tip - keep | |
| # v1.72 — γ IS A CEILING, NOT A SECOND FLOOR. | |
| # | |
| # The old line was `tip = min(TIP_MAX, max(floor, γ·(R−C)))`, taken | |
| # literally from the tip report. Read what `max` does there: the bid | |
| # is the LARGER of the clearing price and a fixed share of the edge. | |
| # So whenever γ·(R−C) exceeds the floor — which is essentially | |
| # always — the floor stops being an input at all and the bot bids | |
| # half its edge into an auction that is clearing for pennies. | |
| # | |
| # THIS IS WHY 0 BUNDLES WERE EVER SUBMITTED. From the operator's own | |
| # /jito and the attempt that motivated this, at $73/SOL: | |
| # | |
| # edge R 880,821 lamports $0.0643 | |
| # live floor 1,122 lamports $0.0001 | |
| # γ·(R−C) at γ=0.5 397,910 lamports $0.0290 ← bid | |
| # | |
| # A bid 355x the clearing price, eating 45% of the edge — after | |
| # which execute_flash_arb's own margin gate correctly refused to | |
| # send, every single time. The bot was not losing auctions. It was | |
| # pricing itself out before reaching one, and /jito has been | |
| # reporting `Bundles: 0/0` as the proof. | |
| # | |
| # An auction is won by beating the clearing price, not by donating a | |
| # share of your profit. So: anchor the bid to the FLOOR, multiply it | |
| # by enough to be comfortably above it, and let γ·(R−C) act as the | |
| # ceiling it always should have been — the most this trade is | |
| # willing to pay, not the amount it must pay. | |
| # | |
| # With floor 1,122 the same trade now bids ~1,683 lamports ($0.0001) | |
| # and keeps $0.0597 of its $0.0643. That is the difference between a | |
| # bot that trades and one that watches. | |
| # | |
| # JITO_TIP_FLOOR_MULTIPLIER tunes the headroom over the clearing | |
| # price. Raise it when bundles are submitted and LOSING — that is | |
| # the real signal to bid harder, and it is a signal this deployment | |
| # has never once produced. | |
| aggressive = int(gamma * net_before_tip) # the ceiling | |
| anchored = int(max(floor, 0) * _TIP_FLOOR_MULTIPLIER) | |
| tip = max(_TIP_MIN_LAMPORTS, anchored) | |
| if aggressive > 0: | |
| tip = min(tip, aggressive) | |
| tip = min(_TIP_MAX_LAMPORTS, tip) | |
| # No floor known (fresh box, feed down and nothing remembered) is | |
| # the one case where the anchor cannot be computed. Fall back to the | |
| # old share-of-edge behaviour rather than bidding the bare minimum | |
| # into an auction whose price is genuinely unknown. | |
| if floor <= 0 and aggressive > 0: | |
| tip = min(_TIP_MAX_LAMPORTS, max(_TIP_MIN_LAMPORTS, aggressive)) | |
| # …subject to ΔNet > 0. This is the clause the report leaves | |
| # implicit; see the module docstring, section 2. | |
| if tip > affordable: | |
| if floor > 0 and affordable < floor * _FLOOR_TOLERANCE_FRACTION: | |
| self._priced_out += 1 | |
| return TipDecision( | |
| tip_lamports=0, should_bundle=False, | |
| reason=( | |
| f"priced out of the auction — this edge can afford at most " | |
| f"{affordable:,} lamports of tip but the " | |
| f"{self._floor.source if self._floor.source != 'none' else 'live'} " | |
| f"floor is {floor:,} ({affordable / floor:.0%} of it, below the " | |
| f"{_FLOOR_TOLERANCE_FRACTION:.0%} tolerance). Bidding under the " | |
| f"clearing price would not land; bidding over it would land at a loss" | |
| ), | |
| gamma=gamma, floor_lamports=floor, affordable_lamports=affordable, | |
| expected_revenue_lamports=R, base_cost_lamports=C, | |
| floor_source=self._floor.source, | |
| ) | |
| tip = affordable | |
| if tip < _TIP_MIN_LAMPORTS: | |
| self._priced_out += 1 | |
| return TipDecision( | |
| tip_lamports=0, should_bundle=False, | |
| reason=( | |
| f"affordable tip {tip:,} lamports is below Jito's " | |
| f"{_TIP_MIN_LAMPORTS:,}-lamport minimum — the block engine would " | |
| f"reject the bundle" | |
| ), | |
| gamma=gamma, floor_lamports=floor, affordable_lamports=affordable, | |
| expected_revenue_lamports=R, base_cost_lamports=C, | |
| floor_source=self._floor.source, | |
| ) | |
| # v1.3 — write the bid down before returning it. Only this path, | |
| # deliberately: the earlier returns are decisions NOT to bundle, and | |
| # recording them as bids would put rows in the auction history for | |
| # auctions this bot never entered. | |
| try: | |
| from modules.tip_memory import record_bid | |
| record_bid(tip_lamports=int(tip), floor_lamports=floor, | |
| gamma=gamma, net_lamports=net_before_tip, | |
| floor_source=self._floor.source) | |
| except Exception as exc: # noqa: BLE001 — memory never blocks a bid | |
| logger.debug("[JitoTip] bid not remembered: %s", exc) | |
| return TipDecision( | |
| tip_lamports=int(tip), should_bundle=True, | |
| reason=( | |
| f"γ·(R−C) = {aggressive:,}, floor {floor:,}, ceiling " | |
| f"{_TIP_MAX_LAMPORTS:,}, affordable {affordable:,}" | |
| ), | |
| gamma=gamma, floor_lamports=floor, affordable_lamports=affordable, | |
| expected_revenue_lamports=R, base_cost_lamports=C, | |
| floor_source=self._floor.source, | |
| ) | |
| async def decide_live( | |
| self, client: httpx.AsyncClient, | |
| expected_revenue_lamports: int, base_cost_lamports: int, | |
| min_keep_lamports: Optional[int] = None, | |
| ) -> TipDecision: | |
| """`decide()` with a freshly-refreshed floor. One HTTP call at most | |
| per _TIP_FLOOR_TTL_SECS across the whole process. | |
| `min_keep_lamports` is the caller's own margin gate, passed through | |
| so the bid can never be sized to leave less than the gate will | |
| demand back — see decide() for the arithmetic that made every one | |
| of the operator's 20 attempts reject by construction.""" | |
| floor = await self.refresh_tip_floor(client) | |
| return self.decide(expected_revenue_lamports, base_cost_lamports, floor, | |
| min_keep_lamports) | |
| def decide_fast( | |
| self, client: httpx.AsyncClient, | |
| expected_revenue_lamports: int, base_cost_lamports: int, | |
| min_keep_lamports: Optional[int] = None, | |
| ) -> TipDecision: | |
| """`decide_live()` without the network wait. Never awaits, never raises. | |
| v1.71 — WHY THIS EXISTS. | |
| decide_live() awaits refresh_tip_floor(), which is an HTTPS GET to | |
| bundles.jito.wtf with `timeout=5.0`. The executor called it from the | |
| middle of the send path, while holding the Jupiter lock, and — because | |
| `tip` was declared in pipeline_trace.STAGES but `trace.stage("tip")` | |
| was never opened around it — the time it took was invisible, landing | |
| in `unaccounted_ms` where no report could name it. A five-second | |
| worst case sat on the critical path of a trade whose edge decays at | |
| $0.92/s, and /pipeline could not have told anyone. | |
| The floor does not need to be fetched inside the trade. It is a 20s- | |
| TTL reading of a market-wide auction statistic — it describes what | |
| OTHER people paid recently, not anything about this trade — and | |
| refresh_tip_floor() already serves a cached value, already falls back | |
| to tip_memory's remembered floor, and already treats a failure as | |
| "serve what we have". Everything needed to answer instantly is | |
| present; the only reason it blocked is that the refresh and the read | |
| were the same call. | |
| So this splits them: read what we know NOW, and if that reading is | |
| stale, start the refresh in the background for the NEXT trade. The | |
| bid is made against a floor that is at most a few tens of seconds | |
| old instead of against one fetched at a cost that can exceed the | |
| edge being bid for. | |
| """ | |
| # If the cached reading is stale, refresh behind us — never in front. | |
| try: | |
| age = (time.monotonic() - self._floor.fetched_at | |
| if self._floor.fetched_at else float("inf")) | |
| if age >= _TIP_FLOOR_TTL_SECS and not self._refresh_inflight: | |
| self._refresh_inflight = True | |
| async def _bg() -> None: | |
| try: | |
| await self.refresh_tip_floor(client) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.debug("[JitoTip] background floor refresh failed: %s", exc) | |
| finally: | |
| self._refresh_inflight = False | |
| task = asyncio.ensure_future(_bg()) | |
| # Strong reference: asyncio only weakly references running | |
| # tasks, so without this the refresh can be collected before | |
| # it completes and the floor never updates. | |
| _BG_REFRESHES.add(task) | |
| task.add_done_callback(_BG_REFRESHES.discard) | |
| except Exception as exc: # noqa: BLE001 — a bid must never fail on its own telemetry | |
| self._refresh_inflight = False | |
| logger.debug("[JitoTip] could not schedule floor refresh: %s", exc) | |
| return self.decide(expected_revenue_lamports, base_cost_lamports, | |
| self._floor.lamports, min_keep_lamports) | |
| # ── Redundant submission ───────────────────────────────────────────── | |
| async def send_bundle( | |
| self, client: httpx.AsyncClient, tx_b64: str, | |
| engines: Optional[list[str]] = None, | |
| ) -> tuple[bool, str]: | |
| """Submit one already-signed transaction as a single-tx bundle to | |
| every configured block engine concurrently. Returns (accepted, | |
| detail). | |
| Identical bundles are deduped by the auction, so this buys landing | |
| probability at zero extra tip — the same bid simply reaches more | |
| relays, and whichever one is closest to the current leader wins. | |
| The first acceptance is enough; remaining requests are cancelled. | |
| Never raises. A total failure returns (False, why) so the caller | |
| can fall back to plain RPC sendTransaction exactly as before. | |
| """ | |
| urls = engines if engines is not None else block_engines() | |
| if not urls: | |
| return False, "no Jito block engines configured" | |
| payload = { | |
| "jsonrpc": "2.0", "id": 1, "method": "sendBundle", | |
| "params": [[tx_b64], {"encoding": "base64"}], | |
| } | |
| async def _submit(base_url: str) -> str: | |
| r = await client.post( | |
| f"{base_url.rstrip('/')}/api/v1/bundles", json=payload, timeout=8.0, | |
| ) | |
| r.raise_for_status() | |
| body = r.json() | |
| if isinstance(body, dict) and body.get("error"): | |
| raise RuntimeError(f"{base_url}: {str(body['error'])[:150]}") | |
| bundle_id = body.get("result") if isinstance(body, dict) else None | |
| return f"{base_url} accepted (bundle {str(bundle_id)[:16]})" | |
| tasks = [asyncio.create_task(_submit(u)) for u in urls] | |
| errors: list[str] = [] | |
| try: | |
| for coro in asyncio.as_completed(tasks): | |
| try: | |
| detail = await coro | |
| except Exception as exc: # noqa: BLE001 — try the next relay | |
| errors.append(str(exc)[:120]) | |
| continue | |
| accepted_by = len(urls) - len(errors) | |
| logger.info( | |
| "[JitoTip] bundle accepted — %s (submitted to %d engine(s), " | |
| "%d rejected so far)", detail, len(urls), len(errors), | |
| ) | |
| return True, f"{detail}; submitted to {len(urls)} engine(s)" | |
| finally: | |
| for t in tasks: | |
| if not t.done(): | |
| t.cancel() | |
| return False, ( | |
| f"all {len(urls)} block engine(s) rejected the bundle: " | |
| + " | ".join(errors[:3]) | |
| ) | |
| # ── Introspection (for /doctor, /jito, the dashboard) ──────────────── | |
| def status(self) -> dict[str, Any]: | |
| rate = self._landing_rate() | |
| floor_age = ( | |
| None if not self._floor.fetched_at | |
| else round(time.monotonic() - self._floor.fetched_at, 1) | |
| ) | |
| return { | |
| "gamma": round(self._gamma, 4), | |
| "gamma_adaptive": _GAMMA_ADAPTIVE, | |
| "gamma_size_scaling": _GAMMA_SIZE_SCALING, | |
| "gamma_r_ref_sol": round(_R_REF_LAMPORTS / LAMPORTS_PER_SOL, 4), | |
| "gamma_bounds": [_GAMMA_MIN, _GAMMA_MAX], | |
| "target_land_rate": _GAMMA_TARGET_LAND_RATE, | |
| "landing_rate": None if rate is None else round(rate, 3), | |
| # v1.2 — say plainly when the ceiling is being bid on purpose, | |
| # so a large tip reads as a decision rather than a bug. | |
| "first_blood": self.in_first_blood(), | |
| "first_blood_bundles": _FIRST_BLOOD_BUNDLES, | |
| "samples": len(self._outcomes), | |
| "bundles_submitted": self._bundles_submitted, | |
| "bundles_landed": self._bundles_landed, | |
| "priced_out": self._priced_out, | |
| "tip_sol_paid": round(self._tip_lamports_paid / LAMPORTS_PER_SOL, 6), | |
| "tip_floor_lamports": self._floor.lamports, | |
| "tip_floor_source": self._floor.source, | |
| "tip_floor_age_secs": floor_age, | |
| "tip_max_lamports": _TIP_MAX_LAMPORTS, | |
| "min_keep_lamports": _MIN_KEEP_LAMPORTS, | |
| "block_engines": block_engines(), | |
| } | |
| _engine: Optional[JitoTipEngine] = None | |
| def get_tip_engine() -> JitoTipEngine: | |
| """Process-wide singleton — γ and the landing record are only meaningful | |
| if every bundle this bot sends contributes to the same sample.""" | |
| global _engine | |
| if _engine is None: | |
| _engine = JitoTipEngine() | |
| return _engine | |