Garden-Angel-Ai-35Bot / modules /jito_tip_engine.py
35
Why 0 bundles were ever submitted, and six other things that failed silently (#164)
12f1e35 unverified
Raw
History Blame Contribute Delete
47.3 kB
"""
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
@dataclass
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"
@property
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:,}"
)
@dataclass
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
# ── γ ────────────────────────────────────────────────────────────────
@property
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