diff --git a/benchmarks/terminal-fill-quality.yml b/benchmarks/terminal-fill-quality.yml index 608ac719..bcd02102 100644 --- a/benchmarks/terminal-fill-quality.yml +++ b/benchmarks/terminal-fill-quality.yml @@ -58,7 +58,7 @@ methodology: - "Quote side, exact from balance deltas: user_q = what left or reached the user's SOL / WSOL / stable balance, the tx fee included when the user paid it, rent of accounts created or closed in the transaction excluded; pool_q = the pool's quote movement, converted to the user's quote unit when the pool is quoted differently; terminal_q = lamports, WSOL or stables landing in the fee wallets (FOMO: plus its user-signed USDC legs, 2 % of the trade or its $0.10 minimum)." - "network_q = tx fee if the user paid it + inclusion tips: Jito, 0slot, bloXroute, Astralane, Nozomi and each terminal's own relay accounts (Axiom, Trojan, Maestro, Photon, Pepeboost, pump.fun app). other_q = user_q minus pool_q, terminal_q and network_q on single-pool routes without hops (pump.fun protocol and creator fees, referral payouts); on routed swaps it stays inside pool." - "Pool identity: the token vault that moved against the user and the quote vault of the same swap instruction, both by pubkey. Two pools behind one shared authority (Raydium, Launchpad, Meteora DAMM v2) never merge, so a hop through a sibling pool is never read as pool impact and the previous-trade lookup only sees that pool." - - "Reference price, in order: the pool's exact pre-trade mid from its balances in the transaction on one constant-product pool (PumpSwap, Raydium); the venue's swap event when it carries the state before the trade (Launchpad, DLMM), when it reconciles with the vault deltas; else the previous trade on the pool at most 60 s old (pump.fun curve, CLMM, routed swaps). No off-chain token price; gas coins at Coinbase spot on the Relay rows, at the route's own hop mid on the native EVM rows." + - "Reference price, in order: the pool's exact pre-trade mid from its balances in the transaction (PumpSwap, Raydium); the venue's swap event when it carries the pre-trade state (Launchpad, DLMM) and reconciles with the vault deltas; else the previous trade on the pool, at most 60 s old (pump.fun curve, CLMM, routed swaps), up to 2 h on the EVM launchpad curves BasedBot's router trades on. No off-chain token price; gas coins at Coinbase spot on Relay rows, at the route's hop mid on native EVM rows." - "Loss (headline): buy = 1 − tokens × ref / user_q; sell = 1 − user_q / (tokens × ref). Basis points of the trade (buy: quote spent; sell: tokens × ref, plus the gas on an EVM chain, paid apart from the tokens). Pool = loss minus terminal, network and other: the components (relay on Relay rows) sum to the loss per swap; the split columns are medians (flow-weighted on All chains) and do not sum to the median loss. Losses outside −10 % to +50 % stay out. Median with its 95 % interval, p90." - "A product is published from 50 priced swaps in the window (a chain row, or a single-chain product, from 25) and marked as ranked from 100; a pooled entry also needs 25 effective swaps (weights considered), a row with one side only and no fee on it waits, and a product waits while its main chain fills. The previous-trade reference carries the previous trader's direction, so terminals priced mostly that way read some tens of bps worse in buy waves than on the exact mid." - "Fail rate: failed over every swap attempt the feed saw on the terminal's wallets in the window, exhaustive; wallet funding, fee sweeps and marker transfers invoke no swap program and are not attempts. Error classes are in the JSON. On the EVM rows the fail rate comes from a sample of blocks read in full (every transaction sent to the routers, reverted or not)." @@ -73,7 +73,7 @@ methodology: - "Which side of an EVM pool is the token is settled by matching the swap event's amounts with the receipt's transfers, not the pool's token order. Split: terminal = the app fee; relay = what Relay kept (fees, spread, gas); pool = the settlement swap's impact and LP fee; network = the Solana tx fee (0 when the app's sponsor signs). Sales the other way (a token sold on that chain, USDC or SOL delivered on Solana) sit in the same per-chain row, priced at the origin pool's state before the sale." - "FOMO sponsors gas: its own signer pays the tx fee, so the user's network cost is zero; its fee is a router commission plus user-signed USDC legs, all counted as terminal fee. pump.fun's app takes no fee; the fixed 0.001 SOL it forwards on some swaps is counted as network." - "Native EVM (GMGN and Axiom on BNB and Robinhood Chain, Banana Gun): their router contracts emit an event on every swap; eth_getLogs on those routers lists every successful swap they routed; the fail rate comes from a sample of blocks read in full (every tx sent to the routers, reverted or not). Value given = what the user sent plus gas; received = the tokens at the pool's state before the swap; the fee is paid inside the router as a native transfer: the residual after pool and gas." - - "Unpriced draws: on the EVM rows a share of the drawn swaps (about a sixth on BNB, a quarter on Robinhood Chain, more on BasedBot's row) cannot be valued at the pool's state (a route through pools without a quote leg, a venue whose event is not decoded) and stays out of the figure; each entry's unpriced_share and its rejects in the JSON say how much and why." + - "Unpriced draws: on the EVM rows a share of the drawn swaps (about a sixth on BNB; on Robinhood Chain 2 of 35 fills on 2026-09-21 once the launchpad curves and ETH-quoted v4 pools were decoded) cannot be valued at the pool's state and stays out of the figure; a token launch's first curve buy is its own reject (launch_first_trade), not a fill; each entry's unpriced_share and its rejects in the JSON say how much and why." - "Cohort discovery: the fee wallets and routers are learned from the chain, the lists being the seed. On a platform's transactions (Mobula's attribution as the sample, every 6 h), a recipient taking a fixed share of the trade across many payers is its fee wallet, adopted from 30 swaps of 20 payers with a stable 20 to 300 bps share; on EVM the transactions' destination is its router. The window's swaps also give the fee-like recipients no terminal lists yet (JSON, 'discovery')." - "Phantom (Solana): its in-wallet swap pays 0.85 % to its fee wallet in SOL or WSOL, sampled like the other Solana terminals. Binance Wallet (BSC, Ethereum, Base): one router on the three chains, no event of its own, so its swaps are the transactions sent to it in blocks read in full; valued like the other native EVM rows. Terminal's EVM fee wallets get under $10 a day: not measured. Banana Gun on Ethereum: buys only, no fee, its users sell on Uniswap directly; held out (one side)." - "four.meme's bonding curve on BNB is priced exactly: the curve is a constant product on virtual reserves (checked on consecutive trades to 1e-12), its constants come from the TokenManager's token info, so the mid before the trade is known; the launchpad's 1 % fee is 'other', like pump.fun's on Solana." diff --git a/harnesses/terminal-fill-quality/README.md b/harnesses/terminal-fill-quality/README.md index 4b265fa5..45ed0a12 100644 --- a/harnesses/terminal-fill-quality/README.md +++ b/harnesses/terminal-fill-quality/README.md @@ -142,6 +142,23 @@ depends on pool activity. The victim's extra cost is already inside **Trade-size buckets**: `by_size` per terminal (under $25, $25 to $250, over $250; median loss and n from 5 samples). +**Launchpad curves (`rhcurve.go`)**: BasedBot's router family also trades +bonding curves, one contract per token with the same bytecode, no Uniswap +event: `buy 0xec36bf57…` [gas coin in, tokens out, fee 1, fee 2], `sell +0x8113d738…` [tokens in, gas coin out, fee 1, fee 2], fees (1 % + 2 %) on the +gross amount. The quote is the gas coin itself unless an ERC20 moved the +amount to or from the curve (a stable, WETH, or a token the route bought on +the way, priced through that swap). Reference = the previous trade on the +curve at the reserve-side price, walked in 9,000-block chunks (the keyed +node caps `eth_getLogs` at 10,000; Robinhood Chain makes ~590 blocks a +minute) up to two hours back. A token launch's first buy (minted to the +curve in the same transaction) is rejected as `launch_first_trade`. On +2026-09-21, 15 of 40 router swaps on Robinhood Chain were curve trades and +7 more were sells against ETH-quoted v4 pools (no ERC20 transfer for the +quote leg: priced as the gas coin when no ERC20 of that order moved +through the manager, `nativeV4Quote`): 8 of 40 priced before, 33 of 35 +fills after. + **Cross-chain (`xchain.go`)**: FOMO's users pay on BNB, Robinhood Chain, Base, Ethereum or Arc and Relay delivers on Solana (BasedBot's requests are its in-app bridge between the user's Solana and EVM wallets, both diff --git a/harnesses/terminal-fill-quality/cmd/script/evm.go b/harnesses/terminal-fill-quality/cmd/script/evm.go index 2c9c0967..c1d6000c 100644 --- a/harnesses/terminal-fill-quality/cmd/script/evm.go +++ b/harnesses/terminal-fill-quality/cmd/script/evm.go @@ -245,6 +245,29 @@ const ( arcPseudo = "0xfffffffffffffffffffffffffffffffffffffffe" ) +// nativeV4Quote: a v4 pool holds the gas coin itself, so a swap against +// ETH logs no ERC20 transfer for the quote leg. When no ERC20 flow of the +// manager matches the quote (to 0.5 %), the quote is the gas coin at the +// exchange's price, USD per wei; any ERC20 flow of half the quote or more +// through the manager means a token leg instead (left unpriced). Sells of +// BasedBot's users on Robinhood Chain against ETH-quoted v4 pools read +// no_quote_leg before this. +func nativeV4Quote(c originChain, gas map[string]float64, ev *swapEv, quoteRaw *big.Int, flows map[string]*big.Int) (float64, bool) { + if ev.kind != "v4" || c.gas == "" || quoteRaw == nil || quoteRaw.Sign() <= 0 { + return 0, false + } + p, ok := gas[c.gas] + if !ok || p <= 0 { + return 0, false + } + for _, amt := range flows { + if amt != nil && amt.Cmp(new(big.Int).Div(quoteRaw, big.NewInt(2))) >= 0 { + return 0, false // an ERC20 of that order moved through the manager: the quote is a token leg the passes above could not price, not the gas coin + } + } + return p * 1e-18, true +} + // quoteUSD prices a quote token: stables at $1, wrapped gas coins at the // Coinbase spot of the chain's gas token. func quoteUSD(sym string, c originChain, gas map[string]float64) (float64, bool) { @@ -487,6 +510,10 @@ func priceEvmSettlement(ctx context.Context, httpc *http.Client, c originChain, return out, nil } } + for _, tr := range rhTrades(rc.Logs, true, tokenFrom) { + priceRHCurve(ctx, httpc, c, &out, tr, token, meta.dec, gas, rc.Logs) + return out, nil + } out.Unpriced = "no_pool" return out, nil } @@ -545,6 +572,9 @@ func priceEvmSettlement(ctx context.Context, httpc *http.Client, c originChain, } } } + if u, ok := nativeV4Quote(c, gas, ev, quoteRaw, intoPool[ev.pool]); ok { + return u, 0, true + } return 0, 0, false } upr, hops, ok := usdPerRaw(main.ev, main.quote, 0) @@ -832,6 +862,10 @@ func priceEvmOriginSale(ctx context.Context, httpc *http.Client, c originChain, return out, nil } } + for _, tr := range rhTrades(rc.Logs, false, tokenTo) { + priceRHCurve(ctx, httpc, c, &out, tr, token, meta.dec, gas, rc.Logs) + return out, nil + } out.Unpriced = "no_pool" return out, nil } @@ -890,6 +924,9 @@ func priceEvmOriginSale(ctx context.Context, httpc *http.Client, c originChain, } } } + if u, ok := nativeV4Quote(c, gas, ev, quoteRaw, outOfPool[ev.pool]); ok { + return u, 0, true + } return 0, 0, false } mainQuote, _ := delivered(main.ev, main.quote) diff --git a/harnesses/terminal-fill-quality/cmd/script/native.go b/harnesses/terminal-fill-quality/cmd/script/native.go index 77ba0f11..d32b5af3 100644 --- a/harnesses/terminal-fill-quality/cmd/script/native.go +++ b/harnesses/terminal-fill-quality/cmd/script/native.go @@ -468,6 +468,10 @@ func sampleNative(ctx context.Context, httpc *http.Client, st *State, nf *native st.reject(t.Slug, rejectNotSwap) continue } + if sw.Flag == "launch_first_trade" { + st.reject(t.Slug, parseReject(sw.Flag)) // a token launch's first curve buy: no reference, not a user's fill + continue + } if sw.Flag != "" && !sw.Priced && strings.HasPrefix(sw.Flag, "unpriced_") { st.reject(t.Slug, parseReject(sw.Flag)) continue @@ -706,7 +710,7 @@ func nativeRow(ctx context.Context, httpc *http.Client, t evmTerminal, hash stri sw.TerminalQ = fee if !s.Priced { sw.finalize(nil, 0, "") - sw.Flag = "unpriced_" + s.Unpriced + sw.Flag = unpricedFlag(s.Unpriced) return sw } ref := s.MidUSD @@ -757,7 +761,7 @@ func nativeRow(ctx context.Context, httpc *http.Client, t evmTerminal, hash stri sw.TerminalQ = fee if !s.Priced { sw.finalize(nil, 0, "") - sw.Flag = "unpriced_" + s.Unpriced + sw.Flag = unpricedFlag(s.Unpriced) return sw } ref := s.MidUSD @@ -767,3 +771,12 @@ func nativeRow(ctx context.Context, httpc *http.Client, t evmTerminal, hash stri } return &Swap{Flag: "unpriced_no_token_leg"} } + +// unpricedFlag: a settlement's reason as the row's flag; a token launch +// (the creator's first curve buy) is its own reject, not an unpriced fill. +func unpricedFlag(reason string) string { + if reason == "launch" { + return "launch_first_trade" + } + return "unpriced_" + reason +} diff --git a/harnesses/terminal-fill-quality/cmd/script/rhcurve.go b/harnesses/terminal-fill-quality/cmd/script/rhcurve.go new file mode 100644 index 00000000..f512995f --- /dev/null +++ b/harnesses/terminal-fill-quality/cmd/script/rhcurve.go @@ -0,0 +1,222 @@ +package main + +// Bonding-curve trades of the launchpad the BasedBot router family trades +// on (Robinhood Chain first: one curve contract per token, the same +// 10,230-byte bytecode on every one, no Uniswap event). The curve emits +// one event per trade, indexed (sender, recipient): +// +// buy 0xec36bf57…: data [gas coin in, tokens out, fee 1, fee 2] +// sell 0x8113d738…: data [tokens in, gas coin out, fee 1, fee 2] +// +// The fees (1 % + 2 % on 2026-09-21) are taken on the gross amount: on a +// buy the reserve gets the input less the fees, on a sell the reserve +// gives the output plus the fees. The curve's formula is not read: the +// reference is the previous trade on the same curve, as for pump.fun's +// curve on Solana, at the reserve-side price (net input, or gross output, +// per token). 15 of 40 router swaps on Robinhood Chain on 2026-09-21 were +// curve trades, all unpriced before this. + +import ( + "context" + "log" + "math" + "math/big" + "net/http" + "sort" + "strings" +) + +const ( + topicRHCurveBuy = "0xec36bf571f136799e8dc0b0b8bea4b04d8bd3d43de838aab0d5fc21d4cbfc455" + topicRHCurveSell = "0x8113d738abdcb6b38357e9d53a54a7157861a09031b453651f0fe7fe151f59df" +) + +type rhTrade struct { + curve string + buy bool + quote, tokens, fee1, fee2 *big.Int // raw + block, index int64 +} + +func rhTradeOf(l *evmLog) (rhTrade, bool) { + if len(l.Topics) != 3 || (l.Topics[0] != topicRHCurveBuy && l.Topics[0] != topicRHCurveSell) || len(strings.TrimPrefix(l.Data, "0x")) < 64*4 { + return rhTrade{}, false + } + t := rhTrade{curve: strings.ToLower(l.Address), buy: l.Topics[0] == topicRHCurveBuy, fee1: word(l.Data, 2), fee2: word(l.Data, 3), block: hexInt(l.BlockNumber), index: hexInt(l.LogIndex)} + if t.buy { + t.quote, t.tokens = word(l.Data, 0), word(l.Data, 1) + } else { + t.tokens, t.quote = word(l.Data, 0), word(l.Data, 1) + } + return t, true +} + +// rhTrades: the curve trades in a receipt whose curve moved the token +// (paid it out on a buy, took it in on a sale). +func rhTrades(logs []evmLog, buy bool, moved map[string]*big.Int) []rhTrade { + var out []rhTrade + for i := range logs { + t, ok := rhTradeOf(&logs[i]) + if !ok || t.buy != buy || moved[t.curve] == nil || moved[t.curve].Sign() == 0 || t.tokens.Sign() <= 0 || t.quote.Sign() <= 0 { + continue + } + out = append(out, t) + } + return out +} + +// reservePrice: raw gas coin per raw token at the reserve's side of the +// trade (input net of the fees on a buy, output plus the fees on a sale). +func (t rhTrade) reservePrice() float64 { + q := new(big.Int).Set(t.quote) + if t.buy { + q.Sub(q, t.fee1) + q.Sub(q, t.fee2) + } else { + q.Add(q, t.fee1) + q.Add(q, t.fee2) + } + if q.Sign() <= 0 { + return 0 + } + return f(q) / f(t.tokens) +} + +// priceRHCurve fills the settlement from a curve trade: pool = the +// reserve's side of the trade, other = the launchpad's fees, the mid = the +// previous trade on the same curve. +func priceRHCurve(ctx context.Context, httpc *http.Client, c originChain, out *evmSettlement, tr rhTrade, token string, tokenDec int, gas map[string]float64, logs []evmLog) { + // The curve's quote: the gas coin itself when no ERC20 moved the + // amount to or from the curve, else that ERC20 (a stable, the wrapped + // gas coin, or a token the route bought on the way: priced through + // the swap that paid it out). + unit, qdec, ok := curveQuoteUnit(ctx, httpc, c, tr, token, gas, logs) + if !ok { + out.Unpriced = "curve_quote" + return + } + p := unit * math.Pow10(qdec) // USD per whole quote unit + fees := new(big.Int).Add(tr.fee1, tr.fee2) + out.Venue, out.Pool, out.Pools = "launchpad-curve", tr.curve, 1 + out.OtherUSD = f(fees) * unit + out.PoolInUSD = tr.reservePrice() * f(tr.tokens) * unit + out.Tokens = f(tr.tokens) * math.Pow10(-tokenDec) + // The previous trade on the curve: Robinhood Chain makes ~590 blocks + // a minute and the keyed node caps eth_getLogs at 10,000 blocks, so + // the walk goes back in 9,000-block chunks (about 15 min each) up to + // eight of them (two hours); a curve quiet for longer stays unpriced. + var prev *rhTrade + to := tr.block + for chunk := 0; chunk < 8 && prev == nil && to > 0; chunk++ { + from := to - 9000 + if from < 0 { + from = 0 + } + var logs []evmLog + if err := evmCall(ctx, httpc, c.logsRPC(), "eth_getLogs", []any{map[string]any{"address": tr.curve, "topics": []any{[]string{topicRHCurveBuy, topicRHCurveSell}}, "fromBlock": "0x" + big.NewInt(from).Text(16), "toBlock": "0x" + big.NewInt(to).Text(16)}}, &logs); err != nil && !strings.Contains(err.Error(), "empty result") { + out.Unpriced = "curve_logs" + log.Printf("[evm] curve %s previous trades: %s", tr.curve, redactURL(err.Error(), c.logsRPC()[0])) + return + } + sort.Slice(logs, func(i, j int) bool { + bi, bj := hexInt(logs[i].BlockNumber), hexInt(logs[j].BlockNumber) + if bi != bj { + return bi < bj + } + return hexInt(logs[i].LogIndex) < hexInt(logs[j].LogIndex) + }) + for i := range logs { + t, ok := rhTradeOf(&logs[i]) + if !ok || t.tokens.Sign() <= 0 || t.quote.Sign() <= 0 { + continue + } + if t.block < tr.block || (t.block == tr.block && t.index < tr.index) { + tt := t + prev = &tt + } + } + to = from - 1 + } + if prev == nil { + // The launch itself (the token minted to the curve in this + // transaction, the creator's first buy): no reference, and not a + // user's fill; else a curve quiet for two hours. + out.Unpriced = "curve_no_prev" + for i := range logs { + l := &logs[i] + if len(l.Topics) == 3 && l.Topics[0] == topicTransfer && strings.ToLower(l.Address) == token && topicAddr(l.Topics[1]) == "0x0000000000000000000000000000000000000000" && topicAddr(l.Topics[2]) == tr.curve { + out.Unpriced = "launch" + } + } + return + } + mid := prev.reservePrice() // raw gas coin per raw token + if mid <= 0 { + out.Unpriced = "no_mid" + return + } + out.MidUSD = mid * math.Pow10(tokenDec-qdec) * p + out.RefSrc = "pool" + out.Priced = out.MidUSD > 0 +} + +// curveQuoteUnit: USD per raw unit of the curve's quote and its decimals. +func curveQuoteUnit(ctx context.Context, httpc *http.Client, c originChain, tr rhTrade, token string, gas map[string]float64, logs []evmLog) (float64, int, bool) { + close := func(a, b *big.Int) bool { + d := new(big.Int).Sub(a, b) + d.Abs(d) + return d.Cmp(new(big.Int).Div(b, big.NewInt(200))) <= 0 + } + // An ERC20 moving the quote amount into (buy) or out of (sell) the curve. + quoteErc := "" + for i := range logs { + l := &logs[i] + if len(l.Topics) != 3 || l.Topics[0] != topicTransfer || strings.ToLower(l.Address) == token { + continue + } + from, to := topicAddr(l.Topics[1]), topicAddr(l.Topics[2]) + if ((tr.buy && to == tr.curve) || (!tr.buy && from == tr.curve)) && close(word(l.Data, 0), tr.quote) { + quoteErc = strings.ToLower(l.Address) + break + } + } + if quoteErc == "" { + p, ok := gas[c.gas] + if c.gas == "" || !ok || p <= 0 { + return 0, 0, false + } + return 1e-18 * p, 18, true + } + qm := erc20(ctx, httpc, c, quoteErc) + if !qm.ok { + return 0, 0, false + } + if q, ok := quoteUSD(qm.symbol, c, gas); ok { + return q * math.Pow10(-qm.dec), qm.dec, true + } + // A token the route bought for the curve: the swap that paid it out, + // against a priced ERC20 that went into that pool. + for i := range logs { + ev := parseSwapEv(&logs[i]) + if ev == nil { + continue + } + for side := 0; side < 2; side++ { + if ev.out[side].Sign() <= 0 || !close(ev.out[side], tr.quote) || ev.in[1-side].Sign() <= 0 { + continue + } + for j := range logs { + l := &logs[j] + if len(l.Topics) != 3 || l.Topics[0] != topicTransfer || topicAddr(l.Topics[2]) != ev.pool || !close(word(l.Data, 0), ev.in[1-side]) { + continue + } + im := erc20(ctx, httpc, c, strings.ToLower(l.Address)) + if q, ok := quoteUSD(im.symbol, c, gas); im.ok && ok { + inUSD := f(ev.in[1-side]) * q * math.Pow10(-im.dec) + return inUSD / f(ev.out[side]), qm.dec, true + } + } + } + } + return 0, 0, false +}