From 9689cf0ed25d31fe6f41995cbfd86152c1e155f9 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 21:39:42 +0200 Subject: [PATCH 01/50] solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries --- public/logos/leorpc.png | Bin 0 -> 733 bytes src/components/provider-logo.tsx | 1 + src/data/provider-registry.ts | 11 +++++++++++ src/lib/logo-manifest.ts | 2 ++ 4 files changed, 14 insertions(+) create mode 100644 public/logos/leorpc.png diff --git a/public/logos/leorpc.png b/public/logos/leorpc.png new file mode 100644 index 0000000000000000000000000000000000000000..27485d7613cc0fcb030433e6db8e038b43fbe021 GIT binary patch literal 733 zcmV<30wVp1P)RBqUiZ7W*)oQd;j*{ z-3QAIZ)az|eQ$PVAGC%oz!-23_z3*bu?kdy^FXmh->Db^mVh8-tO2Lm9sD@3ZUtEY zCV^oc6UbX>ST>j16}$}8)aiHNSc2^VV8tRa+71GYdM*8f2Z2{WUG;Jv0(DF8@*n{4P35*c1YTSE zPEP^_mA~>5SXTX@NANOc=b!Qr0GLx*!R#sx!-p)`GXBC!YBBmKFCiv*H z?3VDzwQi)R2O2C270jzW9Ak6E+QdZG-~c0JV|_>MZbr-%kh?&DY^FOYpc`aR^ZX>{ zyx#)8rV(&aLXKYpX~9wG1wJF+c*>cpVhYcIZ}1S|`CUP-RyKAZB{cxP0R0a6@N_(C zH8@Dt+9mWUaaE+E(V@OU^Me{I#NTb>LSwGBftND?7_e^gPiWa P00000NkvXXu0mjfnR-C8 literal 0 HcmV?d00001 diff --git a/src/components/provider-logo.tsx b/src/components/provider-logo.tsx index 62d4b0f4..db7f1af0 100644 --- a/src/components/provider-logo.tsx +++ b/src/components/provider-logo.tsx @@ -80,6 +80,7 @@ export function ProviderLogo({ // hairline shadow so they pop on both light and dark page backgrounds. const NEEDS_LIGHT_CHIP = new Set([ "aptos", + "leorpc", "blinklabs", "mobula", "lighter", diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index 8b89ddbc..a10cc7c8 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -214,6 +214,17 @@ export const PROVIDER_REGISTRY: Record = { "Solana transaction landing service by Temporal Labs. Direct-to-leader submission from 9 colocated regions, tip paid only on successful landing.", twitter: "@temporal_xyz", }, + "solana-official": { + url: "https://solana.com", + description: + "api.mainnet-beta.solana.com is the chain-official public RPC operated by Solana Labs, the endpoint every tutorial and SDK defaults to, documented at roughly 100 requests per 10 seconds per IP.", + twitter: "@solana", + }, + leorpc: { + url: "https://leorpc.com", + description: + "LeoRPC is a community Solana RPC operation exposing a publicly documented FREE tier through a query key, one of the few remaining keyless ways to read Solana mainnet.", + }, bloxroute: { url: "https://bloxroute.com", description: diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 3df2764e..0b15d77e 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -164,6 +164,7 @@ const RAW: Record = { nextblock: "/logos/nextblock.png", astralane: "/logos/astralane.svg", solanavibestation: "/logos/solanavibestation.png", + leorpc: "/logos/leorpc.png", // ─── Buyback audit (bench 018) ─── sky: "/logos/sky.svg", @@ -365,6 +366,7 @@ const ALIASES: Record = { // Long-tail RPC cluster (055-066). "sonic-official": "sonic", "monad-official": "monad", + "solana-official": "solana", "megaeth-official": "megaeth", "celo-official": "celo", "blast-official": "blast", From d80b67de12018593b337b675defc4fbed5e98713 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:59:45 +0200 Subject: [PATCH 02/50] nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136) Co-authored-by: Florent Tapponnier --- .../cmd/script/opensea.go | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/harnesses/nft-metadata-coverage/cmd/script/opensea.go b/harnesses/nft-metadata-coverage/cmd/script/opensea.go index 944ae839..0869c7d7 100644 --- a/harnesses/nft-metadata-coverage/cmd/script/opensea.go +++ b/harnesses/nft-metadata-coverage/cmd/script/opensea.go @@ -43,7 +43,12 @@ var openSeaClient = &http.Client{Timeout: 10 * time.Second} func resolveOpenSeaSlug(contract, apiKey, region string) (string, error) { url := fmt.Sprintf("https://api.opensea.io/api/v2/chain/ethereum/contract/%s", contract) req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("x-api-key", apiKey) + // OpenSea v2 serves these endpoints keyless (verified 2026-07-13), + // but an EMPTY x-api-key header gets a 401. Only set it when a key + // is actually configured. + if apiKey != "" { + req.Header.Set("x-api-key", apiKey) + } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", userAgent) @@ -73,12 +78,9 @@ func resolveOpenSeaSlug(contract, apiKey, region string) (string, error) { func checkOpenSea(coll NFTCollection, apiKey, region string) NFTResult { res := newResult("opensea", coll.Name) - if apiKey == "" { - res.Error = "missing_api_key" - res.ErrorType = "config" - recordError("opensea", region, "config") - return res - } + // Keyless mode is supported: OpenSea v2 collections/stats/contract + // endpoints answer without a key (verified 2026-07-13, incl. burst). + // A configured key only raises the rate-limit headroom. if coll.OpenSeaSlug == "" { res.Error = "missing_slug" res.ErrorType = "slug_resolve_error" @@ -91,7 +93,9 @@ func checkOpenSea(coll NFTCollection, apiKey, region string) NFTResult { // Call 1: collection metadata url1 := fmt.Sprintf("https://api.opensea.io/api/v2/collections/%s", coll.OpenSeaSlug) req1, _ := http.NewRequest("GET", url1, nil) - req1.Header.Set("x-api-key", apiKey) + if apiKey != "" { + req1.Header.Set("x-api-key", apiKey) + } req1.Header.Set("Accept", "application/json") req1.Header.Set("User-Agent", userAgent) @@ -130,7 +134,9 @@ func checkOpenSea(coll NFTCollection, apiKey, region string) NFTResult { // Call 2: stats (floor price). Non-fatal if it fails — we still score 4/5. url2 := fmt.Sprintf("https://api.opensea.io/api/v2/collections/%s/stats", coll.OpenSeaSlug) req2, _ := http.NewRequest("GET", url2, nil) - req2.Header.Set("x-api-key", apiKey) + if apiKey != "" { + req2.Header.Set("x-api-key", apiKey) + } req2.Header.Set("Accept", "application/json") req2.Header.Set("User-Agent", userAgent) From 7c6f4f1a1ab210446577e194a4b04ce830fbd9d6 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:30:01 +0200 Subject: [PATCH 03/50] nft harness: delist rarible, free tier is 100 requests per month (#1137) Co-authored-by: Florent Tapponnier --- harnesses/nft-metadata-coverage/cmd/script/monitor.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/harnesses/nft-metadata-coverage/cmd/script/monitor.go b/harnesses/nft-metadata-coverage/cmd/script/monitor.go index 26f14653..5fd4d0da 100644 --- a/harnesses/nft-metadata-coverage/cmd/script/monitor.go +++ b/harnesses/nft-metadata-coverage/cmd/script/monitor.go @@ -25,7 +25,7 @@ func truncate(s string, n int) string { // providerOrder is the column order in the condensed line, log table, and // any debug dump. -var providerOrder = []string{"moralis", "alchemy", "opensea", "rarible"} +var providerOrder = []string{"moralis", "alchemy", "opensea"} // fieldLetter maps each field to a single-letter code (NIDFE) used in the // condensed per-collection log line. @@ -228,10 +228,11 @@ func runCheckAllProviders(cfg *Config) { results["opensea"] = osRes openSeaWaitTurn() // accounts for the second internal call's spacing - // Rarible is serial @ 1 req/sec. - raribleWaitTurn() - raRes := checkRarible(coll, cfg.RaribleAPIKey, cfg.MonitorRegion) - results["rarible"] = raRes + // Rarible delisted 2026-07-13: the free tier caps at 100 requests + // per MONTH (one 50-collection cycle burns half of it), below any + // measurable cadence. The probe code stays in rarible.go for a + // sponsored or paid key revival; it was never a spec provider, so + // nothing user-facing changes. for _, p := range providerOrder { applyResult(results[p], cfg.MonitorRegion) From a98ae7ad5a316bd2fea21e8eb46add8d6783ad93 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 15:05:00 +0200 Subject: [PATCH 04/50] robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees --- benchmarks/l2-block-time.yml | 21 +++- benchmarks/network-fees.yml | 22 +++- harnesses/chain-kpis/cmd/script/registry.go | 1 + harnesses/l2-block-time/cmd/script/config.go | 12 ++ .../cmd/script/sequencer_poll.go | 106 ++++++++++++++++++ .../l2-block-time/cmd/script/sequencer_ws.go | 7 +- .../transaction-fee/cmd/script/config.go | 9 ++ src/lib/chains.ts | 8 ++ 8 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 harnesses/l2-block-time/cmd/script/sequencer_poll.go diff --git a/benchmarks/l2-block-time.yml b/benchmarks/l2-block-time.yml index 17199113..e65d3f81 100644 --- a/benchmarks/l2-block-time.yml +++ b/benchmarks/l2-block-time.yml @@ -2,12 +2,16 @@ slug: l2-block-time number: "009" -title: Fastest L2 block time, live across Arbitrum, Base, Optimism and 6 more +title: Fastest L2 block time, live across Robinhood, Arbitrum, Base and 7 more seo_title: "Fastest L2 block time 2026" -seo_description: "Fastest L2 sequencer block time live: Arbitrum, Blast, Optimism, Base, Linea and more ranked by newHeads interval." +seo_description: "Fastest L2 sequencer block time live: Robinhood Chain, Arbitrum, Optimism, Base, Linea and more ranked by real block interval." subtitle: Wall-clock interval in milliseconds between two consecutive newHeads events on each L2 sequencer, refreshed continuously. per_chain_explainer: + - slug: robinhood + h2: "Robinhood Chain block time" + body: | + Robinhood Chain block time is {{p50:robinhood}} (p50, 24h), the most aggressive cadence in the cohort: the Arbitrum Orbit stack lets Robinhood's sequencer target ~100 ms blocks for tokenized-stock settlement. The chain launched mainnet on July 1, 2026, pays gas in ETH and settles to Ethereum blobs. Measurement note: Robinhood exposes no public WebSocket, so block time here is derived from block-number deltas over 2-second HTTP poll windows, an accurate average that does not capture per-block jitter. - slug: arbitrum h2: "Arbitrum One block time" body: | @@ -66,6 +70,7 @@ abstract: | outlier sample is capped at 5 min so a reconnect can't pollute p50. methodology: + - "Robinhood Chain exception: the sequencer exposes no public WebSocket, so its block time is derived from eth_blockNumber deltas polled every 2 seconds over HTTP. A window that advanced N blocks contributes N samples of (elapsed / N) ms, which preserves per-block weighting and yields an accurate average; per-block jitter is not captured on this chain and this note is the disclosure." - "Cadence: continuous WebSocket subscription per L2, one persistent connection, no polling." - "Subscribe payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_subscribe\",\"params\":[\"newHeads\"]}`. No API key, public endpoints (publicnode.com mostly, era.zksync.io/ws for zkSync)." - "Sample: block-time(N) = T_recv(N) − T_recv(N-1), measured in milliseconds with `time.Now()` precision on the harness clock. Block-number duplicates from the same connection are filtered." @@ -101,6 +106,18 @@ faq: a: "Polygon zkEVM and Mode don't expose a public WebSocket endpoint without an API key, so we defer them to v2 when an API-keyed contributor endpoint lands. Starknet uses the Cairo stack with a different RPC shape, it would live in a dedicated bench rather than be shoehorned into the EVM cohort here. Berachain, Sonic and other adjacent chains are L1s, not L2s, so they live in the l1-finality bench." providers: + - slug: robinhood + name: Robinhood Chain + tag: Arbitrum Orbit, ~100ms blocks, HTTP-polled (no public WSS) + formula: "Median milliseconds per block on Robinhood Chain, derived from eth_blockNumber deltas over 2s HTTP poll windows (no public WebSocket), p50 over 24h." + queries: + p50: histogram_quantile(0.50, sum(rate(l2_block_time_milliseconds_histogram_bucket{chain="robinhood"}[24h])) by (le)) + p90: histogram_quantile(0.90, sum(rate(l2_block_time_milliseconds_histogram_bucket{chain="robinhood"}[24h])) by (le)) + p99: histogram_quantile(0.99, sum(rate(l2_block_time_milliseconds_histogram_bucket{chain="robinhood"}[24h])) by (le)) + mean: sum(rate(l2_block_time_milliseconds_histogram_sum{chain="robinhood"}[24h])) / sum(rate(l2_block_time_milliseconds_histogram_count{chain="robinhood"}[24h])) + success: avg_over_time(l2_block_time_health{chain="robinhood"}[24h]) + sample_size: sum(increase(l2_block_time_samples_total{chain="robinhood"}[24h])) + series: avg_over_time(l2_block_time_milliseconds{chain="robinhood"}[1h]) - slug: arbitrum name: Arbitrum One tag: Nitro fast-finality, sub-second sequencer diff --git a/benchmarks/network-fees.yml b/benchmarks/network-fees.yml index 36e65e3a..730f3900 100644 --- a/benchmarks/network-fees.yml +++ b/benchmarks/network-fees.yml @@ -2,10 +2,10 @@ slug: network-fees number: "031" -title: Cheapest blockchain transaction fee, live across 14 L1 and L2 chains +title: Cheapest blockchain transaction fee, live across 15 L1 and L2 chains seo_title: "Cheapest crypto transaction fee 2026" -seo_description: "Cheapest blockchain transaction fee in USD live across 14 chains. Solana, Base, Arbitrum, BNB Chain ranked." -subtitle: "Live USD cost of one native token transaction on 14 Layer 1 and Layer 2 chains, refreshed every 30 seconds." +seo_description: "Cheapest blockchain transaction fee in USD live across 15 chains. Solana, Base, Robinhood Chain, Arbitrum, BNB ranked." +subtitle: "Live USD cost of one native token transaction on 15 Layer 1 and Layer 2 chains, refreshed every 30 seconds." seo_intro: | This page answers one question. How much does it cost in dollars to send one transaction on each major blockchain right now. We track 14 chains in parallel and refresh the number every 30 seconds. The eight Layer 1 chains are Ethereum, Solana, BNB Chain, TRON, Cardano, Sui, Litecoin and Monero. The six Layer 2 rollups are Arbitrum, Base, zkSync Era, Linea, Mantle and Taiko. For every chain we query its own fee market directly (eth_feeHistory for the EVM family, getRecentPrioritizationFees on Solana, koios epoch params on Cardano, get_fee_estimate on Monero, the mempool oracle on Litecoin, getChainParameters on TRON, suix_getReferenceGasPrice on Sui), convert the result to the smallest native unit, then multiply by the live USD price of the chain's native token from Mobula. The output is the actual dollar amount a wallet user pays today. No gwei to lamport conversion, no marketing claim. Compare Ethereum gas now versus Solana fee in USD, see whether Arbitrum is still cheaper than Base today, find out which Layer 1 has the lowest transaction cost this minute. @@ -71,6 +71,10 @@ per_chain_explainer: h2: "Arbitrum transaction fee" body: | Arbitrum One native transfer fee is {{p50:arbitrum}} (p50, 24h). Arbitrum Nitro runs an EIP-1559 fee market against the sequencer at sub-second cadence; native ETH transfers are 21000 gas and sit at fractions of a cent because the sequencer's priority fee is near-zero by design. The L1 data-posting component (Ethereum blobs since Dencun) is not yet included in this figure, only L2 execution cost. Computed via `eth_feeHistory` on the Arbitrum sequencer RPC times live ETH price. + - slug: robinhood + h2: "Robinhood Chain transaction fee" + body: | + Robinhood Chain native transfer fee is {{p50:robinhood}} (p50, 24h). Robinhood's Arbitrum Orbit rollup posts a very low base fee (~0.05 gwei) and zero priority rewards, the typical Orbit pattern where the centralized sequencer needs no tip market; ETH is the gas asset and the L1 blob-posting cost is excluded from this figure like every other L2 on this page. Computed via `eth_feeHistory` on the official sequencer RPC times live ETH price. - slug: base h2: "Base transaction fee" body: | @@ -264,6 +268,18 @@ providers: mean: avg_over_time(tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="arbitrum"}[24h]) series: tx_fee_native_transfer_usd{chain="arbitrum",tier="std"} + - slug: robinhood + name: Robinhood Chain + layer: l2 + tag: "Arbitrum Orbit rollup Robinhood, 21000 gas" + formula: "Median USD cost of a native ETH transfer on Robinhood Chain over 24h. L2 execution cost only, L1 data posting fee excluded for now." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="robinhood",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="robinhood",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="robinhood",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="robinhood",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="robinhood"}[24h]) + series: tx_fee_native_transfer_usd{chain="robinhood",tier="std"} - slug: base name: Base layer: l2 diff --git a/harnesses/chain-kpis/cmd/script/registry.go b/harnesses/chain-kpis/cmd/script/registry.go index eb084453..b68a25eb 100644 --- a/harnesses/chain-kpis/cmd/script/registry.go +++ b/harnesses/chain-kpis/cmd/script/registry.go @@ -52,6 +52,7 @@ var Registry = []Chain{ {Slug: "arbitrum", DefiLlama: "Arbitrum", Mobula: "Arbitrum", NativeSymbol: "ETH"}, {Slug: "optimism", DefiLlama: "Optimism", Mobula: "Optimistic", NativeSymbol: "ETH"}, {Slug: "base", DefiLlama: "Base", Mobula: "Base", NativeSymbol: "ETH"}, + {Slug: "robinhood", DefiLlama: "Robinhood Chain", Mobula: "Robinhood Chain", NativeSymbol: "ETH"}, {Slug: "zksync", DefiLlama: "ZKsync Era", Mobula: "ZkSync", NativeSymbol: "ETH"}, {Slug: "linea", DefiLlama: "Linea", Mobula: "Linea", NativeSymbol: "ETH"}, {Slug: "scroll", DefiLlama: "Scroll", Mobula: "Scroll", NativeSymbol: "ETH"}, diff --git a/harnesses/l2-block-time/cmd/script/config.go b/harnesses/l2-block-time/cmd/script/config.go index 98bdb9c4..0879337f 100644 --- a/harnesses/l2-block-time/cmd/script/config.go +++ b/harnesses/l2-block-time/cmd/script/config.go @@ -13,6 +13,12 @@ type L2Chain struct { Slug string Name string URL string + // Poll switches the chain to HTTP block-number polling instead of a + // WebSocket newHeads subscription, for sequencers that expose no + // public WSS (Robinhood Chain). Block time is then derived from + // block-number deltas over each poll window; per-block jitter is + // not captured and the methodology discloses it. + Poll bool } // l2Chains is the source of truth for which Layer-2s appear in the @@ -22,6 +28,12 @@ type L2Chain struct { // rebuild — useful if a public endpoint goes flaky. func l2Chains() []L2Chain { return []L2Chain{ + { + Slug: "robinhood", + Name: "Robinhood Chain", + URL: envDefault("RPC_HTTP_ROBINHOOD", "https://rpc.mainnet.chain.robinhood.com"), + Poll: true, + }, { Slug: "arbitrum", Name: "Arbitrum One", diff --git a/harnesses/l2-block-time/cmd/script/sequencer_poll.go b/harnesses/l2-block-time/cmd/script/sequencer_poll.go new file mode 100644 index 00000000..30a03d10 --- /dev/null +++ b/harnesses/l2-block-time/cmd/script/sequencer_poll.go @@ -0,0 +1,106 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// HTTP polling fallback for sequencers with no public WebSocket +// (Robinhood Chain). Every pollInterval we read eth_blockNumber; a +// window that advanced by N blocks contributes N histogram samples of +// (elapsed / N) ms. This yields an accurate average block time while +// keeping the "one sample per block" weighting of the WS path; what it +// cannot capture is per-block jitter, which the bench methodology +// discloses for polled chains. +const ( + pollInterval = 2 * time.Second + pollTimeout = 10 * time.Second + maxWindowBlend = 600 // safety cap on samples per window (reconnect gaps) +) + +func runPollChain(ch L2Chain) error { + client := &http.Client{Timeout: pollTimeout} + var lastNum int64 + var lastAt time.Time + fails := 0 + + t := time.NewTicker(pollInterval) + defer t.Stop() + fmt.Printf("[%s] HTTP polling mode, interval=%s\n", ch.Slug, pollInterval) + + for now := range t.C { + num, err := fetchBlockNumber(client, ch.URL) + if err != nil || num == 0 { + fails++ + if fails >= 5 { + blockTimeHealth.WithLabelValues(ch.Slug).Set(0) + } + if fails == 5 || fails%150 == 0 { + fmt.Printf("[%s] poll error x%d: %v\n", ch.Slug, fails, err) + } + continue + } + fails = 0 + blockTimeHealth.WithLabelValues(ch.Slug).Set(1) + + if lastNum > 0 && num > lastNum && !lastAt.IsZero() { + delta := num - lastNum + elapsedMs := float64(now.Sub(lastAt).Milliseconds()) + perBlock := elapsedMs / float64(delta) + if perBlock > 0 && perBlock < maxSampleMs { + blockTimeGauge.WithLabelValues(ch.Slug).Set(perBlock) + n := int(delta) + if n > maxWindowBlend { + n = maxWindowBlend + } + for i := 0; i < n; i++ { + blockTimeHistogram.WithLabelValues(ch.Slug).Observe(perBlock) + } + blockTimeSampleCtr.WithLabelValues(ch.Slug).Add(float64(n)) + } + } + lastNum = num + lastAt = now + } + return nil +} + +func fetchBlockNumber(client *http.Client, url string) (int64, error) { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":%d}`, + time.Now().UnixNano(), + )) + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return 0, err + } + if resp.StatusCode != 200 { + return 0, fmt.Errorf("status %d", resp.StatusCode) + } + var env struct { + Result string `json:"result"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return 0, err + } + n := parseHexInt64(env.Result) + if n == 0 { + return 0, fmt.Errorf("empty block number") + } + return n, nil +} diff --git a/harnesses/l2-block-time/cmd/script/sequencer_ws.go b/harnesses/l2-block-time/cmd/script/sequencer_ws.go index 26d59f69..e9327aaf 100644 --- a/harnesses/l2-block-time/cmd/script/sequencer_ws.go +++ b/harnesses/l2-block-time/cmd/script/sequencer_ws.go @@ -75,7 +75,12 @@ func StartSequencerWS() { go func() { backoff := minBackoff for { - err := runChain(ch) + var err error + if ch.Poll { + err = runPollChain(ch) + } else { + err = runChain(ch) + } blockTimeHealth.WithLabelValues(ch.Slug).Set(0) blockTimeReconnectCtr.WithLabelValues(ch.Slug).Inc() if err != nil { diff --git a/harnesses/transaction-fee/cmd/script/config.go b/harnesses/transaction-fee/cmd/script/config.go index 24286094..15a20bb0 100644 --- a/harnesses/transaction-fee/cmd/script/config.go +++ b/harnesses/transaction-fee/cmd/script/config.go @@ -136,6 +136,15 @@ func loadConfig() *Config { HasPriorityMarket: true, Layer: "l2", }, + { + Slug: "robinhood", + Name: "Robinhood Chain", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_ROBINHOOD", "https://rpc.mainnet.chain.robinhood.com"), + MobulaSlug: "ethereum", + HasPriorityMarket: true, + Layer: "l2", + }, { Slug: "base", Name: "Base", diff --git a/src/lib/chains.ts b/src/lib/chains.ts index 29fd4bff..48da5cd7 100644 --- a/src/lib/chains.ts +++ b/src/lib/chains.ts @@ -155,6 +155,14 @@ export const CHAINS: ChainEntry[] = [ description: "OP Stack reference rollup, 2 s sequencer cadence, EIP-4844 blob calldata settlement on Ethereum.", }, + { + slug: "robinhood", + label: "Robinhood Chain", + category: "L2", + nativeSymbol: "ETH", + description: + "Robinhood's Arbitrum Orbit rollup for tokenized stocks, ~100 ms blocks, ETH gas, Ethereum blob settlement, sequencer operated by Robinhood with 10% of chain fees shared to ArbitrumDAO.", + }, { slug: "base", label: "Base", From be49b756adea09b1c204eb10929119d4a01d19df Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 15:05:03 +0200 Subject: [PATCH 05/50] bridge-monitor: on-chain balance fallback + degraded flag --- .../bridge-monitor/cmd/monitor/balance.go | 144 +++++++++++++++++- .../bridge-monitor/cmd/monitor/executor.go | 5 + .../bridge-monitor/cmd/monitor/metrics.go | 8 + .../cmd/monitor/onchain_balance.go | 22 +++ 4 files changed, 178 insertions(+), 1 deletion(-) diff --git a/harnesses/bridge-monitor/cmd/monitor/balance.go b/harnesses/bridge-monitor/cmd/monitor/balance.go index 570a38fa..3d5c401b 100644 --- a/harnesses/bridge-monitor/cmd/monitor/balance.go +++ b/harnesses/bridge-monitor/cmd/monitor/balance.go @@ -9,6 +9,9 @@ import ( "os" "strings" "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" ) // BalanceChecker fetches wallet balances from Mobula API @@ -17,6 +20,18 @@ type BalanceChecker struct { apiKey string evmAddress string solanaAddress string + + // Fallback on-chain reader. When the Mobula portfolio API fails we read the + // triangle tokens directly from RPC instead of silently returning zeros, + // which used to make every tier look like "insufficient funds" during an + // API outage. Nil in quote-only mode (no TxExecutor). + onchain *TxExecutor +} + +// SetOnchainFallback wires the RPC-based balance readers. Called after the +// TxExecutor exists because the BalanceChecker is constructed first in main. +func (bc *BalanceChecker) SetOnchainFallback(tx *TxExecutor) { + bc.onchain = tx } // MobulaWalletResponse represents the Mobula wallet API response @@ -53,6 +68,17 @@ func NewBalanceChecker(apiKey, evmAddress, solanaAddress string) *BalanceChecker // GetAllBalances fetches balances for all configured wallets // Returns map[chain][token] = balance_usd func (bc *BalanceChecker) GetAllBalances() (map[string]map[string]float64, error) { + balances, _, err := bc.GetAllBalancesDetailed() + return balances, err +} + +// GetAllBalancesDetailed fetches balances for all configured wallets. +// Primary source is the Mobula portfolio API; on per-chain failure it falls +// back to direct RPC reads of the triangle tokens. The degraded flag is true +// when at least one chain could not be read by EITHER path: callers must treat +// degraded balances as unreadable (never "empty wallet") and must not trigger +// automatic rebalancing from them. +func (bc *BalanceChecker) GetAllBalancesDetailed() (map[string]map[string]float64, bool, error) { result := make(map[string]map[string]float64) // Initialize chains @@ -60,11 +86,17 @@ func (bc *BalanceChecker) GetAllBalances() (map[string]map[string]float64, error result["Base"] = make(map[string]float64) result["Arbitrum"] = make(map[string]float64) + degraded := false + // Fetch Solana balances if bc.solanaAddress != "" { solBalances, err := bc.fetchWalletBalance(bc.solanaAddress) if err != nil { log.Printf("⚠️ Failed to fetch Solana balances: %v", err) + if !bc.fillSolanaFromChain(result) { + log.Printf("⚠️ On-chain Solana fallback also failed: balances degraded") + degraded = true + } } else { indexAssets(result, solBalances, "Solana") } @@ -76,6 +108,10 @@ func (bc *BalanceChecker) GetAllBalances() (map[string]map[string]float64, error baseBalances, err := bc.fetchWalletBalanceByChain(bc.evmAddress, "base") if err != nil { log.Printf("⚠️ Failed to fetch Base balances: %v", err) + if !bc.fillEVMFromChain(result, "Base") { + log.Printf("⚠️ On-chain Base fallback also failed: balances degraded") + degraded = true + } } else { indexAssets(result, baseBalances, "Base") } @@ -84,12 +120,118 @@ func (bc *BalanceChecker) GetAllBalances() (map[string]map[string]float64, error arbBalances, err := bc.fetchWalletBalanceByChain(bc.evmAddress, "arbitrum") if err != nil { log.Printf("⚠️ Failed to fetch Arbitrum balances: %v", err) + if !bc.fillEVMFromChain(result, "Arbitrum") { + log.Printf("⚠️ On-chain Arbitrum fallback also failed: balances degraded") + degraded = true + } } else { indexAssets(result, arbBalances, "Arbitrum") } } - return result, nil + if degraded { + bridgeBalanceReadDegraded.Set(1) + } else { + bridgeBalanceReadDegraded.Set(0) + } + + return result, degraded, nil +} + +// Token addresses read by the on-chain fallback. Stablecoins are valued at $1 +// parity (good enough to gate executions); SOL and ETH go through the price +// cache with a static fallback because during a Mobula outage the pricer may +// be down too, and a stale gas estimate beats a zero. +const ( + solanaUSDCMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + baseUSDCAddr = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + arbUSDTAddr = "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9" + arbUSDCAddr = "0xaf88d065e77c8cc2239327c5edb3a432268e5831" +) + +// fillSolanaFromChain reads Solana USDC + native SOL via RPC. Returns true only +// when the triangle token (USDC) was read successfully: SOL alone is not enough +// to run the cycle simulation, so a USDC read failure keeps the chain degraded. +func (bc *BalanceChecker) fillSolanaFromChain(result map[string]map[string]float64) bool { + if bc.onchain == nil || bc.onchain.solanaClient == nil { + return false + } + owner, err := solana.PublicKeyFromBase58(bc.solanaAddress) + if err != nil { + return false + } + + mint := solana.MustPublicKeyFromBase58(solanaUSDCMint) + raw, err := bc.onchain.solanaSPLBalanceOf(owner, mint) + if err != nil { + return false + } + usd := rawToFloat(raw, 6) + result["Solana"]["USDC"] = usd + result["Solana"][strings.ToLower(solanaUSDCMint)] = usd + + if lamports, err := bc.onchain.solanaNativeBalance(owner); err == nil { + result["Solana"]["SOL"] = rawToFloat(lamports, 9) * TokenPriceUSD("SOL", 150) + } + log.Printf("✅ Solana balances recovered via on-chain fallback (USDC $%.2f)", usd) + return true +} + +// fillEVMFromChain reads the chain's triangle stablecoin + native ETH via RPC. +// Same success rule as Solana: the triangle token read must succeed. +func (bc *BalanceChecker) fillEVMFromChain(result map[string]map[string]float64, chain string) bool { + if bc.onchain == nil { + return false + } + owner := common.HexToAddress(bc.evmAddress) + + type tokenRead struct { + addr string + symbols []string + } + var reads []tokenRead + switch chain { + case "Base": + reads = []tokenRead{{baseUSDCAddr, []string{"USDC"}}} + case "Arbitrum": + // Both USDT0 and USDT symbols: cycle_sim reads "USDT0" (Mobula naming) + // while route helpers fall back to "USDT". + reads = []tokenRead{ + {arbUSDTAddr, []string{"USDT0", "USDT"}}, + {arbUSDCAddr, []string{"USDC"}}, + } + default: + return false + } + + ok := false + for i, r := range reads { + raw, err := bc.onchain.erc20BalanceOf(chain, common.HexToAddress(r.addr), owner) + if err != nil { + // Only the first entry is the triangle token; secondary reads are + // best-effort for the stranded-fund gauges. + if i == 0 { + return false + } + continue + } + usd := rawToFloat(raw, 6) + for _, sym := range r.symbols { + result[chain][sym] = usd + } + result[chain][strings.ToLower(r.addr)] = usd + if i == 0 { + ok = true + } + } + + if wei, err := bc.onchain.evmNativeBalance(chain, owner); err == nil { + result[chain]["ETH"] = rawToFloat(wei, 18) * TokenPriceUSD("ETH", 3000) + } + if ok { + log.Printf("✅ %s balances recovered via on-chain fallback", chain) + } + return ok } // indexAssets stores balances in the result map, keyed by BOTH symbol AND contract address (lowercase). diff --git a/harnesses/bridge-monitor/cmd/monitor/executor.go b/harnesses/bridge-monitor/cmd/monitor/executor.go index 94ef212b..34dfde66 100644 --- a/harnesses/bridge-monitor/cmd/monitor/executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/executor.go @@ -112,6 +112,11 @@ func NewExecutor( } else { e.txExecutor = txExec log.Println("✅ TxExecutor initialized") + // Give the balance checker an RPC fallback so a Mobula portfolio + // API outage no longer reads as an empty wallet. + if balanceCheck != nil { + balanceCheck.SetOnchainFallback(txExec) + } } } diff --git a/harnesses/bridge-monitor/cmd/monitor/metrics.go b/harnesses/bridge-monitor/cmd/monitor/metrics.go index 0ac368ae..7d60c219 100644 --- a/harnesses/bridge-monitor/cmd/monitor/metrics.go +++ b/harnesses/bridge-monitor/cmd/monitor/metrics.go @@ -128,4 +128,12 @@ var ( Name: "bridge_consecutive_failures", Help: "Number of consecutive execution failures for a bridge (resets on success)", }, []string{"bridge", "region"}) + + // 1 when at least one chain's balances could not be read by either the + // Mobula API or the on-chain fallback. Lets alerting distinguish "wallet is + // empty" from "we cannot see the wallet". + bridgeBalanceReadDegraded = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "bridge_balance_read_degraded", + Help: "1 if wallet balances are currently unreadable via both API and RPC, 0 otherwise", + }) ) diff --git a/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go b/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go index ac450ef1..c6aac96d 100644 --- a/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go +++ b/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" ) @@ -49,6 +50,27 @@ func (tx *TxExecutor) evmClientFor(chain string) interface{ CallContract(context return nil } +// evmNativeBalance returns the native (ETH) balance of owner in wei. Needed by +// the balance fallback path: the Mobula portfolio API is the primary source, but +// when it is down we still need gas balances to gate executions safely. +func (tx *TxExecutor) evmNativeBalance(chain string, owner common.Address) (*big.Int, error) { + var client *ethclient.Client + switch strings.ToLower(chain) { + case "base": + client = tx.baseClient + case "arbitrum": + client = tx.arbitrumClient + default: + return nil, fmt.Errorf("unknown EVM chain: %s", chain) + } + if client == nil { + return nil, fmt.Errorf("no RPC client for %s", chain) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return client.BalanceAt(ctx, owner, nil) +} + // solanaSPLBalanceOf returns the SPL token balance for `owner` and `mint`. If // the associated token account does not yet exist (never received this token), // returns 0 — that's a valid pre-execution state. From 90658e072f92493e7decbb203c24be323ead88a2 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 15:07:07 +0200 Subject: [PATCH 06/50] bridge-monitor: self-healing metrics + slack notifiers --- .../bridge-monitor/cmd/monitor/metrics.go | 50 +++++++++++++++ harnesses/bridge-monitor/cmd/monitor/slack.go | 62 +++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/harnesses/bridge-monitor/cmd/monitor/metrics.go b/harnesses/bridge-monitor/cmd/monitor/metrics.go index 7d60c219..5f8ec776 100644 --- a/harnesses/bridge-monitor/cmd/monitor/metrics.go +++ b/harnesses/bridge-monitor/cmd/monitor/metrics.go @@ -136,4 +136,54 @@ var ( Name: "bridge_balance_read_degraded", Help: "1 if wallet balances are currently unreadable via both API and RPC, 0 otherwise", }) + + // Auto-rebalance attempts by outcome: attempted, succeeded, failed, capped. + bridgeRebalanceAttempts = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_rebalance_attempts_total", + Help: "Total automatic rebalance attempts by outcome (attempted, succeeded, failed, capped)", + }, []string{"outcome"}) + + // Set to 1 when a scheduled tier was downgraded to a smaller amount because + // the requested tier was not viable even after rebalancing. Reset to 0 when + // the original tier runs at full size again. + bridgeTierDowngraded = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_tier_downgraded", + Help: "1 when the last run of tier `from` was downgraded to tier `to`, 0 otherwise", + }, []string{"from", "to"}) + + // Hours funds have been sitting off their home triangle leg. 0 when the + // wallet only holds expected inventory. Exported continuously (also while + // paused) so Prometheus alerting can fire without any execution enabled. + bridgeStrandedHours = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_stranded_hours", + Help: "Hours a balance has been stranded off its home triangle leg (0 when home)", + }, []string{"chain", "token"}) + + // Gas top-up attempts per chain by outcome: attempted, succeeded, failed, + // capped, gated (needed but GAS_TOPUP_ENABLED is off). + bridgeGasTopup = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_gas_topup_total", + Help: "Total gas top-up attempts by chain and outcome", + }, []string{"chain", "outcome"}) ) + +// initSelfHealingMetrics pre-seeds the label combinations the alerting rules +// query, so the series exist on /metrics from process start instead of only +// after the first event. +func initSelfHealingMetrics() { + for _, outcome := range []string{"attempted", "succeeded", "failed", "capped"} { + bridgeRebalanceAttempts.WithLabelValues(outcome).Add(0) + } + bridgeTierDowngraded.WithLabelValues("300", "50").Set(0) + bridgeTierDowngraded.WithLabelValues("300", "5").Set(0) + bridgeTierDowngraded.WithLabelValues("50", "5").Set(0) + bridgeStrandedHours.WithLabelValues("Solana", "USDC").Set(0) + bridgeStrandedHours.WithLabelValues("Base", "USDC").Set(0) + bridgeStrandedHours.WithLabelValues("Arbitrum", "USDT0").Set(0) + for _, chain := range []string{"Solana", "Base", "Arbitrum"} { + for _, outcome := range []string{"attempted", "succeeded", "failed", "capped", "gated"} { + bridgeGasTopup.WithLabelValues(chain, outcome).Add(0) + } + } + bridgeBalanceReadDegraded.Set(0) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/slack.go b/harnesses/bridge-monitor/cmd/monitor/slack.go index 7b9422f1..5b42f773 100644 --- a/harnesses/bridge-monitor/cmd/monitor/slack.go +++ b/harnesses/bridge-monitor/cmd/monitor/slack.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" ) @@ -278,6 +279,67 @@ Waiting for next scheduled slot. Top up the blocked leg to unblock. return s.send(message) } +// NotifyRebalance reports every auto-rebalance event (attempted, succeeded, +// failed, capped) so the owner can audit what the self-healing loop moved. +func (s *SlackNotifier) NotifyRebalance(outcome, detail string) error { + if s == nil { + return nil + } + emoji := map[string]string{ + "attempted": "🔧", + "succeeded": "✅", + "failed": "❌", + "capped": "🛑", + }[outcome] + if emoji == "" { + emoji = "🔧" + } + message := fmt.Sprintf(`%s *Auto-Rebalance %s* + +%s`, emoji, strings.ToUpper(outcome), detail) + return s.send(message) +} + +// NotifyTierDowngrade reports that a scheduled tier ran at a smaller amount +// because the requested size was not viable. Partial data beats none, but the +// owner should know the wallet needs attention. +func (s *SlackNotifier) NotifyTierDowngrade(from, to float64, reason string) error { + if s == nil { + return nil + } + message := fmt.Sprintf(`⬇️ *Tier DOWNGRADED: $%.0f to $%.0f* + +*Reason $%.0f was blocked:* %s + +Running the smaller tier so the benchmark keeps producing data.`, + from, to, from, reason) + return s.send(message) +} + +// NotifyReaper reports stuck-fund reaper actions (corrective transfer of funds +// stranded off their home triangle leg). +func (s *SlackNotifier) NotifyReaper(detail string) error { + if s == nil { + return nil + } + message := fmt.Sprintf(`🧹 *Stuck-Fund Reaper* + +%s`, detail) + return s.send(message) +} + +// NotifyGasTopUp reports gas top-up events (low native gas detected, swap +// attempted, succeeded, failed, capped or gated). +func (s *SlackNotifier) NotifyGasTopUp(chain, outcome, detail string) error { + if s == nil { + return nil + } + message := fmt.Sprintf(`⛽ *Gas Top-Up %s on %s* + +%s`, strings.ToUpper(outcome), chain, detail) + return s.send(message) +} + // NotifyStartup sends a startup notification func (s *SlackNotifier) NotifyStartup(mode string) error { if s == nil { From 2283c9781703355b41a1e35c543b42106fc44ea1 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 15:08:48 +0200 Subject: [PATCH 07/50] bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge --- .../bridge-monitor/cmd/monitor/rebalancer.go | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 harnesses/bridge-monitor/cmd/monitor/rebalancer.go diff --git a/harnesses/bridge-monitor/cmd/monitor/rebalancer.go b/harnesses/bridge-monitor/cmd/monitor/rebalancer.go new file mode 100644 index 00000000..27d815ba --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/rebalancer.go @@ -0,0 +1,308 @@ +package main + +import ( + "fmt" + "log" + "strings" + "time" +) + +// Auto-rebalancer: when the pre-flight cycle simulation says a tier cannot run +// because one leg is short, move the shortfall from the leg holding excess +// inventory instead of waiting for a human. Reuses the executor's quote and +// broadcast plumbing so a rebalance transfer is measured and notified exactly +// like a benchmark execution. cmd/rebalance stays as the manual fallback. + +const ( + // Two attempts per tier slot: one transfer plus one retry covers transient + // quote failures without letting a broken bridge drain the daily budget. + maxRebalanceAttemptsPerSlot = 2 + + // Move 10 percent more than the computed shortfall so bridge fees and + // slippage on the transfer itself do not leave the leg short again. + rebalanceBufferFactor = 1.10 +) + +// legSpec describes one home leg of the USDC triangle. +type legSpec struct { + Chain string + ChainAPI string + Token string + TokenAddr string +} + +// The triangle's expected inventory distribution. Order matches R1, R2, R3. +var triangleLegs = []legSpec{ + {Chain: "Solana", ChainAPI: "solana:solana", Token: "USDC", TokenAddr: solanaUSDCMint}, + {Chain: "Base", ChainAPI: "evm:8453", Token: "USDC", TokenAddr: baseUSDCAddr}, + {Chain: "Arbitrum", ChainAPI: "evm:42161", Token: "USDT", TokenAddr: arbUSDTAddr}, +} + +func findLeg(chain string) (legSpec, bool) { + for _, leg := range triangleLegs { + if strings.EqualFold(leg.Chain, chain) { + return leg, true + } + } + return legSpec{}, false +} + +// legBalanceUSD looks up a leg's balance by contract address first (robust +// against symbol drift like USDT vs USDT0 in Mobula's DB), then by symbol. +func legBalanceUSD(balances map[string]map[string]float64, leg legSpec) float64 { + chain := balances[leg.Chain] + if chain == nil { + return 0 + } + if v, ok := chain[strings.ToLower(leg.TokenAddr)]; ok { + return v + } + if v, ok := chain[leg.Token]; ok { + return v + } + if leg.Token == "USDT" { + if v, ok := chain["USDT0"]; ok { + return v + } + } + return 0 +} + +// BuildRefillRoute turns a failed CycleSimulation into a corrective TestRoute. +// The source leg is the triangle leg holding the largest surplus above its own +// 1x tier requirement: taking from anywhere else would just move the blockage. +// The amount is the shortfall plus the fee buffer, never more, so a rebalance +// cannot silently drain a healthy leg. Pure function, unit-tested. +func BuildRefillRoute(sim CycleSimulation, balances map[string]map[string]float64) (TestRoute, float64, error) { + if sim.Viable { + return TestRoute{}, 0, fmt.Errorf("cycle already viable, nothing to rebalance") + } + if sim.RefillUSD <= 0 { + return TestRoute{}, 0, fmt.Errorf("simulation has no refill amount") + } + dest, ok := findLeg(sim.RefillChain) + if !ok { + return TestRoute{}, 0, fmt.Errorf("unknown refill chain %q", sim.RefillChain) + } + + var source legSpec + bestSurplus := 0.0 + for _, leg := range triangleLegs { + if leg.Chain == dest.Chain { + continue + } + surplus := legBalanceUSD(balances, leg) - sim.Tier + if surplus > bestSurplus { + source = leg + bestSurplus = surplus + } + } + if bestSurplus <= 0 { + return TestRoute{}, 0, fmt.Errorf("no leg holds excess inventory above its own $%.0f tier need", sim.Tier) + } + if bestSurplus < sim.RefillUSD { + return TestRoute{}, 0, fmt.Errorf("no leg holds enough excess: need $%.2f, best surplus is $%.2f on %s", + sim.RefillUSD, bestSurplus, source.Chain) + } + + amount := sim.RefillUSD * rebalanceBufferFactor + if amount > bestSurplus { + amount = bestSurplus + } + + route := TestRoute{ + Name: fmt.Sprintf("REBALANCE_%s_%s", strings.ToUpper(source.Chain), strings.ToUpper(dest.Chain)), + FromChain: source.Chain, + FromChainAPI: source.ChainAPI, + FromToken: source.TokenAddr, + ToChain: dest.Chain, + ToChainAPI: dest.ChainAPI, + ToToken: dest.TokenAddr, + IsSolanaSrc: source.Chain == "Solana", + } + return route, amount, nil +} + +// Rebalancer executes corrective transfers via the cheapest live bridge quote. +type Rebalancer struct { + executor *Executor + slack *SlackNotifier +} + +func NewRebalancer(executor *Executor, slack *SlackNotifier) *Rebalancer { + if executor == nil { + return nil + } + return &Rebalancer{executor: executor, slack: slack} +} + +// canAct verifies keys exist and we are in a mode allowed to broadcast. Keeps +// the rebalancer inert in dry-run and while the benchmark is paused. +func (r *Rebalancer) canAct() bool { + return r != nil && r.executor != nil && r.executor.txExecutor != nil && r.executor.txExecutor.CanExecute() +} + +// TryUnblockTier attempts up to maxRebalanceAttemptsPerSlot corrective +// transfers to make the given failed simulation viable. Returns the latest +// simulation and whether the tier can now run. Callers must only pass +// non-degraded balances: rebalancing on unreadable balances could move real +// funds based on phantom zeros. +func (r *Rebalancer) TryUnblockTier(sim CycleSimulation, balances map[string]map[string]float64, tierLabel string) (CycleSimulation, bool) { + if !r.canAct() { + return sim, false + } + + for attempt := 1; attempt <= maxRebalanceAttemptsPerSlot; attempt++ { + if r.executor.config.DailySpentUSD >= r.executor.config.MaxDailySpendUSD { + bridgeRebalanceAttempts.WithLabelValues("capped").Inc() + _ = r.slack.NotifyRebalance("capped", fmt.Sprintf( + "Tier $%.0f (%s) blocked (%s) but daily spend limit is reached ($%.2f / $%.2f). No rebalance attempted.", + sim.Tier, tierLabel, sim.Reason, r.executor.config.DailySpentUSD, r.executor.config.MaxDailySpendUSD)) + return sim, false + } + + route, amountUSD, err := BuildRefillRoute(sim, balances) + if err != nil { + bridgeRebalanceAttempts.WithLabelValues("failed").Inc() + log.Printf("🔧 Rebalance not possible for tier $%.0f: %v", sim.Tier, err) + _ = r.slack.NotifyRebalance("failed", fmt.Sprintf( + "Tier $%.0f (%s) blocked (%s) and no corrective route could be built: %v", + sim.Tier, tierLabel, sim.Reason, err)) + return sim, false + } + + bridgeRebalanceAttempts.WithLabelValues("attempted").Inc() + log.Printf("🔧 Rebalance attempt %d/%d: $%.2f %s -> %s (unblocks tier $%.0f)", + attempt, maxRebalanceAttemptsPerSlot, amountUSD, route.FromChain, route.ToChain, sim.Tier) + _ = r.slack.NotifyRebalance("attempted", fmt.Sprintf( + "Tier $%.0f (%s) blocked: %s\nMoving $%.2f from %s %s to %s %s (attempt %d/%d).", + sim.Tier, tierLabel, sim.Reason, amountUSD, route.FromChain, sourceSymbol(route), route.ToChain, sim.RefillToken, + attempt, maxRebalanceAttemptsPerSlot)) + + result := r.ExecuteCheapest(route, amountUSD) + if result == nil || !result.Success { + bridgeRebalanceAttempts.WithLabelValues("failed").Inc() + detail := "no bridge produced a usable quote" + if result != nil && result.Error != nil { + detail = result.Error.Error() + } + _ = r.slack.NotifyRebalance("failed", fmt.Sprintf( + "Rebalance transfer for tier $%.0f failed: %s", sim.Tier, detail)) + } else { + bridgeRebalanceAttempts.WithLabelValues("succeeded").Inc() + r.executor.config.DailySpentUSD += result.ActualFeeUSD + _ = r.slack.NotifyRebalance("succeeded", fmt.Sprintf( + "Moved $%.2f from %s to %s via %s (fee $%.4f, tx %s). Re-checking tier $%.0f viability.", + amountUSD, route.FromChain, route.ToChain, result.Bridge, result.ActualFeeUSD, result.TxHash, sim.Tier)) + } + + // Give the destination credit a moment to be indexed by the portfolio + // API before re-simulating, otherwise we would re-read the old state. + time.Sleep(15 * time.Second) + + fresh, degraded, err := r.executor.balanceCheck.GetAllBalancesDetailed() + if err != nil || degraded { + log.Printf("🔧 Post-rebalance balances unreadable (degraded=%v err=%v), stopping", degraded, err) + return sim, false + } + balances = fresh + sim = SimulateTriangleCycle(balances, sim.Tier) + if sim.Viable { + return sim, true + } + } + + bridgeRebalanceAttempts.WithLabelValues("capped").Inc() + _ = r.slack.NotifyRebalance("capped", fmt.Sprintf( + "Tier $%.0f still not viable after %d rebalance attempts: %s", + sim.Tier, maxRebalanceAttemptsPerSlot, sim.Reason)) + return sim, false +} + +// ExecuteCheapest quotes all three executing bridges and broadcasts through +// the cheapest one that returned a live quote. Shared by the tier rebalancer +// and the stuck-fund reaper so both follow the same cost discipline. +func (r *Rebalancer) ExecuteCheapest(route TestRoute, amountUSD float64) *ExecutionResult { + bridge, fee, err := r.quoteCheapest(route, amountUSD) + if err != nil { + log.Printf("🔧 Cheapest-quote selection failed for %s: %v", route.Name, err) + return nil + } + log.Printf("🔧 Cheapest bridge for %s: %s (quoted fee $%.4f)", route.Name, bridge, fee) + + rawUnits := toRawUnits(amountUSD) + return r.executor.executeOnBridge(bridge, route, amountUSD, amountUSD, rawUnits) +} + +// quoteCheapest fetches quotes from Mobula, Relay and LI.FI and returns the +// bridge with the lowest total quoted fee among those that answered. +func (r *Rebalancer) quoteCheapest(route TestRoute, amountUSD float64) (string, float64, error) { + e := r.executor + sender := e.walletManager.EVMAddress + receiver := e.walletManager.EVMAddress + if route.FromChain == "Solana" { + sender = e.walletManager.SolanaAddress + } + if route.ToChain == "Solana" { + receiver = e.walletManager.SolanaAddress + } + rawUnits := toRawUnits(amountUSD) + + bestBridge := "" + bestFee := 0.0 + + consider := func(bridge string, fee float64, err error) { + if err != nil { + log.Printf("🔧 [%s] rebalance quote failed: %v", bridge, err) + return + } + if bestBridge == "" || fee < bestFee { + bestBridge, bestFee = bridge, fee + } + } + + if e.mobula != nil { + quote, _, err := e.mobula.GetQuote(route.FromChainAPI, route.FromToken, route.ToChainAPI, route.ToToken, sender, receiver, amountUSD) + if err != nil { + consider("mobula", 0, err) + } else { + consider("mobula", parseFloatOrZero(quote.Data.Fees.TotalFeeUsd)+parseFloatOrZero(quote.Data.Fees.GasFeeUsd), nil) + } + } + + if quote, _, err := e.relay.GetQuote(route, rawUnits, sender, receiver); err != nil { + consider("relay", 0, err) + } else { + consider("relay", parseFloatOrZero(quote.Fees.RelayerService.AmountUsd)+parseFloatOrZero(quote.Fees.RelayerGas.AmountUsd), nil) + } + + if quote, _, err := e.lifi.GetQuote(route, rawUnits, sender, receiver); err != nil { + consider("lifi", 0, err) + } else { + fee := 0.0 + for _, f := range quote.Estimate.FeeCosts { + fee += parseFloatOrZero(f.AmountUSD) + } + consider("lifi", fee, nil) + } + + if bestBridge == "" { + return "", 0, fmt.Errorf("all bridges failed to quote %s", route.Name) + } + return bestBridge, bestFee, nil +} + +func parseFloatOrZero(s string) float64 { + return parseFloat(s, 0) +} + +// sourceSymbol maps the route's source token address back to a human symbol +// for Slack messages. +func sourceSymbol(route TestRoute) string { + for _, leg := range triangleLegs { + if strings.EqualFold(leg.TokenAddr, route.FromToken) { + return leg.Token + } + } + return route.FromToken +} From ca1efe8a0f60f4f1131ccea5d7669f98f72d81e0 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 15:10:21 +0200 Subject: [PATCH 08/50] bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight --- harnesses/bridge-monitor/cmd/monitor/main.go | 117 +++++++++++++++---- 1 file changed, 94 insertions(+), 23 deletions(-) diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index 5f867994..ee504f81 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -139,6 +139,9 @@ func main() { } } + // Pre-seed the self-healing series so alerting rules match from startup. + initSelfHealingMetrics() + // Start Prometheus metrics endpoint. Railway / most PaaS inject $PORT // and route external traffic to whatever value they chose. If we bind // to the wrong port the edge proxy returns 502 with x-railway-fallback. @@ -336,6 +339,14 @@ func main() { log.Println("🚀 Production mode: fixed-time scheduler started") } + // Auto-rebalancer shares the executor's quote and broadcast plumbing. Inert + // unless the executor can actually broadcast (production mode, keys present), + // so it costs nothing in dry-run or while paused. + var rebalancer *Rebalancer + if executor != nil { + rebalancer = NewRebalancer(executor, slackNotifier) + } + // Track last meme execution day to run weekly lastMemeDay := -1 @@ -345,7 +356,7 @@ func main() { case <-getSchedulerChan(scheduler, "$5"): // $5 execution loop - daily at 10:00 UTC if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, GetTriangleRoutes(), 5.0, "daily") + runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, GetTriangleRoutes(), 5.0, "daily") // Meme routes use independent capital (TRUMP) — always attempt, // the per-route RunReal check catches insufficient TRUMP. @@ -361,49 +372,110 @@ func main() { case <-getSchedulerChan(scheduler, "$50"): if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, GetTriangleRoutes(), 50.0, "Mon+Thu") + runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, GetTriangleRoutes(), 50.0, "Mon+Thu") } case <-getSchedulerChan(scheduler, "$300"): if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, GetTriangleRoutes(), 300.0, "Mon weekly") + runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, GetTriangleRoutes(), 300.0, "Mon weekly") } } } } +// downgradeLadder returns the tier amounts to try, largest first, starting at +// the scheduled tier. Partial data beats none: if $300 cannot run we still +// want the $50 or $5 datapoint from the same slot. +func downgradeLadder(tier float64) []float64 { + all := []float64{300, 50, 5} + var out []float64 + for _, t := range all { + if t <= tier { + out = append(out, t) + } + } + if len(out) == 0 { + out = []float64{tier} + } + return out +} + // runTierIfViable pre-flights the full R1→R2→R3 cycle at the given tier. If the -// simulation says the cycle cannot complete, emit ONE Slack "couldn't run" message -// and skip — next scheduler tick will retry. Returns true if the tier actually ran. +// simulation says the cycle cannot complete, it first lets the auto-rebalancer +// try to unblock the tier, then walks the downgrade ladder to a smaller amount. +// Only if nothing on the ladder is viable does it emit ONE Slack "couldn't run" +// message and skip — next scheduler tick will retry. Returns true if any +// amount actually ran. func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifier, - routes []TestRoute, tier float64, tierLabel string, + rebalancer *Rebalancer, routes []TestRoute, tier float64, tierLabel string, ) bool { if bc == nil { log.Printf("⚠️ No balance checker — skipping tier $%.0f pre-flight", tier) return false } - balances, err := bc.GetAllBalances() - if err != nil { - log.Printf("⚠️ Pre-flight balance fetch failed for $%.0f tier: %v", tier, err) - if slack != nil { - _ = slack.NotifyTierSkipped(tier, tierLabel, fmt.Sprintf("Balance API error: %v", err)) + ladder := downgradeLadder(tier) + blockedReason := "" + + for i, amount := range ladder { + balances, degraded, err := bc.GetAllBalancesDetailed() + if err != nil || degraded { + // Unreadable is not the same as empty: refuse to act (and above all + // refuse to rebalance) on balances we cannot trust. + log.Printf("⚠️ Pre-flight balances unreadable for $%.0f tier (degraded=%v err=%v)", tier, degraded, err) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, tierLabel, + fmt.Sprintf("Balances unreadable (degraded=%v, err=%v). Refusing to run or rebalance blind.", degraded, err)) + } + return false } - return false - } - sim := SimulateTriangleCycle(balances, tier) - if !sim.Viable { - log.Printf("⏭️ Tier $%.0f skipped: %s", tier, sim.Reason) - if slack != nil { - _ = slack.NotifyTierSkipped(tier, tierLabel, sim.Reason) + sim := SimulateTriangleCycle(balances, amount) + if !sim.Viable && rebalancer != nil { + sim, _ = rebalancer.TryUnblockTier(sim, balances, tierLabel) } - return false + if !sim.Viable { + if blockedReason == "" { + blockedReason = sim.Reason + } + log.Printf("⏭️ Tier $%.0f not viable at $%.0f: %s", tier, amount, sim.Reason) + continue + } + + if i > 0 { + log.Printf("⬇️ Tier $%.0f downgraded to $%.0f: %s", tier, amount, blockedReason) + bridgeTierDowngraded.WithLabelValues(tierAmountLabel(tier), tierAmountLabel(amount)).Set(1) + if slack != nil { + _ = slack.NotifyTierDowngrade(tier, amount, blockedReason) + } + } else { + // The scheduled tier runs at full size again: clear its downgrade + // gauges so the alert stops firing. + for _, smaller := range ladder[1:] { + bridgeTierDowngraded.WithLabelValues(tierAmountLabel(tier), tierAmountLabel(smaller)).Set(0) + } + } + + runTriangle(executor, routes, amount) + return true + } + + log.Printf("⏭️ Tier $%.0f skipped entirely: %s", tier, blockedReason) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, tierLabel, blockedReason) } + return false +} + +func tierAmountLabel(tier float64) string { + return strconv.FormatFloat(tier, 'f', 0, 64) +} - // Sequential per-bridge orchestration: each bridge runs a full R1→R2→R3 triangle - // before the next bridge starts. Halves the peak capital need per leg, gives a - // clean round-trip cost per provider, and unlocks larger tiers with less capital. +// runTriangle runs the sequential per-bridge orchestration: each bridge runs a +// full R1→R2→R3 triangle before the next bridge starts. Halves the peak capital +// need per leg, gives a clean round-trip cost per provider, and unlocks larger +// tiers with less capital. +func runTriangle(executor *Executor, routes []TestRoute, tier float64) { log.Printf("💸 Running $%.0f triangle (sequential per-bridge)...", tier) bridges := []string{"mobula", "relay", "lifi"} for _, bridge := range bridges { @@ -423,7 +495,6 @@ func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifie } } } - return true } // getSchedulerChan returns the appropriate scheduler channel or nil From 91656a967addeacc3b5e2393ce80b3aa9bc15282 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 15:11:34 +0200 Subject: [PATCH 09/50] bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer --- harnesses/bridge-monitor/cmd/monitor/main.go | 4 + .../bridge-monitor/cmd/monitor/reaper.go | 251 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 harnesses/bridge-monitor/cmd/monitor/reaper.go diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index ee504f81..18c5815a 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -347,6 +347,10 @@ func main() { rebalancer = NewRebalancer(executor, slackNotifier) } + // Stuck-fund reaper: gauges in every mode (alerting must survive a pause), + // corrective transfers only in production and unpaused. + StartReaper(balanceChecker, rebalancer, slackNotifier, config.ExecutionMode, paused) + // Track last meme execution day to run weekly lastMemeDay := -1 diff --git a/harnesses/bridge-monitor/cmd/monitor/reaper.go b/harnesses/bridge-monitor/cmd/monitor/reaper.go new file mode 100644 index 00000000..da6cec88 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/reaper.go @@ -0,0 +1,251 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + "time" +) + +// Stuck-fund reaper: the triangle expects inventory on exactly three legs +// (Sol USDC, Base USDC, Arb USDT) plus native gas. Refunds and partial fills +// sometimes leave capital elsewhere (typically USDC on Arbitrum after an R3 +// refund), where no scheduled route will ever pick it up again. The reaper +// tracks how long such balances sit stranded, exports that as a gauge for +// alerting, and, when execution is enabled, sends ONE corrective transfer per +// hourly tick back to the neediest home leg. + +// Ignore balances below this: bridge dust and rounding leftovers are not worth +// a corrective transfer's fees. +const strandedDustUSD = 1.0 + +// Reaper transfers share the rebalancer's spirit of "never move more than a +// tier plus buffer": one capped hop per tick instead of one big blind sweep. +const reaperMaxTransferUSD = 300 * rebalanceBufferFactor + +// Tokens that are SUPPOSED to sit on each chain. Everything else above the +// dust floor counts as stranded. TRUMP and BRETT are meme-route inventory, +// native SOL and ETH are gas. +var homeTokens = map[string]map[string]bool{ + "Solana": {"USDC": true, "SOL": true, "TRUMP": true}, + "Base": {"USDC": true, "ETH": true, "BRETT": true}, + "Arbitrum": {"USDT0": true, "USDT": true, "ETH": true}, +} + +// Stranded tokens we know how to route home. Anything else is alert-only: +// building a bridge TX for an unknown asset from inside a repair loop is how +// funds get burned, so we only ever move assets we have addresses for. +var movableStranded = map[string]map[string]string{ + "Solana": {"USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, + "Base": {"USDT": "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"}, + "Arbitrum": {"USDC": arbUSDCAddr}, +} + +type strandKey struct { + Chain string + Token string +} + +// StrandedTracker remembers when each off-home balance was first seen so the +// stranded duration survives across ticks (but not restarts: a restart resets +// the clock, which only delays the reaper, never makes it over-eager). +type StrandedTracker struct { + firstSeen map[strandKey]time.Time +} + +func NewStrandedTracker() *StrandedTracker { + return &StrandedTracker{firstSeen: make(map[strandKey]time.Time)} +} + +// Update computes stranded hours for every off-home balance in the snapshot +// and returns them, including explicit zeros for keys that just came home so +// the exported gauge resets. Pure given (balances, now), unit-tested. +func (t *StrandedTracker) Update(balances map[string]map[string]float64, now time.Time) map[strandKey]float64 { + hours := make(map[strandKey]float64) + + // Zero out anything previously tracked; re-add below if still stranded. + for k := range t.firstSeen { + hours[k] = 0 + } + + for chain, tokens := range balances { + home := homeTokens[chain] + for token, usd := range tokens { + // Contract-address keys duplicate the symbol entries. + if strings.HasPrefix(token, "0x") || len(token) > 12 { + continue + } + if usd <= strandedDustUSD || (home != nil && home[token]) { + delete(t.firstSeen, strandKey{chain, token}) + continue + } + k := strandKey{chain, token} + first, seen := t.firstSeen[k] + if !seen { + first = now + t.firstSeen[k] = first + } + hours[k] = now.Sub(first).Hours() + } + } + + // Drop firstSeen entries that vanished from the snapshot entirely. + for k := range t.firstSeen { + if _, still := hours[k]; !still { + delete(t.firstSeen, k) + } + } + for k, h := range hours { + if h == 0 { + delete(t.firstSeen, k) + } + } + return hours +} + +// StartReaper launches the hourly stuck-fund loop. The gauge side runs in +// every mode, including while BENCHMARK_PAUSED, because alerting must keep +// working during a pause. The corrective transfer only fires in production +// mode, unpaused, with broadcast-capable keys. +func StartReaper(bc *BalanceChecker, rebalancer *Rebalancer, slack *SlackNotifier, mode string, paused bool) { + if bc == nil { + log.Println("🧹 Reaper disabled: no balance checker") + return + } + + strandedHours := parseFloat(os.Getenv("REAPER_STRANDED_HOURS"), 6) + tracker := NewStrandedTracker() + + canAct := mode == "production" && !paused && rebalancer.canAct() + log.Printf("🧹 Stuck-fund reaper started (threshold %.0fh, acting=%v)", strandedHours, canAct) + + go func() { + // First evaluation immediately so gauges exist right after boot, then hourly. + reaperTick(bc, rebalancer, slack, tracker, strandedHours, canAct) + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + reaperTick(bc, rebalancer, slack, tracker, strandedHours, canAct) + } + }() +} + +func reaperTick(bc *BalanceChecker, rebalancer *Rebalancer, slack *SlackNotifier, + tracker *StrandedTracker, thresholdHours float64, canAct bool, +) { + balances, degraded, err := bc.GetAllBalancesDetailed() + if err != nil || degraded { + // Without trustworthy balances we cannot tell stranded from home, so + // neither the gauge nor a transfer would mean anything this tick. + log.Printf("🧹 Reaper tick skipped: balances unreadable (degraded=%v err=%v)", degraded, err) + return + } + + hours := tracker.Update(balances, time.Now().UTC()) + + // Home legs always report 0 so alert expressions have a baseline series. + for _, leg := range triangleLegs { + sym := leg.Token + if leg.Chain == "Arbitrum" { + sym = "USDT0" + } + bridgeStrandedHours.WithLabelValues(leg.Chain, sym).Set(0) + } + for k, h := range hours { + bridgeStrandedHours.WithLabelValues(k.Chain, k.Token).Set(h) + } + + // Pick the single worst offender above threshold; one transfer per tick + // keeps the blast radius of a bad tick to one capped TX. + var worst strandKey + worstHours := 0.0 + for k, h := range hours { + if h >= thresholdHours && h > worstHours { + worst, worstHours = k, h + } + } + if worstHours == 0 { + log.Printf("🧹 Reaper tick: no funds stranded beyond %.0fh", thresholdHours) + return + } + + amount := balances[worst.Chain][worst.Token] + log.Printf("🧹 Stranded funds: $%.2f %s on %s for %.1fh (threshold %.0fh)", + amount, worst.Token, worst.Chain, worstHours, thresholdHours) + + if !canAct { + // Read-only mode: the gauge above is the alert path, a human handles it. + return + } + + srcAddr, known := movableStranded[worst.Chain][worst.Token] + if !known { + _ = slack.NotifyReaper(fmt.Sprintf( + "$%.2f of %s stranded on %s for %.1fh, but I have no safe route for that asset. Manual rebalance needed (cmd/rebalance).", + amount, worst.Token, worst.Chain, worstHours)) + return + } + + if rebalancer.executor.config.DailySpentUSD >= rebalancer.executor.config.MaxDailySpendUSD { + _ = slack.NotifyReaper(fmt.Sprintf( + "$%.2f of %s stranded on %s for %.1fh, but daily spend limit is reached ($%.2f / $%.2f). Will retry next tick.", + amount, worst.Token, worst.Chain, worstHours, + rebalancer.executor.config.DailySpentUSD, rebalancer.executor.config.MaxDailySpendUSD)) + return + } + + dest := neediestHomeLeg(balances) + if amount > reaperMaxTransferUSD { + amount = reaperMaxTransferUSD + } + + route := TestRoute{ + Name: fmt.Sprintf("REAPER_%s_%s", strings.ToUpper(worst.Chain), strings.ToUpper(dest.Chain)), + FromChain: worst.Chain, + FromChainAPI: chainAPIFor(worst.Chain), + FromToken: srcAddr, + ToChain: dest.Chain, + ToChainAPI: dest.ChainAPI, + ToToken: dest.TokenAddr, + IsSolanaSrc: worst.Chain == "Solana", + } + + _ = slack.NotifyReaper(fmt.Sprintf( + "Moving $%.2f of stranded %s from %s (stuck %.1fh) home to %s %s via cheapest bridge.", + amount, worst.Token, worst.Chain, worstHours, dest.Chain, dest.Token)) + + result := rebalancer.ExecuteCheapest(route, amount) + if result == nil || !result.Success { + detail := "no bridge produced a usable quote" + if result != nil && result.Error != nil { + detail = result.Error.Error() + } + _ = slack.NotifyReaper(fmt.Sprintf("Corrective transfer FAILED: %s. Will retry next tick.", detail)) + return + } + + rebalancer.executor.config.DailySpentUSD += result.ActualFeeUSD + _ = slack.NotifyReaper(fmt.Sprintf( + "Corrective transfer succeeded via %s (fee $%.4f, tx %s).", result.Bridge, result.ActualFeeUSD, result.TxHash)) +} + +// neediestHomeLeg returns the triangle leg with the lowest balance: stranded +// funds should land where they unblock the next scheduled cycle soonest. +func neediestHomeLeg(balances map[string]map[string]float64) legSpec { + best := triangleLegs[0] + bestBal := legBalanceUSD(balances, best) + for _, leg := range triangleLegs[1:] { + if b := legBalanceUSD(balances, leg); b < bestBal { + best, bestBal = leg, b + } + } + return best +} + +func chainAPIFor(chain string) string { + if leg, ok := findLeg(chain); ok { + return leg.ChainAPI + } + return "" +} From bc7e169de911170204be45ce07f5d9531a2ec11b Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 15:13:19 +0200 Subject: [PATCH 10/50] bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default --- .../bridge-monitor/cmd/monitor/gas_topup.go | 197 ++++++++++++++++++ harnesses/bridge-monitor/cmd/monitor/main.go | 16 +- 2 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 harnesses/bridge-monitor/cmd/monitor/gas_topup.go diff --git a/harnesses/bridge-monitor/cmd/monitor/gas_topup.go b/harnesses/bridge-monitor/cmd/monitor/gas_topup.go new file mode 100644 index 00000000..1a9d2148 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/gas_topup.go @@ -0,0 +1,197 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + "time" +) + +// Gas auto-top-up: an execution slot can fail for the dumbest possible reason, +// no native gas to sign the deposit TX. Pre-flight already fetches balances, +// so we check SOL / Base ETH / Arb ETH against a USD floor and, when allowed, +// swap wallet USDC into native gas on that same chain via a LI.FI same-chain +// swap (their /v1/quote accepts fromChain == toChain and returns broadcastable +// calldata, same shape as a bridge quote). +// +// SAFETY GATE: GAS_TOPUP_ENABLED defaults to false. The plumbing below reuses +// the proven LiFi execution path, but the same-chain variant has not been +// exercised with real funds from this harness yet. Until someone runs one +// supervised top-up per chain and flips the env var, low gas only alerts. + +// LiFi native-token markers. +const ( + lifiEVMNative = "0x0000000000000000000000000000000000000000" + lifiSolanaNative = "11111111111111111111111111111111" +) + +type gasChain struct { + Chain string + NativeSym string + USDCSource string +} + +var gasChains = []gasChain{ + {Chain: "Solana", NativeSym: "SOL", USDCSource: solanaUSDCMint}, + {Chain: "Base", NativeSym: "ETH", USDCSource: baseUSDCAddr}, + {Chain: "Arbitrum", NativeSym: "ETH", USDCSource: arbUSDCAddr}, +} + +type GasTopper struct { + executor *Executor + slack *SlackNotifier + + enabled bool + minUSD float64 + topupUSD float64 + + // Per-chain USD swapped today; resets on UTC day change. Caps a runaway + // price-feed glitch to one top-up per chain per day. + spentToday map[string]float64 + day int +} + +func NewGasTopper(executor *Executor, slack *SlackNotifier) *GasTopper { + if executor == nil { + return nil + } + return &GasTopper{ + executor: executor, + slack: slack, + enabled: strings.EqualFold(strings.TrimSpace(os.Getenv("GAS_TOPUP_ENABLED")), "true"), + minUSD: parseFloat(os.Getenv("GAS_MIN_USD"), 15), + topupUSD: parseFloat(os.Getenv("GAS_TOPUP_USD"), 25), + spentToday: make(map[string]float64), + day: time.Now().UTC().YearDay(), + } +} + +// CheckAndTopUp inspects native gas on each chain and tops up where needed. +// Called from tier pre-flight with balances the caller already validated as +// non-degraded: a phantom zero here would otherwise trigger a pointless swap. +func (g *GasTopper) CheckAndTopUp(balances map[string]map[string]float64) { + if g == nil { + return + } + + if today := time.Now().UTC().YearDay(); today != g.day { + g.day = today + g.spentToday = make(map[string]float64) + } + + for _, gc := range gasChains { + gasUSD := balances[gc.Chain][gc.NativeSym] + if gasUSD >= g.minUSD { + continue + } + log.Printf("⛽ Low gas on %s: $%.2f %s (floor $%.2f)", gc.Chain, gasUSD, gc.NativeSym, g.minUSD) + + if !g.enabled { + bridgeGasTopup.WithLabelValues(gc.Chain, "gated").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "gated", fmt.Sprintf( + "Native gas is $%.2f, below the $%.2f floor, but GAS_TOPUP_ENABLED is off. Top up manually or enable after a supervised test.", + gasUSD, g.minUSD)) + continue + } + if g.executor.txExecutor == nil || !g.executor.txExecutor.CanExecute() { + // Dry-run or missing keys: detection is still useful in logs, but + // there is nothing safe to broadcast. + continue + } + if g.spentToday[gc.Chain] >= g.topupUSD { + bridgeGasTopup.WithLabelValues(gc.Chain, "capped").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "capped", fmt.Sprintf( + "Native gas is $%.2f but today's top-up budget ($%.2f) is already spent.", gasUSD, g.topupUSD)) + continue + } + + amount := g.topupUSD - g.spentToday[gc.Chain] + if amount > g.topupUSD { + amount = g.topupUSD + } + g.topUpChain(gc, amount, gasUSD) + } +} + +// topUpChain swaps `amountUSD` of USDC into native gas on one chain. +func (g *GasTopper) topUpChain(gc gasChain, amountUSD, gasUSD float64) { + bridgeGasTopup.WithLabelValues(gc.Chain, "attempted").Inc() + + toToken := lifiEVMNative + sender := g.executor.walletManager.EVMAddress + if gc.Chain == "Solana" { + toToken = lifiSolanaNative + sender = g.executor.walletManager.SolanaAddress + } + + route := TestRoute{ + Name: fmt.Sprintf("GAS_TOPUP_%s", strings.ToUpper(gc.Chain)), + FromChain: gc.Chain, + FromChainAPI: chainAPIFor(gc.Chain), + FromToken: gc.USDCSource, + ToChain: gc.Chain, + ToChainAPI: chainAPIFor(gc.Chain), + ToToken: toToken, + IsSolanaSrc: gc.Chain == "Solana", + } + rawUnits := toRawUnits(amountUSD) + + quote, _, err := g.executor.lifi.GetQuote(route, rawUnits, sender, sender) + if err != nil { + bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", fmt.Sprintf( + "LI.FI same-chain swap quote failed (gas $%.2f, wanted $%.2f USDC to %s): %v", gasUSD, amountUSD, gc.NativeSym, err)) + return + } + + // EVM swaps of ERC-20 input need the router approved first, same as bridges. + if quote.Estimate.ApprovalAddress != "" && gc.Chain != "Solana" { + approvalHash, err := g.executor.txExecutor.ApproveERC20(gc.Chain, quote.Action.FromToken.Address, quote.Estimate.ApprovalAddress, quote.Action.FromAmount) + if err != nil { + bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", fmt.Sprintf("USDC approval failed: %v", err)) + return + } + time.Sleep(5 * time.Second) + if ok, err := g.executor.txExecutor.CheckEVMTxStatus(gc.Chain, approvalHash); err != nil || !ok { + bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", "USDC approval not confirmed") + return + } + } + + var txHash string + if gc.Chain == "Solana" { + txHash, err = g.executor.txExecutor.ExecuteSolanaTransaction(quote.TransactionRequest.Data) + } else { + txHash, err = g.executor.txExecutor.ExecuteEVMTransaction(gc.Chain, quote.TransactionRequest.To, quote.TransactionRequest.Data, quote.TransactionRequest.Value) + } + if err != nil { + bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", fmt.Sprintf("Broadcast failed: %v", err)) + return + } + + // Same-chain swaps settle in one TX: a confirmed receipt is a fill, no + // bridge status polling needed. Solana broadcast acceptance is our signal. + if gc.Chain != "Solana" { + time.Sleep(8 * time.Second) + if ok, err := g.executor.txExecutor.CheckEVMTxStatus(gc.Chain, txHash); err != nil || !ok { + bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", fmt.Sprintf("Swap TX not confirmed or reverted: %s", txHash)) + return + } + } + + g.spentToday[gc.Chain] += amountUSD + fee := 0.0 + for _, f := range quote.Estimate.FeeCosts { + fee += parseFloatOrZero(f.AmountUSD) + } + g.executor.config.DailySpentUSD += fee + bridgeGasTopup.WithLabelValues(gc.Chain, "succeeded").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "succeeded", fmt.Sprintf( + "Swapped $%.2f USDC to %s (was $%.2f, fee $%.4f, tx %s). Daily budget used: $%.2f / $%.2f.", + amountUSD, gc.NativeSym, gasUSD, fee, txHash, g.spentToday[gc.Chain], g.topupUSD)) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index 18c5815a..d7d7eaa4 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -343,8 +343,10 @@ func main() { // unless the executor can actually broadcast (production mode, keys present), // so it costs nothing in dry-run or while paused. var rebalancer *Rebalancer + var gasTopper *GasTopper if executor != nil { rebalancer = NewRebalancer(executor, slackNotifier) + gasTopper = NewGasTopper(executor, slackNotifier) } // Stuck-fund reaper: gauges in every mode (alerting must survive a pause), @@ -360,7 +362,7 @@ func main() { case <-getSchedulerChan(scheduler, "$5"): // $5 execution loop - daily at 10:00 UTC if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, GetTriangleRoutes(), 5.0, "daily") + runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 5.0, "daily") // Meme routes use independent capital (TRUMP) — always attempt, // the per-route RunReal check catches insufficient TRUMP. @@ -376,12 +378,12 @@ func main() { case <-getSchedulerChan(scheduler, "$50"): if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, GetTriangleRoutes(), 50.0, "Mon+Thu") + runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 50.0, "Mon+Thu") } case <-getSchedulerChan(scheduler, "$300"): if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, GetTriangleRoutes(), 300.0, "Mon weekly") + runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 300.0, "Mon weekly") } } } @@ -411,7 +413,7 @@ func downgradeLadder(tier float64) []float64 { // message and skip — next scheduler tick will retry. Returns true if any // amount actually ran. func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifier, - rebalancer *Rebalancer, routes []TestRoute, tier float64, tierLabel string, + rebalancer *Rebalancer, gasTopper *GasTopper, routes []TestRoute, tier float64, tierLabel string, ) bool { if bc == nil { log.Printf("⚠️ No balance checker — skipping tier $%.0f pre-flight", tier) @@ -434,6 +436,12 @@ func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifie return false } + // Gas check once per slot: the same balances snapshot already carries + // SOL / ETH, and an execution without gas fails later anyway. + if i == 0 { + gasTopper.CheckAndTopUp(balances) + } + sim := SimulateTriangleCycle(balances, amount) if !sim.Viable && rebalancer != nil { sim, _ = rebalancer.TryUnblockTier(sim, balances, tierLabel) From 9626ac4abfff89f4615a44c607996df21e7ed039 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 15:14:35 +0200 Subject: [PATCH 11/50] bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset --- .../bridge-monitor/cmd/monitor/reaper.go | 20 +- .../cmd/monitor/selfheal_test.go | 182 ++++++++++++++++++ 2 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 harnesses/bridge-monitor/cmd/monitor/selfheal_test.go diff --git a/harnesses/bridge-monitor/cmd/monitor/reaper.go b/harnesses/bridge-monitor/cmd/monitor/reaper.go index da6cec88..c2ea8eee 100644 --- a/harnesses/bridge-monitor/cmd/monitor/reaper.go +++ b/harnesses/bridge-monitor/cmd/monitor/reaper.go @@ -63,11 +63,7 @@ func NewStrandedTracker() *StrandedTracker { // the exported gauge resets. Pure given (balances, now), unit-tested. func (t *StrandedTracker) Update(balances map[string]map[string]float64, now time.Time) map[strandKey]float64 { hours := make(map[strandKey]float64) - - // Zero out anything previously tracked; re-add below if still stranded. - for k := range t.firstSeen { - hours[k] = 0 - } + current := make(map[strandKey]bool) for chain, tokens := range balances { home := homeTokens[chain] @@ -77,10 +73,10 @@ func (t *StrandedTracker) Update(balances map[string]map[string]float64, now tim continue } if usd <= strandedDustUSD || (home != nil && home[token]) { - delete(t.firstSeen, strandKey{chain, token}) continue } k := strandKey{chain, token} + current[k] = true first, seen := t.firstSeen[k] if !seen { first = now @@ -90,14 +86,12 @@ func (t *StrandedTracker) Update(balances map[string]map[string]float64, now tim } } - // Drop firstSeen entries that vanished from the snapshot entirely. + // Keys that just came home report an explicit 0 (so the exported gauge + // resets) and lose their firstSeen entry (so a later re-strand restarts + // the clock instead of inheriting the old one). for k := range t.firstSeen { - if _, still := hours[k]; !still { - delete(t.firstSeen, k) - } - } - for k, h := range hours { - if h == 0 { + if !current[k] { + hours[k] = 0 delete(t.firstSeen, k) } } diff --git a/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go new file mode 100644 index 00000000..1731bb75 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "math" + "testing" + "time" +) + +func balancesSnapshot(sol, base, arb float64) map[string]map[string]float64 { + return map[string]map[string]float64{ + "Solana": {"USDC": sol, "SOL": 30}, + "Base": {"USDC": base, "ETH": 40}, + "Arbitrum": {"USDT0": arb, "ETH": 25}, + } +} + +func TestBuildRefillRouteSourceSelection(t *testing.T) { + // Solana leg is short for a $50 tier; Base holds the biggest surplus so it + // must be picked as the source, not Arbitrum. + balances := balancesSnapshot(10, 400, 120) + sim := SimulateTriangleCycle(balances, 50) + if sim.Viable { + t.Fatal("expected $50 cycle to be blocked on Solana") + } + if sim.RefillChain != "Solana" { + t.Fatalf("expected refill chain Solana, got %s", sim.RefillChain) + } + + route, amount, err := BuildRefillRoute(sim, balances) + if err != nil { + t.Fatalf("BuildRefillRoute failed: %v", err) + } + if route.FromChain != "Base" { + t.Errorf("expected source Base (largest surplus), got %s", route.FromChain) + } + if route.ToChain != "Solana" { + t.Errorf("expected destination Solana, got %s", route.ToChain) + } + if route.FromToken != baseUSDCAddr { + t.Errorf("expected Base USDC source token, got %s", route.FromToken) + } + if route.ToToken != solanaUSDCMint { + t.Errorf("expected Solana USDC destination token, got %s", route.ToToken) + } + if !route.IsSolanaSrc == (route.FromChain == "Solana") { + t.Error("IsSolanaSrc inconsistent with FromChain") + } + + // Shortfall is 50 - 10 = 40, buffered by 10 percent. + want := sim.RefillUSD * rebalanceBufferFactor + if math.Abs(amount-want) > 0.01 { + t.Errorf("expected amount %.2f (need + 10%% buffer), got %.2f", want, amount) + } + if amount > 400-50 { + t.Errorf("amount %.2f exceeds Base surplus", amount) + } +} + +func TestBuildRefillRouteCapsAtSurplus(t *testing.T) { + // Surplus barely covers the shortfall: the buffered amount must be clamped + // to the surplus instead of overdrawing the source leg. + balances := balancesSnapshot(0, 55.5, 5) + sim := SimulateTriangleCycle(balances, 5) + if sim.Viable { + t.Fatal("expected $5 cycle to be blocked on Solana") + } + route, amount, err := BuildRefillRoute(sim, balances) + if err != nil { + t.Fatalf("BuildRefillRoute failed: %v", err) + } + if route.FromChain != "Base" { + t.Fatalf("expected source Base, got %s", route.FromChain) + } + surplus := 55.5 - 5 + if amount > surplus+0.001 { + t.Errorf("amount %.2f exceeds surplus %.2f", amount, surplus) + } +} + +func TestBuildRefillRouteNoExcess(t *testing.T) { + // Every leg is broke: refusing is the only safe answer. + balances := balancesSnapshot(1, 2, 3) + sim := SimulateTriangleCycle(balances, 50) + if _, _, err := BuildRefillRoute(sim, balances); err == nil { + t.Fatal("expected error when no leg holds excess inventory") + } +} + +func TestBuildRefillRouteViableRejected(t *testing.T) { + balances := balancesSnapshot(500, 500, 500) + sim := SimulateTriangleCycle(balances, 50) + if !sim.Viable { + t.Fatal("expected viable cycle") + } + if _, _, err := BuildRefillRoute(sim, balances); err == nil { + t.Fatal("expected error for viable simulation") + } +} + +func TestDowngradeLadder(t *testing.T) { + cases := []struct { + tier float64 + want []float64 + }{ + {300, []float64{300, 50, 5}}, + {50, []float64{50, 5}}, + {5, []float64{5}}, + } + for _, c := range cases { + got := downgradeLadder(c.tier) + if len(got) != len(c.want) { + t.Fatalf("tier %.0f: got %v want %v", c.tier, got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("tier %.0f: got %v want %v", c.tier, got, c.want) + break + } + } + } +} + +func TestStrandedHoursComputation(t *testing.T) { + tracker := NewStrandedTracker() + t0 := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC) + + // USDC on Arbitrum is off-home (triangle expects USDT there). + balances := balancesSnapshot(100, 100, 100) + balances["Arbitrum"]["USDC"] = 42 + + hours := tracker.Update(balances, t0) + k := strandKey{"Arbitrum", "USDC"} + if hours[k] != 0 { + t.Errorf("first sighting should report 0 hours, got %.2f", hours[k]) + } + // Home legs never appear as stranded. + if _, ok := hours[strandKey{"Solana", "USDC"}]; ok { + t.Error("home leg Solana/USDC reported as stranded") + } + if _, ok := hours[strandKey{"Arbitrum", "USDT0"}]; ok { + t.Error("home leg Arbitrum/USDT0 reported as stranded") + } + + // 7 hours later the same balance is still there: above the 6h default. + hours = tracker.Update(balances, t0.Add(7*time.Hour)) + if math.Abs(hours[k]-7) > 0.001 { + t.Errorf("expected 7 stranded hours, got %.2f", hours[k]) + } + + // Funds moved home: the key must report an explicit 0 so the gauge resets, + // and the clock must restart if it strands again later. + delete(balances["Arbitrum"], "USDC") + hours = tracker.Update(balances, t0.Add(8*time.Hour)) + if hours[k] != 0 { + t.Errorf("expected explicit 0 after funds came home, got %.2f", hours[k]) + } + + balances["Arbitrum"]["USDC"] = 42 + hours = tracker.Update(balances, t0.Add(20*time.Hour)) + if hours[k] != 0 { + t.Errorf("re-stranding must restart the clock at 0, got %.2f", hours[k]) + } +} + +func TestStrandedIgnoresDustAndAddressKeys(t *testing.T) { + tracker := NewStrandedTracker() + now := time.Now().UTC() + + balances := balancesSnapshot(100, 100, 100) + balances["Arbitrum"]["USDC"] = 0.5 + balances["Arbitrum"][arbUSDCAddr] = 5000 + + hours := tracker.Update(balances, now) + if _, ok := hours[strandKey{"Arbitrum", "USDC"}]; ok { + t.Error("dust below $1 must not count as stranded") + } + for k := range hours { + if len(k.Token) > 12 { + t.Errorf("contract-address key leaked into stranded set: %v", k) + } + } +} From 3b55ed372d834bb8986c0149e5d7fbbe9359e964 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 15:16:06 +0200 Subject: [PATCH 12/50] bridge-monitor: keyless dry-run support for reaper + RunDryRun --- .../bridge-monitor/cmd/monitor/executor.go | 19 +++++++++++++----- .../bridge-monitor/cmd/monitor/reaper.go | 20 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/harnesses/bridge-monitor/cmd/monitor/executor.go b/harnesses/bridge-monitor/cmd/monitor/executor.go index 34dfde66..d88a0770 100644 --- a/harnesses/bridge-monitor/cmd/monitor/executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/executor.go @@ -133,12 +133,21 @@ func (e *Executor) RunDryRun(route TestRoute, amountUSD float64) *ExecutionResul log.Printf("🧪 [DRY-RUN] Testing %s with $%.0f", route.Name, amountUSD) - // Step 1: Check balances + // Step 1: Check balances. Keyless dry-run has no balance checker, so fall + // back to the SIMULATE_BALANCES snapshot instead of crashing. log.Printf(" 📊 Checking balances...") - balances, err := e.balanceCheck.GetAllBalances() - if err != nil { - log.Printf(" ❌ Balance check failed: %v", err) - result.Error = err + var balances map[string]map[string]float64 + if e.balanceCheck != nil { + var err error + balances, err = e.balanceCheck.GetAllBalances() + if err != nil { + log.Printf(" ❌ Balance check failed: %v", err) + result.Error = err + return result + } + } else if balances = SimulateBalances(); balances == nil { + log.Printf(" ❌ No balance checker and SIMULATE_BALANCES not set") + result.Error = fmt.Errorf("no balance source in dry-run") return result } diff --git a/harnesses/bridge-monitor/cmd/monitor/reaper.go b/harnesses/bridge-monitor/cmd/monitor/reaper.go index c2ea8eee..d40f5f6a 100644 --- a/harnesses/bridge-monitor/cmd/monitor/reaper.go +++ b/harnesses/bridge-monitor/cmd/monitor/reaper.go @@ -103,7 +103,17 @@ func (t *StrandedTracker) Update(balances map[string]map[string]float64, now tim // working during a pause. The corrective transfer only fires in production // mode, unpaused, with broadcast-capable keys. func StartReaper(bc *BalanceChecker, rebalancer *Rebalancer, slack *SlackNotifier, mode string, paused bool) { - if bc == nil { + // Balance source: real checker in normal operation, the SIMULATE_BALANCES + // snapshot in keyless dry-run so the gauge path stays testable locally. + var fetch func() (map[string]map[string]float64, bool, error) + switch { + case bc != nil: + fetch = bc.GetAllBalancesDetailed + case SimulateBalances() != nil: + fetch = func() (map[string]map[string]float64, bool, error) { + return SimulateBalances(), false, nil + } + default: log.Println("🧹 Reaper disabled: no balance checker") return } @@ -116,19 +126,19 @@ func StartReaper(bc *BalanceChecker, rebalancer *Rebalancer, slack *SlackNotifie go func() { // First evaluation immediately so gauges exist right after boot, then hourly. - reaperTick(bc, rebalancer, slack, tracker, strandedHours, canAct) + reaperTick(fetch, rebalancer, slack, tracker, strandedHours, canAct) ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for range ticker.C { - reaperTick(bc, rebalancer, slack, tracker, strandedHours, canAct) + reaperTick(fetch, rebalancer, slack, tracker, strandedHours, canAct) } }() } -func reaperTick(bc *BalanceChecker, rebalancer *Rebalancer, slack *SlackNotifier, +func reaperTick(fetch func() (map[string]map[string]float64, bool, error), rebalancer *Rebalancer, slack *SlackNotifier, tracker *StrandedTracker, thresholdHours float64, canAct bool, ) { - balances, degraded, err := bc.GetAllBalancesDetailed() + balances, degraded, err := fetch() if err != nil || degraded { // Without trustworthy balances we cannot tell stranded from home, so // neither the gauge nor a transfer would mean anything this tick. From ed084d84d4464a0ec1ff99a11dd17c61531b4a9e Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 15:18:47 +0200 Subject: [PATCH 13/50] bridge-monitor: comment style cleanup in pre-flight docs --- harnesses/bridge-monitor/cmd/monitor/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index d7d7eaa4..037ee994 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -410,7 +410,7 @@ func downgradeLadder(tier float64) []float64 { // simulation says the cycle cannot complete, it first lets the auto-rebalancer // try to unblock the tier, then walks the downgrade ladder to a smaller amount. // Only if nothing on the ladder is viable does it emit ONE Slack "couldn't run" -// message and skip — next scheduler tick will retry. Returns true if any +// message and skip, so the next scheduler tick retries. Returns true if any // amount actually ran. func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifier, rebalancer *Rebalancer, gasTopper *GasTopper, routes []TestRoute, tier float64, tierLabel string, From d2c3d0037a3e2caacd404799456474e790008e1d Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 16:03:26 +0200 Subject: [PATCH 14/50] bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive DailySpentUSD only ever incremented and was zeroed by every restart, so a crash-loop minted a fresh budget each time. The tracker resets exactly at UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default ./spend-state.json, not /tmp) and is mutex-guarded. Also declares the package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD (default 0.50) helper for gas bled by failed broadcasts. --- harnesses/bridge-monitor/.gitignore | 2 + .../cmd/monitor/spend_tracker.go | 124 ++++++++++++++++++ .../cmd/monitor/spend_tracker_test.go | 79 +++++++++++ 3 files changed, 205 insertions(+) create mode 100644 harnesses/bridge-monitor/cmd/monitor/spend_tracker.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/spend_tracker_test.go diff --git a/harnesses/bridge-monitor/.gitignore b/harnesses/bridge-monitor/.gitignore index 091b86f3..ad59826f 100644 --- a/harnesses/bridge-monitor/.gitignore +++ b/harnesses/bridge-monitor/.gitignore @@ -1,5 +1,7 @@ .env bin/ +spend-state.json +spend-state.json.tmp *.log prometheus_data/ grafana_data/ diff --git a/harnesses/bridge-monitor/cmd/monitor/spend_tracker.go b/harnesses/bridge-monitor/cmd/monitor/spend_tracker.go new file mode 100644 index 00000000..7a885299 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/spend_tracker.go @@ -0,0 +1,124 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "strings" + "sync" + "time" +) + +// execMu is the package-level single-flight lock for every code path that can +// broadcast a transaction: scheduled triangle runs, the auto-rebalancer, the +// stuck-fund reaper and gas top-ups. All of these actors sign with the same +// two wallets, so two concurrent broadcasts race on PendingNonceAt and can +// reuse a nonce. Scheduler slots take the lock for the whole slot; the reaper +// only TryLocks and skips its corrective action when the lock is busy, it +// never queues behind a running slot. +var execMu sync.Mutex + +// SpendTracker accounts spending against the daily cap. The counter is keyed +// by UTC date so the budget resets exactly at midnight UTC, and the +// (date, spent) pair is persisted to disk so a process restart within the +// same UTC day keeps the consumed budget instead of minting a fresh one +// (a crash-looping process used to reset the counter on every start). +type SpendTracker struct { + mu sync.Mutex + path string + now func() time.Time + date string + spent float64 +} + +type spendState struct { + Date string `json:"date"` + SpentUSD float64 `json:"spent_usd"` +} + +// NewSpendTracker loads persisted state from path. A nil clock uses time.Now; +// tests inject a fake clock to drive the date rollover. State recorded on a +// different UTC date is discarded on load. +func NewSpendTracker(path string, now func() time.Time) *SpendTracker { + if now == nil { + now = time.Now + } + t := &SpendTracker{path: path, now: now} + t.date = t.utcDate() + if data, err := os.ReadFile(path); err == nil { + var s spendState + if json.Unmarshal(data, &s) == nil && s.Date == t.date && s.SpentUSD > 0 { + t.spent = s.SpentUSD + log.Printf("💾 Loaded daily spend state: $%.2f already spent on %s (%s)", t.spent, t.date, path) + } + } + return t +} + +func (t *SpendTracker) utcDate() string { + return t.now().UTC().Format("2006-01-02") +} + +// rolloverLocked resets the counter when the UTC date has changed since the +// last access. Callers must hold t.mu. +func (t *SpendTracker) rolloverLocked() { + if d := t.utcDate(); d != t.date { + log.Printf("💾 Daily spend reset: %s -> %s (was $%.2f)", t.date, d, t.spent) + t.date = d + t.spent = 0 + t.saveLocked() + } +} + +// Add books usd against today's budget and persists the new total. +func (t *SpendTracker) Add(usd float64) { + if usd <= 0 { + return + } + t.mu.Lock() + defer t.mu.Unlock() + t.rolloverLocked() + t.spent += usd + t.saveLocked() +} + +// Spent returns today's consumed budget, applying the UTC rollover first. +func (t *SpendTracker) Spent() float64 { + t.mu.Lock() + defer t.mu.Unlock() + t.rolloverLocked() + return t.spent +} + +// saveLocked persists atomically (write temp, rename). Callers hold t.mu. +func (t *SpendTracker) saveLocked() { + data, err := json.Marshal(spendState{Date: t.date, SpentUSD: t.spent}) + if err != nil { + return + } + tmp := t.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + log.Printf("⚠️ Failed to persist spend state to %s: %v", tmp, err) + return + } + if err := os.Rename(tmp, t.path); err != nil { + log.Printf("⚠️ Failed to persist spend state to %s: %v", t.path, err) + } +} + +// spendStatePath resolves where the (date, spent) pair lives. Defaults to a +// path relative to the working directory (NOT /tmp, which some distros wipe +// on a timer) so restarts on the same host see the same file. +func spendStatePath() string { + if p := strings.TrimSpace(os.Getenv("SPEND_STATE_PATH")); p != "" { + return p + } + return "./spend-state.json" +} + +// failedTxFeeEstimateUSD is booked against the daily cap for any broadcast +// that produced a TxHash but never confirmed as a success: the deposit or +// approval TX most likely paid gas even though the bridge never filled. +func failedTxFeeEstimateUSD() float64 { + return parseFloat(os.Getenv("FAILED_TX_FEE_ESTIMATE_USD"), 0.50) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/spend_tracker_test.go b/harnesses/bridge-monitor/cmd/monitor/spend_tracker_test.go new file mode 100644 index 00000000..f7637d8e --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/spend_tracker_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "math" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSpendTrackerUTCRollover(t *testing.T) { + path := filepath.Join(t.TempDir(), "spend-state.json") + now := time.Date(2026, 7, 12, 23, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + + tr := NewSpendTracker(path, clock) + tr.Add(3.5) + if got := tr.Spent(); math.Abs(got-3.5) > 1e-9 { + t.Fatalf("expected 3.5 spent, got %v", got) + } + + // A restart within the same UTC day must keep the consumed budget: a + // crash-loop must not mint a fresh budget. + tr2 := NewSpendTracker(path, clock) + if got := tr2.Spent(); math.Abs(got-3.5) > 1e-9 { + t.Fatalf("restart lost same-day spend: got %v, want 3.5", got) + } + + // Crossing midnight UTC resets the counter without a restart. + now = time.Date(2026, 7, 13, 1, 0, 0, 0, time.UTC) + if got := tr2.Spent(); got != 0 { + t.Fatalf("expected 0 after UTC date rollover, got %v", got) + } + tr2.Add(1.25) + + // A restart on the new day loads the new day's spend. + tr3 := NewSpendTracker(path, clock) + if got := tr3.Spent(); math.Abs(got-1.25) > 1e-9 { + t.Fatalf("restart on new day: got %v, want 1.25", got) + } +} + +func TestSpendTrackerDiscardsStaleState(t *testing.T) { + path := filepath.Join(t.TempDir(), "spend-state.json") + if err := os.WriteFile(path, []byte(`{"date":"2026-07-11","spent_usd":9.99}`), 0o644); err != nil { + t.Fatal(err) + } + clock := func() time.Time { return time.Date(2026, 7, 12, 8, 0, 0, 0, time.UTC) } + tr := NewSpendTracker(path, clock) + if got := tr.Spent(); got != 0 { + t.Fatalf("yesterday's spend must not carry over, got %v", got) + } +} + +func TestSpendTrackerIgnoresCorruptState(t *testing.T) { + path := filepath.Join(t.TempDir(), "spend-state.json") + if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + tr := NewSpendTracker(path, nil) + if got := tr.Spent(); got != 0 { + t.Fatalf("corrupt state must read as 0, got %v", got) + } + tr.Add(2) + if got := tr.Spent(); math.Abs(got-2) > 1e-9 { + t.Fatalf("expected 2 after Add, got %v", got) + } +} + +func TestFailedTxFeeEstimateDefault(t *testing.T) { + t.Setenv("FAILED_TX_FEE_ESTIMATE_USD", "") + if got := failedTxFeeEstimateUSD(); math.Abs(got-0.50) > 1e-9 { + t.Fatalf("default estimate must be $0.50, got %v", got) + } + t.Setenv("FAILED_TX_FEE_ESTIMATE_USD", "1.25") + if got := failedTxFeeEstimateUSD(); math.Abs(got-1.25) > 1e-9 { + t.Fatalf("env override not honored, got %v", got) + } +} From b7b3b08e16e0fee07b521928c582da5b025cee84 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 16:04:46 +0200 Subject: [PATCH 15/50] bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock Fund-loss fixes from hostile review: - a corrective transfer whose deposit broadcast but whose bridge status never resolved is now TERMINAL for the scheduler slot (funds are likely still in flight, a retry double-sends); only pre-broadcast failures may consume another attempt. executeOnBridge keeps TxHash on error paths so callers can tell the two apart. - ONE corrective-transfer budget (max 2) per scheduler slot, shared across all downgrade-ladder rungs (was per rung: up to 6 transfers per slot). - package-level execution mutex: scheduler slots own the wallets end to end, the reaper TryLocks and skips its tick instead of queueing. - broadcasts that failed or timed out after getting a TxHash book a flat FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding unaccounted gas; all spend goes through the persisted UTC-day tracker. - gas top-up now honors MaxDailySpendUSD before broadcasting. --- .../bridge-monitor/cmd/monitor/executor.go | 81 +++++++--- .../bridge-monitor/cmd/monitor/gas_topup.go | 17 +- harnesses/bridge-monitor/cmd/monitor/main.go | 17 +- .../bridge-monitor/cmd/monitor/metrics.go | 7 +- .../bridge-monitor/cmd/monitor/reaper.go | 33 ++-- .../bridge-monitor/cmd/monitor/rebalancer.go | 147 ++++++++++++++---- .../cmd/monitor/selfheal_test.go | 69 ++++++++ 7 files changed, 306 insertions(+), 65 deletions(-) diff --git a/harnesses/bridge-monitor/cmd/monitor/executor.go b/harnesses/bridge-monitor/cmd/monitor/executor.go index d88a0770..ca0bd514 100644 --- a/harnesses/bridge-monitor/cmd/monitor/executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/executor.go @@ -18,16 +18,17 @@ const ( ModeProduction ExecutionMode = "production" // Full execution loop ) -// ExecutionConfig holds the execution loop configuration +// ExecutionConfig holds the execution loop configuration. +// Daily spend tracking lives in the Executor's SpendTracker (UTC-date keyed, +// persisted to disk), not here: a plain struct field never reset and was +// zeroed by every restart. type ExecutionConfig struct { - Mode ExecutionMode - Freq5USD time.Duration // How often to run $5 tests - Freq50USD time.Duration // How often to run $50 tests - Freq300USD time.Duration // How often to run $300 tests - EnableDebridge bool // Whether to execute Debridge (expensive) - MaxDailySpendUSD float64 // Safety cap on daily spending - DailySpentUSD float64 // Track daily spending - LastResetDay int // Day of month for daily reset + Mode ExecutionMode + Freq5USD time.Duration // How often to run $5 tests + Freq50USD time.Duration // How often to run $50 tests + Freq300USD time.Duration // How often to run $300 tests + EnableDebridge bool // Whether to execute Debridge (expensive) + MaxDailySpendUSD float64 // Safety cap on daily spending } // ExecutionResult holds the result of an execution test @@ -68,6 +69,36 @@ type Executor struct { debridge *DebridgeBridge region string slack *SlackNotifier + spend *SpendTracker +} + +// DailySpent returns today's consumed budget via the mutex-guarded tracker. +// All cap checks must go through here, never through a raw field, so the +// reaper goroutine and the scheduler loop cannot race on the counter. +func (e *Executor) DailySpent() float64 { + return e.spend.Spent() +} + +// AddDailySpend books usd against today's budget (UTC-date keyed, persisted). +func (e *Executor) AddDailySpend(usd float64) { + e.spend.Add(usd) +} + +// accountSpend books a broadcast's cost against the daily cap. A successful +// fill books the realized fee. Any broadcast that produced a TxHash but did +// not confirm as a success books a conservative flat estimate, because the +// deposit or approval TX most likely burned gas even without a fill. Results +// that never broadcast cost nothing. +func (e *Executor) accountSpend(result *ExecutionResult) { + if result == nil || result.DryRun { + return + } + switch { + case result.Success: + e.AddDailySpend(result.ActualFeeUSD) + case result.TxHash != "": + e.AddDailySpend(failedTxFeeEstimateUSD()) + } } // NewExecutor creates a new executor @@ -92,6 +123,7 @@ func NewExecutor( debridge: debridge, region: region, slack: slack, + spend: NewSpendTracker(spendStatePath(), time.Now), } // Initialize TxExecutor if we have private keys @@ -212,8 +244,8 @@ func (e *Executor) RunReal(route TestRoute, amountUSD float64) []*ExecutionResul } // Check daily spending limit - if e.config.DailySpentUSD >= e.config.MaxDailySpendUSD { - msg := fmt.Sprintf("Daily spending limit reached ($%.2f / $%.2f)", e.config.DailySpentUSD, e.config.MaxDailySpendUSD) + if spent := e.DailySpent(); spent >= e.config.MaxDailySpendUSD { + msg := fmt.Sprintf("Daily spending limit reached ($%.2f / $%.2f)", spent, e.config.MaxDailySpendUSD) log.Printf("⚠️ %s", msg) if e.slack != nil { _ = e.slack.NotifyScheduledSkip(route.Name, route.FromChain, route.FromToken, amountUSD, msg) @@ -275,10 +307,9 @@ func (e *Executor) RunReal(route TestRoute, amountUSD float64) []*ExecutionResul } } - // Update daily spending - if result.Success { - e.config.DailySpentUSD += result.ActualFeeUSD - } + // Update daily spending (realized fee on success, flat gas + // estimate on a broadcast that never confirmed). + e.accountSpend(result) } // Wait between bridges to avoid rate limiting @@ -296,8 +327,8 @@ func (e *Executor) RunBridgeOnRoute(bridge string, route TestRoute, amountUSD fl if e.txExecutor == nil || !e.txExecutor.CanExecute() { return nil } - if e.config.DailySpentUSD >= e.config.MaxDailySpendUSD { - log.Printf("⚠️ Daily spending limit reached ($%.2f / $%.2f)", e.config.DailySpentUSD, e.config.MaxDailySpendUSD) + if spent := e.DailySpent(); spent >= e.config.MaxDailySpendUSD { + log.Printf("⚠️ Daily spending limit reached ($%.2f / $%.2f)", spent, e.config.MaxDailySpendUSD) return nil } @@ -320,9 +351,7 @@ func (e *Executor) RunBridgeOnRoute(bridge string, route TestRoute, amountUSD fl } } - if result.Success { - e.config.DailySpentUSD += result.ActualFeeUSD - } + e.accountSpend(result) return result } @@ -370,13 +399,17 @@ func (e *Executor) executeOnBridge(bridge string, route TestRoute, amount, amoun result.ToToken = route.ToToken result.AmountUSD = amountUSD + // Keep the TxHash even when the attempt errored out: callers use it to + // tell a pre-broadcast failure (safe to retry) from a broadcast whose + // final status is unknown (terminal, funds may still be in flight) and + // to account the gas a failed TX still burned. + result.TxHash = txHash + if err != nil { log.Printf(" ❌ Execution failed: %v", err) result.Error = err return result } - - result.TxHash = txHash // If the sub-function flagged a refund/revert, keep Success=false so Slack and // Prometheus correctly classify it (Reverted takes precedence over Success). result.Success = !result.Reverted @@ -1018,8 +1051,8 @@ func (e *Executor) testBridgeDryRun(bridge string, route TestRoute, amountUSD fl log.Printf(" 💰 Estimated cost: $%.4f", expectedCost) // Update daily spending tracker (even in dry-run for estimation) - e.config.DailySpentUSD += expectedCost - log.Printf(" 📈 Daily spend estimate: $%.2f / $%.2f max", e.config.DailySpentUSD, e.config.MaxDailySpendUSD) + e.AddDailySpend(expectedCost) + log.Printf(" 📈 Daily spend estimate: $%.2f / $%.2f max", e.DailySpent(), e.config.MaxDailySpendUSD) } // ValidateSetup checks that everything is configured correctly diff --git a/harnesses/bridge-monitor/cmd/monitor/gas_topup.go b/harnesses/bridge-monitor/cmd/monitor/gas_topup.go index 1a9d2148..5e244ff6 100644 --- a/harnesses/bridge-monitor/cmd/monitor/gas_topup.go +++ b/harnesses/bridge-monitor/cmd/monitor/gas_topup.go @@ -105,6 +105,15 @@ func (g *GasTopper) CheckAndTopUp(balances map[string]map[string]float64) { "Native gas is $%.2f but today's top-up budget ($%.2f) is already spent.", gasUSD, g.topupUSD)) continue } + // Same daily-cap pre-check the executor applies before any broadcast: + // a top-up is still spend and must not blow past MaxDailySpendUSD. + if spent := g.executor.DailySpent(); spent >= g.executor.config.MaxDailySpendUSD { + bridgeGasTopup.WithLabelValues(gc.Chain, "capped").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "capped", fmt.Sprintf( + "Native gas is $%.2f but the daily spend limit is reached ($%.2f / $%.2f). No top-up attempted.", + gasUSD, spent, g.executor.config.MaxDailySpendUSD)) + continue + } amount := g.topupUSD - g.spentToday[gc.Chain] if amount > g.topupUSD { @@ -156,6 +165,9 @@ func (g *GasTopper) topUpChain(gc gasChain, amountUSD, gasUSD float64) { time.Sleep(5 * time.Second) if ok, err := g.executor.txExecutor.CheckEVMTxStatus(gc.Chain, approvalHash); err != nil || !ok { bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + // The approval TX was broadcast and likely paid gas even though + // it never confirmed: book the conservative estimate. + g.executor.AddDailySpend(failedTxFeeEstimateUSD()) _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", "USDC approval not confirmed") return } @@ -179,6 +191,9 @@ func (g *GasTopper) topUpChain(gc gasChain, amountUSD, gasUSD float64) { time.Sleep(8 * time.Second) if ok, err := g.executor.txExecutor.CheckEVMTxStatus(gc.Chain, txHash); err != nil || !ok { bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + // Broadcast happened: even a reverted or unconfirmed swap TX + // bled gas, so it counts toward the daily cap. + g.executor.AddDailySpend(failedTxFeeEstimateUSD()) _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", fmt.Sprintf("Swap TX not confirmed or reverted: %s", txHash)) return } @@ -189,7 +204,7 @@ func (g *GasTopper) topUpChain(gc gasChain, amountUSD, gasUSD float64) { for _, f := range quote.Estimate.FeeCosts { fee += parseFloatOrZero(f.AmountUSD) } - g.executor.config.DailySpentUSD += fee + g.executor.AddDailySpend(fee) bridgeGasTopup.WithLabelValues(gc.Chain, "succeeded").Inc() _ = g.slack.NotifyGasTopUp(gc.Chain, "succeeded", fmt.Sprintf( "Swapped $%.2f USDC to %s (was $%.2f, fee $%.4f, tx %s). Daily budget used: $%.2f / $%.2f.", diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index 037ee994..9ed5b3b8 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -247,6 +247,7 @@ func main() { log.Println("⚠️ This will execute REAL transactions!") bridges := []string{"mobula", "relay", "lifi"} + execMu.Lock() for _, bridge := range bridges { log.Printf("\n━━━ Bridge: %s ━━━", bridge) for _, route := range triangleRoutes { @@ -255,6 +256,7 @@ func main() { time.Sleep(2 * time.Second) } } + execMu.Unlock() log.Println("\n✅ Single-test complete! Exiting.") return @@ -369,9 +371,11 @@ func main() { now := time.Now().UTC() if now.Weekday() == time.Monday && now.YearDay() != lastMemeDay { log.Println("💸 Running $5 meme execution tests (weekly)...") + execMu.Lock() for _, route := range GetMemeRoutes() { executor.RunReal(route, 5.0) } + execMu.Unlock() lastMemeDay = now.YearDay() } } @@ -420,9 +424,20 @@ func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifie return false } + // Single-flight: this slot owns the wallets end to end (gas top-up, + // corrective rebalances, triangle runs). The reaper TryLocks and skips + // its corrective action while this is held. + execMu.Lock() + defer execMu.Unlock() + ladder := downgradeLadder(tier) blockedReason := "" + // ONE corrective-transfer budget for the whole scheduler slot, shared by + // every ladder rung. Without sharing, each rung consumed its own attempt + // counter and a single slot could broadcast up to six transfers. + budget := newSlotBudget() + for i, amount := range ladder { balances, degraded, err := bc.GetAllBalancesDetailed() if err != nil || degraded { @@ -444,7 +459,7 @@ func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifie sim := SimulateTriangleCycle(balances, amount) if !sim.Viable && rebalancer != nil { - sim, _ = rebalancer.TryUnblockTier(sim, balances, tierLabel) + sim, _ = rebalancer.TryUnblockTier(sim, balances, tierLabel, budget) } if !sim.Viable { if blockedReason == "" { diff --git a/harnesses/bridge-monitor/cmd/monitor/metrics.go b/harnesses/bridge-monitor/cmd/monitor/metrics.go index 5f8ec776..bd42f6c5 100644 --- a/harnesses/bridge-monitor/cmd/monitor/metrics.go +++ b/harnesses/bridge-monitor/cmd/monitor/metrics.go @@ -137,10 +137,11 @@ var ( Help: "1 if wallet balances are currently unreadable via both API and RPC, 0 otherwise", }) - // Auto-rebalance attempts by outcome: attempted, succeeded, failed, capped. + // Auto-rebalance attempts by outcome: attempted, succeeded, failed, + // capped, in_flight (broadcast whose bridge status never resolved). bridgeRebalanceAttempts = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "bridge_rebalance_attempts_total", - Help: "Total automatic rebalance attempts by outcome (attempted, succeeded, failed, capped)", + Help: "Total automatic rebalance attempts by outcome (attempted, succeeded, failed, capped, in_flight)", }, []string{"outcome"}) // Set to 1 when a scheduled tier was downgraded to a smaller amount because @@ -171,7 +172,7 @@ var ( // query, so the series exist on /metrics from process start instead of only // after the first event. func initSelfHealingMetrics() { - for _, outcome := range []string{"attempted", "succeeded", "failed", "capped"} { + for _, outcome := range []string{"attempted", "succeeded", "failed", "capped", "in_flight"} { bridgeRebalanceAttempts.WithLabelValues(outcome).Add(0) } bridgeTierDowngraded.WithLabelValues("300", "50").Set(0) diff --git a/harnesses/bridge-monitor/cmd/monitor/reaper.go b/harnesses/bridge-monitor/cmd/monitor/reaper.go index d40f5f6a..eceeef48 100644 --- a/harnesses/bridge-monitor/cmd/monitor/reaper.go +++ b/harnesses/bridge-monitor/cmd/monitor/reaper.go @@ -191,14 +191,24 @@ func reaperTick(fetch func() (map[string]map[string]float64, bool, error), rebal return } - if rebalancer.executor.config.DailySpentUSD >= rebalancer.executor.config.MaxDailySpendUSD { + if spent := rebalancer.executor.DailySpent(); spent >= rebalancer.executor.config.MaxDailySpendUSD { _ = slack.NotifyReaper(fmt.Sprintf( "$%.2f of %s stranded on %s for %.1fh, but daily spend limit is reached ($%.2f / $%.2f). Will retry next tick.", amount, worst.Token, worst.Chain, worstHours, - rebalancer.executor.config.DailySpentUSD, rebalancer.executor.config.MaxDailySpendUSD)) + spent, rebalancer.executor.config.MaxDailySpendUSD)) return } + // Single-flight: never broadcast while a scheduler slot (triangle run, + // rebalance, gas top-up) holds the execution lock. Skip instead of + // queueing: waiting here could fire a stale corrective transfer right + // after the slot already rebalanced the same leg. + if !execMu.TryLock() { + log.Printf("🧹 Reaper: execution lock busy (another actor is broadcasting), skipping corrective transfer this tick") + return + } + defer execMu.Unlock() + dest := neediestHomeLeg(balances) if amount > reaperMaxTransferUSD { amount = reaperMaxTransferUSD @@ -219,19 +229,24 @@ func reaperTick(fetch func() (map[string]map[string]float64, bool, error), rebal "Moving $%.2f of stranded %s from %s (stuck %.1fh) home to %s %s via cheapest bridge.", amount, worst.Token, worst.Chain, worstHours, dest.Chain, dest.Token)) + // ExecuteCheapest books the spend (realized fee on success, flat estimate + // for a broadcast that never confirmed), so no accounting here. result := rebalancer.ExecuteCheapest(route, amount) - if result == nil || !result.Success { + switch classifyCorrectiveResult(result) { + case outcomeSuccess: + _ = slack.NotifyReaper(fmt.Sprintf( + "Corrective transfer succeeded via %s (fee $%.4f, tx %s).", result.Bridge, result.ActualFeeUSD, result.TxHash)) + case outcomeInFlight: + _ = slack.NotifyReaper(fmt.Sprintf( + "Corrective transfer broadcast (tx %s) but its bridge status did not resolve. Funds may still be in flight; standing down, next tick re-evaluates fresh balances.", + result.TxHash)) + default: detail := "no bridge produced a usable quote" if result != nil && result.Error != nil { detail = result.Error.Error() } - _ = slack.NotifyReaper(fmt.Sprintf("Corrective transfer FAILED: %s. Will retry next tick.", detail)) - return + _ = slack.NotifyReaper(fmt.Sprintf("Corrective transfer FAILED before broadcast: %s. Will retry next tick.", detail)) } - - rebalancer.executor.config.DailySpentUSD += result.ActualFeeUSD - _ = slack.NotifyReaper(fmt.Sprintf( - "Corrective transfer succeeded via %s (fee $%.4f, tx %s).", result.Bridge, result.ActualFeeUSD, result.TxHash)) } // neediestHomeLeg returns the triangle leg with the lowest balance: stranded diff --git a/harnesses/bridge-monitor/cmd/monitor/rebalancer.go b/harnesses/bridge-monitor/cmd/monitor/rebalancer.go index 27d815ba..9ead71dc 100644 --- a/harnesses/bridge-monitor/cmd/monitor/rebalancer.go +++ b/harnesses/bridge-monitor/cmd/monitor/rebalancer.go @@ -14,15 +14,75 @@ import ( // like a benchmark execution. cmd/rebalance stays as the manual fallback. const ( - // Two attempts per tier slot: one transfer plus one retry covers transient - // quote failures without letting a broken bridge drain the daily budget. - maxRebalanceAttemptsPerSlot = 2 + // Two corrective transfers per SCHEDULER SLOT, shared across every rung + // of the downgrade ladder. One transfer plus one retry covers transient + // pre-broadcast failures without letting a broken bridge drain the daily + // budget. Before this was per rung, a single $300 slot could broadcast + // up to six transfers back to back. + maxCorrectiveTransfersPerSlot = 2 // Move 10 percent more than the computed shortfall so bridge fees and // slippage on the transfer itself do not leave the leg short again. rebalanceBufferFactor = 1.10 ) +// slotBudget caps corrective transfers for one scheduler slot. A single +// instance is created per slot and threaded through every ladder rung. +type slotBudget struct { + remaining int + // inFlight marks that a corrective transfer broadcast a TX whose bridge + // status never resolved. The funds are most likely still moving (legit + // fills can take 2-5 minutes, longer than our status polling), so any + // rung that needs the same destination leg must stand down for the rest + // of the slot instead of double-sending. + inFlight bool + inFlightChain string +} + +func newSlotBudget() *slotBudget { + return &slotBudget{remaining: maxCorrectiveTransfersPerSlot} +} + +// blockedByInFlight reports whether a rung needing refillChain must stand +// down because an earlier transfer to that leg is still unresolved. +func (b *slotBudget) blockedByInFlight(refillChain string) bool { + return b != nil && b.inFlight && strings.EqualFold(b.inFlightChain, refillChain) +} + +// correctiveOutcome classifies a corrective transfer attempt for retry logic. +type correctiveOutcome int + +const ( + // outcomePreBroadcast: nothing left the wallet (quote failed, approval + // failed to broadcast, insufficient funds). Safe to consume another + // attempt from the slot budget. + outcomePreBroadcast correctiveOutcome = iota + // outcomeInFlight: a TX was broadcast but the attempt did not confirm as + // a success. The deposit may well have confirmed with the bridge fill + // still pending, so the funds must be assumed to be in flight. TERMINAL + // for the slot: retrying here is exactly how a double-send happens. + outcomeInFlight + // outcomeSuccess: the transfer confirmed end to end. + outcomeSuccess +) + +// classifyCorrectiveResult decides whether a corrective transfer attempt may +// be retried. Anything that produced a TxHash moved, or may have moved, real +// funds and is terminal. Only failures that provably never broadcast are +// safe to retry. +func classifyCorrectiveResult(result *ExecutionResult) correctiveOutcome { + if result == nil { + return outcomePreBroadcast + } + if result.Success { + return outcomeSuccess + } + if result.TxHash != "" { + return outcomeInFlight + } + return outcomePreBroadcast +} + // legSpec describes one home leg of the USDC triangle. type legSpec struct { Chain string @@ -142,22 +202,37 @@ func (r *Rebalancer) canAct() bool { return r != nil && r.executor != nil && r.executor.txExecutor != nil && r.executor.txExecutor.CanExecute() } -// TryUnblockTier attempts up to maxRebalanceAttemptsPerSlot corrective -// transfers to make the given failed simulation viable. Returns the latest -// simulation and whether the tier can now run. Callers must only pass -// non-degraded balances: rebalancing on unreadable balances could move real -// funds based on phantom zeros. -func (r *Rebalancer) TryUnblockTier(sim CycleSimulation, balances map[string]map[string]float64, tierLabel string) (CycleSimulation, bool) { +// TryUnblockTier attempts corrective transfers to make the given failed +// simulation viable, consuming from the slot-wide budget shared across all +// ladder rungs. Returns the latest simulation and whether the tier can now +// run. Callers must only pass non-degraded balances: rebalancing on +// unreadable balances could move real funds based on phantom zeros. +func (r *Rebalancer) TryUnblockTier(sim CycleSimulation, balances map[string]map[string]float64, tierLabel string, budget *slotBudget) (CycleSimulation, bool) { if !r.canAct() { return sim, false } + if budget == nil { + budget = newSlotBudget() + } - for attempt := 1; attempt <= maxRebalanceAttemptsPerSlot; attempt++ { - if r.executor.config.DailySpentUSD >= r.executor.config.MaxDailySpendUSD { + for { + if budget.blockedByInFlight(sim.RefillChain) { + log.Printf("🔧 Tier $%.0f needs %s but a corrective transfer to that leg is already in flight, standing down for this slot", + sim.Tier, sim.RefillChain) + return sim, false + } + if budget.remaining <= 0 { + bridgeRebalanceAttempts.WithLabelValues("capped").Inc() + _ = r.slack.NotifyRebalance("capped", fmt.Sprintf( + "Tier $%.0f (%s) still blocked (%s) but this slot's corrective transfer budget (%d) is spent. Standing down until next slot.", + sim.Tier, tierLabel, sim.Reason, maxCorrectiveTransfersPerSlot)) + return sim, false + } + if spent := r.executor.DailySpent(); spent >= r.executor.config.MaxDailySpendUSD { bridgeRebalanceAttempts.WithLabelValues("capped").Inc() _ = r.slack.NotifyRebalance("capped", fmt.Sprintf( "Tier $%.0f (%s) blocked (%s) but daily spend limit is reached ($%.2f / $%.2f). No rebalance attempted.", - sim.Tier, tierLabel, sim.Reason, r.executor.config.DailySpentUSD, r.executor.config.MaxDailySpendUSD)) + sim.Tier, tierLabel, sim.Reason, spent, r.executor.config.MaxDailySpendUSD)) return sim, false } @@ -171,26 +246,45 @@ func (r *Rebalancer) TryUnblockTier(sim CycleSimulation, balances map[string]map return sim, false } + budget.remaining-- + attempt := maxCorrectiveTransfersPerSlot - budget.remaining bridgeRebalanceAttempts.WithLabelValues("attempted").Inc() - log.Printf("🔧 Rebalance attempt %d/%d: $%.2f %s -> %s (unblocks tier $%.0f)", - attempt, maxRebalanceAttemptsPerSlot, amountUSD, route.FromChain, route.ToChain, sim.Tier) + log.Printf("🔧 Rebalance attempt %d/%d (slot budget): $%.2f %s -> %s (unblocks tier $%.0f)", + attempt, maxCorrectiveTransfersPerSlot, amountUSD, route.FromChain, route.ToChain, sim.Tier) _ = r.slack.NotifyRebalance("attempted", fmt.Sprintf( - "Tier $%.0f (%s) blocked: %s\nMoving $%.2f from %s %s to %s %s (attempt %d/%d).", + "Tier $%.0f (%s) blocked: %s\nMoving $%.2f from %s %s to %s %s (slot attempt %d/%d).", sim.Tier, tierLabel, sim.Reason, amountUSD, route.FromChain, sourceSymbol(route), route.ToChain, sim.RefillToken, - attempt, maxRebalanceAttemptsPerSlot)) + attempt, maxCorrectiveTransfersPerSlot)) result := r.ExecuteCheapest(route, amountUSD) - if result == nil || !result.Success { + switch classifyCorrectiveResult(result) { + case outcomeInFlight: + // The deposit TX exists on-chain but the bridge status never + // resolved. Funds are most likely still moving toward the short + // leg: any further transfer to that leg this slot would be a + // double send. Terminal for the whole scheduler slot. + budget.inFlight = true + budget.inFlightChain = sim.RefillChain + bridgeRebalanceAttempts.WithLabelValues("in_flight").Inc() + _ = r.slack.NotifyRebalance("in_flight", fmt.Sprintf( + "Corrective transfer for tier $%.0f broadcast (tx %s) but its bridge status did not resolve. Funds are likely still in flight: standing down until next slot to avoid a double send.", + sim.Tier, result.TxHash)) + return sim, false + + case outcomePreBroadcast: bridgeRebalanceAttempts.WithLabelValues("failed").Inc() detail := "no bridge produced a usable quote" if result != nil && result.Error != nil { detail = result.Error.Error() } _ = r.slack.NotifyRebalance("failed", fmt.Sprintf( - "Rebalance transfer for tier $%.0f failed: %s", sim.Tier, detail)) - } else { + "Rebalance transfer for tier $%.0f failed before broadcast: %s", sim.Tier, detail)) + // Nothing left the wallet, so another attempt is safe if the + // slot budget still allows one. + continue + + case outcomeSuccess: bridgeRebalanceAttempts.WithLabelValues("succeeded").Inc() - r.executor.config.DailySpentUSD += result.ActualFeeUSD _ = r.slack.NotifyRebalance("succeeded", fmt.Sprintf( "Moved $%.2f from %s to %s via %s (fee $%.4f, tx %s). Re-checking tier $%.0f viability.", amountUSD, route.FromChain, route.ToChain, result.Bridge, result.ActualFeeUSD, result.TxHash, sim.Tier)) @@ -211,12 +305,6 @@ func (r *Rebalancer) TryUnblockTier(sim CycleSimulation, balances map[string]map return sim, true } } - - bridgeRebalanceAttempts.WithLabelValues("capped").Inc() - _ = r.slack.NotifyRebalance("capped", fmt.Sprintf( - "Tier $%.0f still not viable after %d rebalance attempts: %s", - sim.Tier, maxRebalanceAttemptsPerSlot, sim.Reason)) - return sim, false } // ExecuteCheapest quotes all three executing bridges and broadcasts through @@ -231,7 +319,12 @@ func (r *Rebalancer) ExecuteCheapest(route TestRoute, amountUSD float64) *Execut log.Printf("🔧 Cheapest bridge for %s: %s (quoted fee $%.4f)", route.Name, bridge, fee) rawUnits := toRawUnits(amountUSD) - return r.executor.executeOnBridge(bridge, route, amountUSD, amountUSD, rawUnits) + result := r.executor.executeOnBridge(bridge, route, amountUSD, amountUSD, rawUnits) + // Book the cost here so both callers (tier rebalancer and reaper) share + // the same accounting, including the flat estimate for broadcasts that + // never confirmed. + r.executor.accountSpend(result) + return result } // quoteCheapest fetches quotes from Mobula, Relay and LI.FI and returns the diff --git a/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go index 1731bb75..346c7f96 100644 --- a/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go +++ b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "math" "testing" "time" @@ -120,6 +121,74 @@ func TestDowngradeLadder(t *testing.T) { } } +func TestClassifyCorrectiveResult(t *testing.T) { + if got := classifyCorrectiveResult(nil); got != outcomePreBroadcast { + t.Errorf("nil result: got %v, want pre-broadcast", got) + } + if got := classifyCorrectiveResult(&ExecutionResult{Success: true, TxHash: "0xabc"}); got != outcomeSuccess { + t.Errorf("confirmed fill: got %v, want success", got) + } + // A broadcast whose status never resolved is TERMINAL: the deposit may + // have confirmed with the fill still pending, so a retry double-sends. + if got := classifyCorrectiveResult(&ExecutionResult{Success: false, TxHash: "0xabc", Error: fmt.Errorf("status poll failed: timeout")}); got != outcomeInFlight { + t.Errorf("status timeout with TxHash: got %v, want in-flight", got) + } + // A revert or refund also carries a hash and stays terminal for the slot. + if got := classifyCorrectiveResult(&ExecutionResult{Reverted: true, TxHash: "0xabc"}); got != outcomeInFlight { + t.Errorf("reverted with TxHash: got %v, want in-flight", got) + } + // Pre-broadcast failures (quote failed, approval failed to broadcast, + // insufficient funds) have no hash and may consume another attempt. + if got := classifyCorrectiveResult(&ExecutionResult{Error: fmt.Errorf("quote failed")}); got != outcomePreBroadcast { + t.Errorf("quote failure: got %v, want pre-broadcast", got) + } +} + +func TestSlotBudgetSharedAcrossRungs(t *testing.T) { + if maxCorrectiveTransfersPerSlot != 2 { + t.Fatalf("slot budget must be 2 corrective transfers, got %d", maxCorrectiveTransfersPerSlot) + } + b := newSlotBudget() + if b.remaining != 2 { + t.Fatalf("fresh budget: got %d remaining, want 2", b.remaining) + } + + // Rung 1 ($300) consumes one attempt, rung 2 ($50) consumes the second: + // the SAME budget instance is threaded through the ladder, so rung 3 + // ($5) has nothing left. Per-rung counters allowed up to 6 transfers. + b.remaining-- + b.remaining-- + if b.remaining > 0 { + t.Fatalf("after two attempts across rungs the slot budget must be exhausted, got %d", b.remaining) + } +} + +func TestSlotBudgetInFlightBlocksSameLeg(t *testing.T) { + b := newSlotBudget() + if b.blockedByInFlight("Solana") { + t.Fatal("fresh budget must not block any leg") + } + + // A transfer to Solana broadcast but never resolved: every later rung + // needing Solana stands down, regardless of remaining budget. + b.inFlight = true + b.inFlightChain = "Solana" + if !b.blockedByInFlight("Solana") { + t.Error("rung needing the in-flight leg must stand down") + } + if !b.blockedByInFlight("solana") { + t.Error("leg matching must be case-insensitive") + } + if b.blockedByInFlight("Base") { + t.Error("a rung needing a different leg is not blocked by the in-flight transfer") + } + + var nilBudget *slotBudget + if nilBudget.blockedByInFlight("Solana") { + t.Error("nil budget must not block") + } +} + func TestStrandedHoursComputation(t *testing.T) { tracker := NewStrandedTracker() t0 := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC) From 19357b4cb5591c76bdf5134519994cce6ae0e42f Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 16:05:08 +0200 Subject: [PATCH 16/50] bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode The reaper (and any other consumer) used to accept simulated balances even in production with broadcast-capable keys, so phantom numbers could green-light real transfers. SimulateBalances now takes the execution mode and returns nil outside dry-run; startup logs a warning when the flag is set in a broadcast-capable mode. --- .../bridge-monitor/cmd/monitor/balance.go | 10 ++++++++-- .../bridge-monitor/cmd/monitor/executor.go | 2 +- harnesses/bridge-monitor/cmd/monitor/main.go | 7 +++++++ harnesses/bridge-monitor/cmd/monitor/reaper.go | 7 +++++-- .../cmd/monitor/selfheal_test.go | 18 ++++++++++++++++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/harnesses/bridge-monitor/cmd/monitor/balance.go b/harnesses/bridge-monitor/cmd/monitor/balance.go index 3d5c401b..4067fe88 100644 --- a/harnesses/bridge-monitor/cmd/monitor/balance.go +++ b/harnesses/bridge-monitor/cmd/monitor/balance.go @@ -410,12 +410,18 @@ func (bc *BalanceChecker) GetTotalBalanceUSD() (float64, error) { return total, nil } -// SimulateBalances returns fake balances for dry-run mode -func SimulateBalances() map[string]map[string]float64 { +// SimulateBalances returns fake balances for dry-run mode. Outside dry-run it +// always returns nil: simulated balances feeding a broadcast-capable process +// could green-light real transfers based on made-up numbers. +func SimulateBalances(mode ExecutionMode) map[string]map[string]float64 { // Check if we should simulate if os.Getenv("SIMULATE_BALANCES") != "true" { return nil } + if mode != ModeDryRun { + log.Printf("⚠️ SIMULATE_BALANCES=true ignored: EXECUTION_MODE is %q, simulated balances are only honored in dry-run", mode) + return nil + } log.Println("🧪 Using simulated balances (SIMULATE_BALANCES=true)") return map[string]map[string]float64{ diff --git a/harnesses/bridge-monitor/cmd/monitor/executor.go b/harnesses/bridge-monitor/cmd/monitor/executor.go index ca0bd514..a3e4052a 100644 --- a/harnesses/bridge-monitor/cmd/monitor/executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/executor.go @@ -177,7 +177,7 @@ func (e *Executor) RunDryRun(route TestRoute, amountUSD float64) *ExecutionResul result.Error = err return result } - } else if balances = SimulateBalances(); balances == nil { + } else if balances = SimulateBalances(e.config.Mode); balances == nil { log.Printf(" ❌ No balance checker and SIMULATE_BALANCES not set") result.Error = fmt.Errorf("no balance source in dry-run") return result diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index 9ed5b3b8..a8fe5f4e 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -31,6 +31,13 @@ func main() { // Log configuration config.LogConfig() + // SIMULATE_BALANCES is a dry-run testing aid only. In any mode that can + // broadcast, phantom balances could green-light real transfers, so the + // flag is ignored everywhere outside dry-run (see SimulateBalances). + if config.SimulateBalances && config.ExecutionMode != string(ModeDryRun) { + log.Printf("⚠️ SIMULATE_BALANCES=true is set but EXECUTION_MODE is %q: simulated balances are IGNORED outside dry-run", config.ExecutionMode) + } + // Initialize bridges var mobulaBridge *MobulaBridge if config.MobulaAPIKey != "" { diff --git a/harnesses/bridge-monitor/cmd/monitor/reaper.go b/harnesses/bridge-monitor/cmd/monitor/reaper.go index eceeef48..a4387451 100644 --- a/harnesses/bridge-monitor/cmd/monitor/reaper.go +++ b/harnesses/bridge-monitor/cmd/monitor/reaper.go @@ -105,13 +105,16 @@ func (t *StrandedTracker) Update(balances map[string]map[string]float64, now tim func StartReaper(bc *BalanceChecker, rebalancer *Rebalancer, slack *SlackNotifier, mode string, paused bool) { // Balance source: real checker in normal operation, the SIMULATE_BALANCES // snapshot in keyless dry-run so the gauge path stays testable locally. + // SimulateBalances itself refuses to return anything outside dry-run, so + // a production process with keys can never act on made-up numbers. + execMode := ExecutionMode(mode) var fetch func() (map[string]map[string]float64, bool, error) switch { case bc != nil: fetch = bc.GetAllBalancesDetailed - case SimulateBalances() != nil: + case SimulateBalances(execMode) != nil: fetch = func() (map[string]map[string]float64, bool, error) { - return SimulateBalances(), false, nil + return SimulateBalances(execMode), false, nil } default: log.Println("🧹 Reaper disabled: no balance checker") diff --git a/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go index 346c7f96..7eb6d013 100644 --- a/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go +++ b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go @@ -189,6 +189,24 @@ func TestSlotBudgetInFlightBlocksSameLeg(t *testing.T) { } } +func TestSimulateBalancesOnlyInDryRun(t *testing.T) { + t.Setenv("SIMULATE_BALANCES", "true") + if SimulateBalances(ModeDryRun) == nil { + t.Fatal("dry-run with SIMULATE_BALANCES=true must return the snapshot") + } + if SimulateBalances(ModeProduction) != nil { + t.Fatal("production mode must ignore SIMULATE_BALANCES") + } + if SimulateBalances(ModeSingleTest) != nil { + t.Fatal("single-test mode must ignore SIMULATE_BALANCES") + } + + t.Setenv("SIMULATE_BALANCES", "false") + if SimulateBalances(ModeDryRun) != nil { + t.Fatal("no snapshot when SIMULATE_BALANCES is off") + } +} + func TestStrandedHoursComputation(t *testing.T) { tracker := NewStrandedTracker() t0 := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC) From 1b6841796ceadfcbdeb2d0110bbca39f185de14b Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 16:16:24 +0200 Subject: [PATCH 17/50] tx executor: preserve hash on ambiguous send errors, split dry-run spend state --- .../cmd/monitor/spend_tracker.go | 7 +++++- .../bridge-monitor/cmd/monitor/tx_executor.go | 23 +++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/harnesses/bridge-monitor/cmd/monitor/spend_tracker.go b/harnesses/bridge-monitor/cmd/monitor/spend_tracker.go index 7a885299..93865dd9 100644 --- a/harnesses/bridge-monitor/cmd/monitor/spend_tracker.go +++ b/harnesses/bridge-monitor/cmd/monitor/spend_tracker.go @@ -113,7 +113,12 @@ func spendStatePath() string { if p := strings.TrimSpace(os.Getenv("SPEND_STATE_PATH")); p != "" { return p } - return "./spend-state.json" + // Dry-run gets its own default file so simulated spend never eats the + // production budget when both run on the same host (review pass 2). + if strings.EqualFold(strings.TrimSpace(os.Getenv("EXECUTION_MODE")), "production") { + return "./spend-state.json" + } + return "./spend-state.dryrun.json" } // failedTxFeeEstimateUSD is booked against the daily cap for any broadcast diff --git a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go index 0e51c1bf..97aba65c 100644 --- a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go @@ -158,9 +158,14 @@ func (tx *TxExecutor) ExecuteSolanaTransaction(serializedTxBase64 string) (strin return "", fmt.Errorf("failed to sign tx: %w", err) } - // Send transaction + // Send transaction. Same send-ambiguity rule as the EVM path: a send + // error can follow node acceptance, so surface the signature when the + // client returns one and let callers treat it as in-flight. sig, err := tx.solanaClient.SendTransaction(ctx, transaction) if err != nil { + if sig != (solana.Signature{}) { + return sig.String(), fmt.Errorf("failed to send tx (may be accepted, sig %s): %w", sig.String(), err) + } return "", fmt.Errorf("failed to send tx: %w", err) } @@ -235,9 +240,14 @@ func (tx *TxExecutor) ExecuteSolanaFromInstructions(instructions []RelaySolanaIn return "", fmt.Errorf("failed to sign tx: %w", err) } - // Send transaction + // Send transaction. Same send-ambiguity rule as the EVM path: a send + // error can follow node acceptance, so surface the signature when the + // client returns one and let callers treat it as in-flight. sig, err := tx.solanaClient.SendTransaction(ctx, transaction) if err != nil { + if sig != (solana.Signature{}) { + return sig.String(), fmt.Errorf("failed to send tx (may be accepted, sig %s): %w", sig.String(), err) + } return "", fmt.Errorf("failed to send tx: %w", err) } @@ -314,13 +324,16 @@ func (tx *TxExecutor) ExecuteEVMTransaction(chain string, to string, data string return "", fmt.Errorf("failed to sign tx: %w", err) } - // Send transaction + // Send transaction. On error the node may STILL have accepted the tx + // (RPC timeout after acceptance): return the locally computed hash so + // callers classify this as in-flight, never as retry-safe. Discarding + // it caused the residual double-send hole found in review pass 2. + txHash := signedTx.Hash().Hex() err = client.SendTransaction(ctx, signedTx) if err != nil { - return "", fmt.Errorf("failed to send tx: %w", err) + return txHash, fmt.Errorf("failed to send tx (may be accepted, hash %s): %w", txHash, err) } - txHash := signedTx.Hash().Hex() log.Printf("📤 %s TX sent: %s", chain, txHash) return txHash, nil } From cbe1eb8e73652e11d2a4aeabf48a02a4e1fd86ef Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:57:58 +0200 Subject: [PATCH 18/50] executor: add missing chain label, first real single-test panicked post-fill (#1141) Co-authored-by: Florent Tapponnier --- harnesses/bridge-monitor/cmd/monitor/executor.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/harnesses/bridge-monitor/cmd/monitor/executor.go b/harnesses/bridge-monitor/cmd/monitor/executor.go index a3e4052a..add2a17a 100644 --- a/harnesses/bridge-monitor/cmd/monitor/executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/executor.go @@ -899,6 +899,12 @@ func (e *Executor) recordExecutionMetrics(result *ExecutionResult) { result.Route.ToToken, amountStr, e.region, + // chain dimension label, same convention as every quote path + // (mobula_bridge.go etc). This 8th label was missed when the + // metrics gained the chain dimension, and because execution was + // paused in prod the mismatch only surfaced at the first real + // single-test (panic: inconsistent label cardinality). + result.Route.ToChain, } // Record latencies From 548d1097638226abdb0f6ff0cd65508d78122761 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:01:25 +0200 Subject: [PATCH 19/50] bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142) Co-authored-by: Florent Tapponnier --- harnesses/bridge-monitor/cmd/monitor/balance.go | 4 ++-- harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go | 6 +++--- harnesses/bridge-monitor/cmd/monitor/pricer.go | 2 +- harnesses/bridge-monitor/cmd/monitor/slack.go | 2 +- harnesses/bridge-monitor/cmd/monitor/tx_executor.go | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/harnesses/bridge-monitor/cmd/monitor/balance.go b/harnesses/bridge-monitor/cmd/monitor/balance.go index 4067fe88..bb9f5e1d 100644 --- a/harnesses/bridge-monitor/cmd/monitor/balance.go +++ b/harnesses/bridge-monitor/cmd/monitor/balance.go @@ -266,7 +266,7 @@ func indexAssets(result map[string]map[string]float64, resp *MobulaWalletRespons // fetchWalletBalance fetches balance for a single wallet (all chains) func (bc *BalanceChecker) fetchWalletBalance(address string) (*MobulaWalletResponse, error) { - url := fmt.Sprintf("https://api.mobula.io/api/1/wallet/portfolio?wallet=%s", address) + url := fmt.Sprintf("https://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s", address) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -298,7 +298,7 @@ func (bc *BalanceChecker) fetchWalletBalance(address string) (*MobulaWalletRespo // fetchWalletBalanceByChain fetches balance for a specific chain func (bc *BalanceChecker) fetchWalletBalanceByChain(address, chain string) (*MobulaWalletResponse, error) { - url := fmt.Sprintf("https://api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", address, chain) + url := fmt.Sprintf("https://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", address, chain) req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go index 73e2a41d..551428d2 100644 --- a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go @@ -91,7 +91,7 @@ func (m *MobulaBridge) GetQuote(originChain, originToken, destChain, destToken, start := time.Now() url := fmt.Sprintf( - "https://api.mobula.io/api/2/bridge/quote?originChainId=%s&originToken=%s&destinationChainId=%s&destinationToken=%s&amount=%s&walletAddress=%s&apiKey=%s", + "https://demo-api.mobula.io/api/2/bridge/quote?originChainId=%s&originToken=%s&destinationChainId=%s&destinationToken=%s&amount=%s&walletAddress=%s&apiKey=%s", originChain, originToken, destChain, destToken, strconv.FormatFloat(amount, 'f', -1, 64), walletAddress, m.apiKey, @@ -135,7 +135,7 @@ func (m *MobulaBridge) GetQuote(originChain, originToken, destChain, destToken, } func (m *MobulaBridge) GetStatus(txHash string) (*MobulaStatusResponse, error) { - url := fmt.Sprintf("https://api.mobula.io/api/2/bridge/status/%s", txHash) + url := fmt.Sprintf("https://demo-api.mobula.io/api/2/bridge/status/%s", txHash) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -164,7 +164,7 @@ func (m *MobulaBridge) GetStatus(txHash string) (*MobulaStatusResponse, error) { } func (m *MobulaBridge) VerifyRoutes() (*MobulaRoutesResponse, error) { - url := "https://api.mobula.io/api/2/bridge/routes" + url := "https://demo-api.mobula.io/api/2/bridge/routes" req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/harnesses/bridge-monitor/cmd/monitor/pricer.go b/harnesses/bridge-monitor/cmd/monitor/pricer.go index 024aac08..4a677987 100644 --- a/harnesses/bridge-monitor/cmd/monitor/pricer.go +++ b/harnesses/bridge-monitor/cmd/monitor/pricer.go @@ -52,7 +52,7 @@ func TokenPriceUSD(symbol string, fallback float64) float64 { func fetchPriceUSD(symbol string) float64 { apiKey := os.Getenv("MOBULA_API_KEY") - url := fmt.Sprintf("https://api.mobula.io/api/1/market/data?symbol=%s", symbol) + url := fmt.Sprintf("https://demo-api.mobula.io/api/1/market/data?symbol=%s", symbol) req, err := http.NewRequest("GET", url, nil) if err != nil { return 0 diff --git a/harnesses/bridge-monitor/cmd/monitor/slack.go b/harnesses/bridge-monitor/cmd/monitor/slack.go index 5b42f773..579f514b 100644 --- a/harnesses/bridge-monitor/cmd/monitor/slack.go +++ b/harnesses/bridge-monitor/cmd/monitor/slack.go @@ -176,7 +176,7 @@ func (s *SlackNotifier) getBalanceSummary() string { // fetchBalances calls Mobula API to get wallet balances func (s *SlackNotifier) fetchBalances(wallet, blockchain string) string { - url := fmt.Sprintf("https://api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", wallet, blockchain) + url := fmt.Sprintf("https://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", wallet, blockchain) req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go index 97aba65c..e9161f8f 100644 --- a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go @@ -420,7 +420,7 @@ func (tx *TxExecutor) PollMobulaStatus(txHash string, timeout time.Duration) (*B } func (tx *TxExecutor) getMobulaStatus(txHash string) (*BridgeStatus, error) { - url := fmt.Sprintf("https://api.mobula.io/api/2/bridge/status/%s", txHash) + url := fmt.Sprintf("https://demo-api.mobula.io/api/2/bridge/status/%s", txHash) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err From 1a19724f21b3a8f8e9f8fea71c23f2c006683925 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 19:13:11 +0200 Subject: [PATCH 20/50] tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated) --- benchmarks/tokenized-stock-peg.yml | 211 ++++++++++++++++++ harnesses/tokenized-stock-peg/Dockerfile | 22 ++ .../tokenized-stock-peg/cmd/script/config.go | 72 ++++++ .../tokenized-stock-peg/cmd/script/loghub.go | 114 ++++++++++ .../tokenized-stock-peg/cmd/script/main.go | 82 +++++++ .../tokenized-stock-peg/cmd/script/metrics.go | 67 ++++++ .../tokenized-stock-peg/cmd/script/onchain.go | 129 +++++++++++ .../cmd/script/reference.go | 186 +++++++++++++++ harnesses/tokenized-stock-peg/go.mod | 18 ++ harnesses/tokenized-stock-peg/go.sum | 46 ++++ src/lib/removed-benches.ts | 1 + 11 files changed, 948 insertions(+) create mode 100644 benchmarks/tokenized-stock-peg.yml create mode 100644 harnesses/tokenized-stock-peg/Dockerfile create mode 100644 harnesses/tokenized-stock-peg/cmd/script/config.go create mode 100644 harnesses/tokenized-stock-peg/cmd/script/loghub.go create mode 100644 harnesses/tokenized-stock-peg/cmd/script/main.go create mode 100644 harnesses/tokenized-stock-peg/cmd/script/metrics.go create mode 100644 harnesses/tokenized-stock-peg/cmd/script/onchain.go create mode 100644 harnesses/tokenized-stock-peg/cmd/script/reference.go create mode 100644 harnesses/tokenized-stock-peg/go.mod create mode 100644 harnesses/tokenized-stock-peg/go.sum diff --git a/benchmarks/tokenized-stock-peg.yml b/benchmarks/tokenized-stock-peg.yml new file mode 100644 index 00000000..26048327 --- /dev/null +++ b/benchmarks/tokenized-stock-peg.yml @@ -0,0 +1,211 @@ +# OpenChainBench. Bench № 076 + +slug: tokenized-stock-peg +number: "076" +title: Tokenized stock price accuracy, live onchain vs Nasdaq across 11 equities +seo_title: "Tokenized stock price tracker 2026" +seo_description: "Do tokenized stocks track the real market? AAPL, TSLA, NVDA and 8 more on Robinhood Chain vs their Nasdaq price, deviation in bps, live and keyless." +subtitle: "Absolute deviation between each tokenized equity's Uniswap v4 pool price on Robinhood Chain and its real market reference price, in basis points, labeled by market session. The only live public measurement of whether onchain stocks actually track their underlying." + +category: Trading +status: live +metric: Price deviation +unit: bps +higher_is_better: false + +seo_intro: | + Robinhood Chain tokenizes ~95 equities, and its Uniswap v4 pools trade + them 24/7 while the Nasdaq closes every evening and every weekend. + This page answers the question that setup begs. how far does the + onchain price drift from the real one. Every 60 seconds we read each + pool's spot price straight from the chain (StateView getSlot0, keyless) + and compare it to the live reference price, labeling every sample with + the market session it was taken in (pre, regular, post, closed). The + regular-hours deviation is the headline ranking; the closed-state + series is the weekend drift nobody else publishes: what AAPL is worth + onchain on a Saturday when no market maker has a reference to arb + against. Of the ~95 tokenized equities only 14 have pools with real + liquidity; we measure the 11 with a listed underlying. SpaceX trades + onchain too, but SpaceX is not listed anywhere, so there is no + reference price to measure against, which says something about + tokenized private equity all by itself. + +abstract: | + Every 60 seconds the harness reads the Uniswap v4 spot price of 11 + tokenized equities on Robinhood Chain (one batched eth_call to the + StateView contract, keyless) and fetches the reference equity price + from Yahoo Finance (one batched spark call). Deviation is + 10000 x |pool - reference| / reference, in basis points, published + with a market_state label derived from Yahoo's holiday-aware session + windows. No API keys, no transactions, both legs reproducible from + public endpoints. + +methodology: + - "Onchain leg: one JSON-RPC batch per tick to StateView (0xF3334192D15450CdD385c8B70e03f9A6bD9E673b) getSlot0(poolId) on the Uniswap v4 PoolManager singleton, keyless against the official Robinhood Chain RPC. sqrtPriceX96 converts to USDG per share with the per-pool currency ordering and the USDG 6 vs stock 18 decimal gap (factor 1e12)." + - "Reference leg: Yahoo Finance v8 spark batch, one call for all 11 symbols per tick, browser User-Agent, no key. regularMarketPrice doubles as the last-close reference when the market is closed, which is exactly the weekend baseline." + - "Market session labels: derived from Yahoo currentTradingPeriod epochs (pre / regular / post / closed), which Yahoo publishes holiday-aware, so the harness maintains no NYSE calendar. The headline ranking pins market_state=\"regular\"; the closed series is the drift panel." + - "Deviation: 10000 x |pool_price - reference_price| / reference_price, sampled every 60 seconds, quantiles over 24h via quantile_over_time." + - "Cohort: the 11 official tokenized equities (name pattern * Robinhood Token, shared verified Stock implementation) whose USDG pool has real liquidity and swap history: NVDA, AAPL, GOOGL, TSLA, PLTR, META, AMD, MSFT, AMZN, SPY, MU. Pool fees range 0.3% to 2%." + - "Excluded, with reasons: SPCX (SpaceX pool holds ~$196k but SpaceX is not listed, no reference price exists), SNDK and QQQ (pool depth under $2k, deviation would be noise), CRCL (pool at zero liquidity), ~80 other official tokens (issuer-seeded placeholder pools at 90-95% fee, zero swaps), HOOD (never issued onchain, all HOOD tokens on the chain are third-party spam)." + - "Thin liquidity is part of the story, not a defect: pool depths run $24k to $330k, so a single mid-size swap can move the onchain price meaningfully off the reference until arbitrage closes it. The bench measures how fast that closure actually happens." + - "Caveats: the issuer can pause or blocklist a token (the harness drops the sample and lets the series age out rather than freezing), and the Stock contract carries a split multiplier; a corporate action can look like a one-day deviation spike until reconciled." + +findings: + - "{{best_name}} tracks its reference tightest at {{best_p50}} (p50, 24h) across {{count}} measured tokenized equities." + - "{{name:aapl}} deviates {{p50:aapl}} (p50, 24h) with roughly $330k of pool depth, the deepest tokenized-stock pool on the chain alongside {{name:nvda}} ({{p50:nvda}}, ~$290k)." + - "{{name:tsla}} ({{p50:tsla}}) has the most active pool of the cohort by swap count, which is what keeps an AMM price honest between arbitrage passes." + - "The spread between the regular-hours deviation and the closed-market drift is the number this bench exists for: pools keep trading nights and weekends with no reference to arb against, and the drift until Monday open is measurable here, live." + - "Of the ~95 equities Robinhood tokenized, only 14 have a pool anyone actually trades. The other 80 sit in issuer-seeded placeholder pools at 90-95% fees with zero swaps. Tokenization is easy; markets are hard." + +faq: + - q: "Do tokenized stocks track their real market price?" + a: "During regular Nasdaq hours, mostly yes: arbitrageurs keep the Uniswap v4 pools within tens of basis points of the reference on the liquid names, and this page shows the live per-symbol number. Outside market hours the tether loosens, the pools keep trading around the clock with no authoritative reference, and the closed-session drift measured here is the honest answer to how far a 24/7 stock wanders from its 9:30-to-4 anchor." + - q: "What happens to tokenized stock prices on weekends?" + a: "The pools trade continuously, so the onchain price becomes pure supply and demand against the Friday close. This bench samples that drift every minute all weekend and publishes it as the closed-session series. Monday pre-market usually snaps it back; the interesting data is how far it got before that." + - q: "Why only 11 stocks when Robinhood tokenized about 95?" + a: "Because only 14 of the 95 have a USDG pool with real liquidity and swap activity, and 3 of those are unmeasurable: SpaceX has no listed reference price, and SNDK plus QQQ hold under $2k of depth. The other 80 tokens sit in placeholder pools at 90-95% fees that nobody has ever traded. The gap between tokens issued and markets that exist is one of this page's findings." + - q: "How is the onchain price read?" + a: "One batched keyless eth_call per minute to the StateView contract of the Uniswap v4 singleton on Robinhood Chain, decoding sqrtPriceX96 from getSlot0 for each pool and converting with the pool's currency ordering and the 6-vs-18 decimals gap. No indexer, no API key, reproducible with curl." + - q: "Where does the reference price come from?" + a: "Yahoo Finance's public chart API, one batched call per minute with the session windows read from currentTradingPeriod, which is holiday-aware. When the market is closed the reference is the last regular close, which is exactly the baseline the weekend drift should be measured against." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/tokenized-stock-peg + +prometheus: + window: 24h + freshness_metric: tsp_deviation_bps + +rank_matrix_query: avg by (asset) (quantile_over_time(0.50, tsp_deviation_bps{market_state="regular"}[24h])) + +# Metrics emitted by the tokenized-stock-peg harness: +# tsp_deviation_bps{asset, market_state} gauge, abs deviation in bps +# tsp_price_onchain_usdg{asset} gauge, v4 pool spot +# tsp_price_reference_usd{asset} gauge, yahoo reference +# tsp_reference_age_seconds{asset} gauge +# tsp_market_session{market_state} gauge, active session flag +# tsp_health{asset} gauge +# tsp_source_call_total{source, result} counter +# tsp_source_latency_milliseconds{source} gauge + +providers: + - slug: nvda + name: NVDA + tag: "Nvidia, ~$290k pool depth, 0.3% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the NVDA Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="nvda", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="nvda", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="nvda", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="nvda", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="nvda"}[24h]) + series: tsp_deviation_bps{asset="nvda", market_state="regular"} + - slug: aapl + name: AAPL + tag: "Apple, ~$330k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the AAPL Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="aapl", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="aapl", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="aapl", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="aapl", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="aapl"}[24h]) + series: tsp_deviation_bps{asset="aapl", market_state="regular"} + - slug: googl + name: GOOGL + tag: "Alphabet, ~$280k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the GOOGL Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="googl", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="googl", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="googl", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="googl", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="googl"}[24h]) + series: tsp_deviation_bps{asset="googl", market_state="regular"} + - slug: tsla + name: TSLA + tag: "Tesla, most active pool of the cohort, 0.3% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the TSLA Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="tsla", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="tsla", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="tsla", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="tsla", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="tsla"}[24h]) + series: tsp_deviation_bps{asset="tsla", market_state="regular"} + - slug: pltr + name: PLTR + tag: "Palantir, ~$31k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the PLTR Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="pltr", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="pltr", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="pltr", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="pltr", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="pltr"}[24h]) + series: tsp_deviation_bps{asset="pltr", market_state="regular"} + - slug: meta + name: META + tag: "Meta, ~$89k pool depth, 0.3% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the META Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="meta", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="meta", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="meta", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="meta", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="meta"}[24h]) + series: tsp_deviation_bps{asset="meta", market_state="regular"} + - slug: amd + name: AMD + tag: "AMD, ~$25k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the AMD Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="amd", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="amd", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="amd", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="amd", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="amd"}[24h]) + series: tsp_deviation_bps{asset="amd", market_state="regular"} + - slug: msft + name: MSFT + tag: "Microsoft, ~$50k pool depth, 2% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the MSFT Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="msft", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="msft", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="msft", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="msft", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="msft"}[24h]) + series: tsp_deviation_bps{asset="msft", market_state="regular"} + - slug: amzn + name: AMZN + tag: "Amazon, ~$24k pool depth, 2% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the AMZN Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="amzn", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="amzn", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="amzn", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="amzn", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="amzn"}[24h]) + series: tsp_deviation_bps{asset="amzn", market_state="regular"} + - slug: spy + name: SPY + tag: "S&P 500 ETF, ~$65k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the SPY Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="spy", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="spy", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="spy", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="spy", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="spy"}[24h]) + series: tsp_deviation_bps{asset="spy", market_state="regular"} + - slug: mu + name: MU + tag: "Micron, ~$35k pool depth, 1% fee" + formula: "p50 over 24h of the absolute deviation (bps) between the MU Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{asset="mu", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{asset="mu", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{asset="mu", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{asset="mu", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="mu"}[24h]) + series: tsp_deviation_bps{asset="mu", market_state="regular"} diff --git a/harnesses/tokenized-stock-peg/Dockerfile b/harnesses/tokenized-stock-peg/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/tokenized-stock-peg/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/tokenized-stock-peg/cmd/script/config.go b/harnesses/tokenized-stock-peg/cmd/script/config.go new file mode 100644 index 00000000..0a91141b --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/config.go @@ -0,0 +1,72 @@ +package main + +import ( + "os" + "strings" + "time" +) + +// Tokenized-stock-peg harness: onchain price of Robinhood Chain +// tokenized equities (Uniswap v4 pools vs USDG) against the real +// Nasdaq/NYSE price from Yahoo Finance, deviation in basis points, +// labeled by market session state. +// +// Cohort: the 11 official " • Robinhood Token" equities whose +// USDG pool has real liquidity and swap activity (verified 2026-07-13 +// via Blockscout + PoolManager extsload sweep). Excluded and why: +// SPCX (SpaceX is not listed, no reference price exists), SNDK + QQQ +// (pool depth under $2k, pure noise), CRCL (pool has zero liquidity), +// the ~80 other official tokens (issuer-seeded placeholder pools at +// 90-95% fee, zero swaps), HOOD (never issued onchain, only spam). +// +// Orientation: Uniswap v4 orders currencies by address; USDGIsC0 says +// whether USDG (6 decimals) is currency0 in that pool. Stocks are 18 +// decimals, so the raw sqrtPriceX96 price converts with a 1e12 factor +// whose direction depends on the ordering. + +const ( + rpcDefault = "https://rpc.mainnet.chain.robinhood.com" + stateView = "0xF3334192D15450CdD385c8B70e03f9A6bD9E673b" + getSlot0Sel = "0xc815641c" // StateView.getSlot0(bytes32) — live-verified + pollInterval = 60 * time.Second + httpTimeout = 15 * time.Second + // A tokenized stock more than this far from its reference during + // regular hours is displayed but flagged; used only for logging. + logThresholdBps = 100.0 +) + +type Asset struct { + Symbol string // Yahoo ticker == display slug (lowercased for labels) + Token string + PoolID string + FeePPM int + USDGIsC0 bool +} + +var assets = []Asset{ + {Symbol: "NVDA", Token: "0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC", PoolID: "0x3bb34a44f1b2b5f32c034c38a53065a521a47b199700fa9bd19d60985ff24bf1", FeePPM: 3000, USDGIsC0: true}, + {Symbol: "AAPL", Token: "0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9", PoolID: "0xda4116b5894ee7479e64eae9276e1a2944ef0e5ce863a299d296a15618deee01", FeePPM: 10000, USDGIsC0: true}, + {Symbol: "GOOGL", Token: "0x2e0847E8910a9732eB3fb1bb4b70a580ADAD4FE3", PoolID: "0xef22239f96c6ac95dcd57b90c6b14c0cc8c3c16844def34daef68dc9dd945344", FeePPM: 10000, USDGIsC0: false}, + {Symbol: "TSLA", Token: "0x322F0929c4625eD5bAd873c95208D54E1c003b2d", PoolID: "0x8517f8071ae5b831b738052f12125e8e3d6c158b78728aa44ce3b25e5104d32e", FeePPM: 3000, USDGIsC0: false}, + {Symbol: "PLTR", Token: "0x894E1EC2D74FFE5AEF8Dc8A9e84686acCB964F2A", PoolID: "0xee430ee1003e1985e1828a01b9a20dad67ad4302994fe2abb4a173de4ac54623", FeePPM: 10000, USDGIsC0: true}, + {Symbol: "META", Token: "0xc0D6457C16Cc70d6790Dd43521C899C87ce02f35", PoolID: "0x5875d407a42965b0e768c8925cea290e06fa50603ef34fc99eb92a1050e6ae36", FeePPM: 3000, USDGIsC0: true}, + {Symbol: "AMD", Token: "0x86923f96303D656E4aa86D9d42D1e57ad2023fdC", PoolID: "0xde9f85fdd9e05a943a52f2c69ffafe3064a3287df03d02c9b431bc92d4781274", FeePPM: 10000, USDGIsC0: true}, + {Symbol: "MSFT", Token: "0xe93237C50D904957Cf27E7B1133b510C669c2e74", PoolID: "0xace02af66d24427b162f80329e039b78c226fb9a79669f5e18d5feec2aa0c056", FeePPM: 20000, USDGIsC0: true}, + {Symbol: "AMZN", Token: "0x12f190a9F9d7D37a250758b26824B97CE941bF54", PoolID: "0xa3280c768df670a535d14af8c22ad3907f2acfc0277c03309fb4d5fc8d43447e", FeePPM: 20000, USDGIsC0: false}, + {Symbol: "SPY", Token: "0x117cc2133c37B721F49dE2A7a74833232B3B4C0C", PoolID: "0x7eeda68cd84620339e6ad4bf054af9b19878ac13139991c7aaec018c40a8bb6a", FeePPM: 10000, USDGIsC0: false}, + {Symbol: "MU", Token: "0xfF080c8ce2E5feadaCa0Da81314Ae59D232d4afD", PoolID: "0x6fa3ee0048e78bf0a513eb0ab56f482944a767c21db990fcf555605e69f05659", FeePPM: 10000, USDGIsC0: true}, +} + +func rpcURL() string { + if v := strings.TrimSpace(os.Getenv("TSP_RPC_URL")); v != "" { + return v + } + return rpcDefault +} + +func listenAddr() string { + if v := strings.TrimSpace(os.Getenv("LISTEN_ADDR")); v != "" { + return v + } + return ":2112" +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/loghub.go b/harnesses/tokenized-stock-peg/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/main.go b/harnesses/tokenized-stock-peg/cmd/script/main.go new file mode 100644 index 00000000..94227a9a --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "math" + "net/http" + "strings" + "time" +) + +func main() { + installLogCapture() + fmt.Println("=== Tokenized Stock Peg Harness ===") + fmt.Println("OpenChainBench — Robinhood Chain tokenized equities vs Nasdaq reference.") + fmt.Printf("Cohort: %d assets | poll: %s | RPC: %s\n", len(assets), pollInterval, rpcURL()) + for _, a := range assets { + fmt.Printf(" - %-6s token=%s pool=%s… fee=%.2f%%\n", a.Symbol, a.Token[:10]+"…", a.PoolID[:14], float64(a.FeePPM)/10000) + } + + go func() { + if err := startMetricsServer(listenAddr()); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + } + }() + + client := &http.Client{Timeout: httpTimeout} + var periods *tradingPeriods + + tick := func() { + now := time.Now() + if periods == nil || now.Sub(periods.FetchedAt) > 30*time.Minute { + if tp := fetchTradingPeriods(client); tp != nil { + periods = tp + } + } + state := periods.state(now) + for _, s := range []string{"pre", "regular", "post", "closed", "unknown"} { + v := 0.0 + if s == state { + v = 1.0 + } + tspMarketState.WithLabelValues(s).Set(v) + } + + refs := fetchReferencePrices(client) + onchain := fetchOnchainPrices(client) + + for _, a := range assets { + sym := strings.ToLower(a.Symbol) + ref, hasRef := refs[sym] + pool, hasPool := onchain[sym] + if hasRef { + tspPriceReference.WithLabelValues(sym).Set(ref.Price) + if ref.AsOfSec > 0 { + tspRefAge.WithLabelValues(sym).Set(float64(now.Unix() - ref.AsOfSec)) + } + } + if hasPool { + tspPriceOnchain.WithLabelValues(sym).Set(pool) + } + if hasRef && hasPool && ref.Price > 0 { + dev := math.Abs(pool-ref.Price) / ref.Price * 10000 + tspDeviationBps.WithLabelValues(sym, state).Set(dev) + tspHealth.WithLabelValues(sym).Set(1) + flag := "" + if dev > logThresholdBps && state == "regular" { + flag = " <-- wide" + } + fmt.Printf("[%s][%s] pool=%.2f ref=%.2f dev=%.1fbps%s\n", sym, state, pool, ref.Price, dev, flag) + } else { + tspHealth.WithLabelValues(sym).Set(0) + } + } + } + + tick() + t := time.NewTicker(pollInterval) + defer t.Stop() + for range t.C { + tick() + } +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/metrics.go b/harnesses/tokenized-stock-peg/cmd/script/metrics.go new file mode 100644 index 00000000..bd798777 --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/metrics.go @@ -0,0 +1,67 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + // Headline: absolute deviation of the onchain pool price from the + // Yahoo reference, in basis points, labeled with the market session + // the sample was taken in. The bench pins its ranking to + // market_state="regular"; the closed-state series is the weekend / + // overnight drift panel. + tspDeviationBps = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_deviation_bps", + Help: "Absolute onchain vs reference price deviation per tokenized stock, in bps, labeled by market session state.", + }, []string{"asset", "market_state"}) + + tspPriceOnchain = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_price_onchain_usdg", + Help: "Uniswap v4 pool spot price of the tokenized stock, in USDG.", + }, []string{"asset"}) + + tspPriceReference = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_price_reference_usd", + Help: "Reference equity price from Yahoo Finance (regularMarketPrice; last close when the market is closed).", + }, []string{"asset"}) + + tspRefAge = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_reference_age_seconds", + Help: "Age of the reference price sample (now minus regularMarketTime). Large outside regular hours by design.", + }, []string{"asset"}) + + tspMarketState = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_market_session", + Help: "1 for the currently active market session label, 0 otherwise.", + }, []string{"market_state"}) + + tspSourceLatency = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_source_latency_milliseconds", + Help: "Round-trip latency of the last fetch per source.", + }, []string{"source"}) + + tspSourceCall = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "tsp_source_call_total", + Help: "Fetch outcomes per source (onchain batch, yahoo spark, yahoo chart).", + }, []string{"source", "result"}) + + tspHealth = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_health", + Help: "1 when the last tick produced a deviation sample for the asset, 0 otherwise.", + }, []string{"asset"}) +) + +func startMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/onchain.go b/harnesses/tokenized-stock-peg/cmd/script/onchain.go new file mode 100644 index 00000000..206b1a6f --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/onchain.go @@ -0,0 +1,129 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "time" +) + +// Onchain leg: one JSON-RPC batch per tick, one eth_call per asset to +// StateView.getSlot0(poolId) on the Uniswap v4 singleton's view +// contract. The response's first 32-byte word is sqrtPriceX96. +// +// Price math (v4 = v3 semantics): raw = (sqrtPriceX96 / 2^96)^2 is the +// amount of currency1 base units per 1 base unit of currency0. With +// USDG at 6 decimals and stocks at 18: +// USDG is currency0 → USDG per stock = 1e12 / raw +// USDG is currency1 → USDG per stock = raw × 1e12 +// (verified against the live AAPL pool: 319.42 USDG on 2026-07-13). + +type batchReq struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +type batchResp struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +var q96 = new(big.Float).SetPrec(200).SetInt(new(big.Int).Lsh(big.NewInt(1), 96)) + +func fetchOnchainPrices(client *http.Client) map[string]float64 { + reqs := make([]batchReq, 0, len(assets)) + for i, a := range assets { + data := getSlot0Sel + strings.TrimPrefix(strings.ToLower(a.PoolID), "0x") + reqs = append(reqs, batchReq{ + JSONRPC: "2.0", ID: i, Method: "eth_call", + Params: []any{map[string]string{"to": stateView, "data": data}, "latest"}, + }) + } + body, _ := json.Marshal(reqs) + req, err := http.NewRequest("POST", rpcURL(), bytes.NewReader(body)) + if err != nil { + return nil + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + tspSourceCall.WithLabelValues("onchain", "network").Inc() + return nil + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<22)) + if err != nil || resp.StatusCode != 200 { + tspSourceCall.WithLabelValues("onchain", fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return nil + } + tspSourceLatency.WithLabelValues("onchain").Set(float64(time.Since(start).Milliseconds())) + + var out []batchResp + if err := json.Unmarshal(raw, &out); err != nil { + tspSourceCall.WithLabelValues("onchain", "parse").Inc() + return nil + } + prices := make(map[string]float64, len(assets)) + for _, r := range out { + if r.ID < 0 || r.ID >= len(assets) { + continue + } + a := assets[r.ID] + sym := strings.ToLower(a.Symbol) + if r.Error != nil { + // Issuer pause/block or pool state error: skip the sample so + // Prom staleness ages the series out instead of freezing it. + tspSourceCall.WithLabelValues("onchain", "rpc_error").Inc() + fmt.Printf("[%s] onchain error: %s\n", sym, r.Error.Message) + continue + } + p, ok := slot0ToUSDG(string(r.Result), a.USDGIsC0) + if !ok || p <= 0 { + tspSourceCall.WithLabelValues("onchain", "decode").Inc() + continue + } + prices[sym] = p + tspSourceCall.WithLabelValues("onchain", "ok").Inc() + } + return prices +} + +// slot0ToUSDG parses the getSlot0 return blob (sqrtPriceX96 is the +// first word) and converts to USDG per stock respecting pool ordering. +func slot0ToUSDG(resultJSON string, usdgIsC0 bool) (float64, bool) { + hexStr := strings.Trim(resultJSON, `"`) + hexStr = strings.TrimPrefix(hexStr, "0x") + if len(hexStr) < 64 { + return 0, false + } + sqrtInt, ok := new(big.Int).SetString(hexStr[:64], 16) + if !ok || sqrtInt.Sign() == 0 { + return 0, false + } + sqrtF := new(big.Float).SetPrec(200).SetInt(sqrtInt) + ratio := new(big.Float).SetPrec(200).Quo(sqrtF, q96) + raw := new(big.Float).SetPrec(200).Mul(ratio, ratio) + + e12 := new(big.Float).SetPrec(200).SetFloat64(1e12) + var price *big.Float + if usdgIsC0 { + price = new(big.Float).SetPrec(200).Quo(e12, raw) + } else { + price = new(big.Float).SetPrec(200).Mul(raw, e12) + } + f, _ := price.Float64() + return f, true +} diff --git a/harnesses/tokenized-stock-peg/cmd/script/reference.go b/harnesses/tokenized-stock-peg/cmd/script/reference.go new file mode 100644 index 00000000..bc435c2c --- /dev/null +++ b/harnesses/tokenized-stock-peg/cmd/script/reference.go @@ -0,0 +1,186 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Reference leg: Yahoo Finance, keyless. One spark batch call fetches +// the latest price for every symbol; one chart call (AAPL as the +// bellwether) fetches the day's exact session windows, which Yahoo +// publishes holiday-aware so the harness never maintains an NYSE +// calendar. Verified 2026-07-13 from the harness host: clean 200s with +// a browser User-Agent (datacenter IP), 70-130ms. +// +// Market state is derived from currentTradingPeriod epochs: +// pre / regular / post / closed. Deviation samples carry the state as +// a label so the bench can pin its headline to regular hours and read +// the weekend drift from the closed-state series. + +const ( + sparkHost = "https://query1.finance.yahoo.com/v8/finance/spark" + chartHost = "https://query1.finance.yahoo.com/v8/finance/chart/AAPL" + yahooUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" +) + +type refQuote struct { + Price float64 + AsOfSec int64 +} + +type tradingPeriods struct { + PreStart, PreEnd int64 + RegularStart, RegularEnd int64 + PostStart, PostEnd int64 + FetchedAt time.Time +} + +// fetchReferencePrices returns the freshest Yahoo price per symbol +// (lowercased) from one spark batch call. When the market is closed +// the spark meta still carries regularMarketPrice = last close, which +// is exactly the weekend reference we want. +func fetchReferencePrices(client *http.Client) map[string]refQuote { + syms := make([]string, 0, len(assets)) + for _, a := range assets { + syms = append(syms, a.Symbol) + } + url := sparkHost + "?symbols=" + strings.Join(syms, ",") + "&range=1d&interval=5m" + raw, status := yahooGet(client, url) + if raw == nil { + tspSourceCall.WithLabelValues("yahoo", status).Inc() + // Rotate to query2 once before giving up this tick. + raw, status = yahooGet(client, strings.Replace(url, "query1", "query2", 1)) + if raw == nil { + tspSourceCall.WithLabelValues("yahoo", status).Inc() + return nil + } + } + tspSourceCall.WithLabelValues("yahoo", "ok").Inc() + + // Spark response: {"spark":{"result":[{"symbol":"AAPL","response":[{"meta":{...}}]}]}} + // or the flatter {"AAPL":{...}} shape depending on edge; handle both. + prices := make(map[string]refQuote, len(syms)) + var envel struct { + Spark struct { + Result []struct { + Symbol string `json:"symbol"` + Response []struct { + Meta struct { + RegularMarketPrice float64 `json:"regularMarketPrice"` + RegularMarketTime int64 `json:"regularMarketTime"` + } `json:"meta"` + } `json:"response"` + } `json:"result"` + } `json:"spark"` + } + if err := json.Unmarshal(raw, &envel); err == nil && len(envel.Spark.Result) > 0 { + for _, r := range envel.Spark.Result { + if len(r.Response) == 0 || r.Response[0].Meta.RegularMarketPrice <= 0 { + continue + } + prices[strings.ToLower(r.Symbol)] = refQuote{ + Price: r.Response[0].Meta.RegularMarketPrice, + AsOfSec: r.Response[0].Meta.RegularMarketTime, + } + } + return prices + } + var flat map[string]struct { + RegularMarketPrice float64 `json:"regularMarketPrice"` + Timestamp []int64 `json:"timestamp"` + } + if err := json.Unmarshal(raw, &flat); err == nil { + for sym, v := range flat { + if v.RegularMarketPrice <= 0 { + continue + } + q := refQuote{Price: v.RegularMarketPrice} + if n := len(v.Timestamp); n > 0 { + q.AsOfSec = v.Timestamp[n-1] + } + prices[strings.ToLower(sym)] = q + } + return prices + } + tspSourceCall.WithLabelValues("yahoo", "parse").Inc() + return nil +} + +// fetchTradingPeriods reads currentTradingPeriod from one chart call. +// Refreshed every 30 minutes; between refreshes marketState() reuses +// the cached windows. +func fetchTradingPeriods(client *http.Client) *tradingPeriods { + raw, status := yahooGet(client, chartHost+"?range=1d&interval=5m") + if raw == nil { + tspSourceCall.WithLabelValues("yahoo_chart", status).Inc() + return nil + } + var envel struct { + Chart struct { + Result []struct { + Meta struct { + CurrentTradingPeriod struct { + Pre struct{ Start, End int64 } `json:"pre"` + Regular struct{ Start, End int64 } `json:"regular"` + Post struct{ Start, End int64 } `json:"post"` + } `json:"currentTradingPeriod"` + } `json:"meta"` + } `json:"result"` + } `json:"chart"` + } + if err := json.Unmarshal(raw, &envel); err != nil || len(envel.Chart.Result) == 0 { + tspSourceCall.WithLabelValues("yahoo_chart", "parse").Inc() + return nil + } + m := envel.Chart.Result[0].Meta.CurrentTradingPeriod + tspSourceCall.WithLabelValues("yahoo_chart", "ok").Inc() + return &tradingPeriods{ + PreStart: m.Pre.Start, PreEnd: m.Pre.End, + RegularStart: m.Regular.Start, RegularEnd: m.Regular.End, + PostStart: m.Post.Start, PostEnd: m.Post.End, + FetchedAt: time.Now(), + } +} + +func (tp *tradingPeriods) state(now time.Time) string { + if tp == nil { + return "unknown" + } + u := now.Unix() + switch { + case u >= tp.RegularStart && u < tp.RegularEnd: + return "regular" + case u >= tp.PreStart && u < tp.PreEnd: + return "pre" + case u >= tp.PostStart && u < tp.PostEnd: + return "post" + default: + return "closed" + } +} + +func yahooGet(client *http.Client, url string) ([]byte, string) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, "request_build" + } + req.Header.Set("User-Agent", yahooUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, "network" + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<22)) + if err != nil { + return nil, "read" + } + if resp.StatusCode != 200 { + return nil, fmt.Sprintf("http_%d", resp.StatusCode) + } + return raw, "ok" +} diff --git a/harnesses/tokenized-stock-peg/go.mod b/harnesses/tokenized-stock-peg/go.mod new file mode 100644 index 00000000..510f4f14 --- /dev/null +++ b/harnesses/tokenized-stock-peg/go.mod @@ -0,0 +1,18 @@ +module tokenized-stock-peg + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/tokenized-stock-peg/go.sum b/harnesses/tokenized-stock-peg/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/tokenized-stock-peg/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts index d7823ee9..7f6c5ad2 100644 --- a/src/lib/removed-benches.ts +++ b/src/lib/removed-benches.ts @@ -37,5 +37,6 @@ export const REMOVED_BENCH_SLUGS = new Set([ "rpc-keyed-latency", "explorer-chain-coverage", "solana-rpc", + "tokenized-stock-peg", "portfolio-chain-coverage", ]); From 84d61a3725ec357d317b805b6606c6cdd8b37ca3 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:25:05 +0200 Subject: [PATCH 21/50] spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144) Co-authored-by: Florent Tapponnier --- src/lib/spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 7f9ea805..efcd21b9 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -201,6 +201,16 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // ISR re-render bursts. const stored = await benchFromStore(slug, ""); if (stored) return slimBenchmarkForCache(overlayEditorial(stored, spec)); + if (spec.status === "live") { + // A live spec with no readable blob is a transient store failure + // (proxy timeout, worker mid-write), not a real state. Returning + // undefined would cache the miss for the whole revalidate window: + // home row + /api/stat then serve "Awaiting samples" while the + // bench page reads fine (bnb-rpc incident, 2026-07-13). Throwing + // makes unstable_cache keep the last good entry; cold-cache + // callers already catch and fall back to the draft placeholder. + throw new Error(`store blob missing for live bench ${slug}`); + } return undefined; }, // Version key bumped when Benchmark shape changes so stale cache entries From 31d40ea0ce79304e4bdf91ce7e2a226bdfca6569 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 19:26:24 +0200 Subject: [PATCH 22/50] tsp: parse the flat spark shape (close/timestamp arrays), log empty parses --- .../cmd/script/reference.go | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/harnesses/tokenized-stock-peg/cmd/script/reference.go b/harnesses/tokenized-stock-peg/cmd/script/reference.go index bc435c2c..7062061f 100644 --- a/harnesses/tokenized-stock-peg/cmd/script/reference.go +++ b/harnesses/tokenized-stock-peg/cmd/script/reference.go @@ -77,7 +77,7 @@ func fetchReferencePrices(client *http.Client) map[string]refQuote { } `json:"result"` } `json:"spark"` } - if err := json.Unmarshal(raw, &envel); err == nil && len(envel.Spark.Result) > 0 { + if err := json.Unmarshal(raw, &envel); err == nil { for _, r := range envel.Spark.Result { if len(r.Response) == 0 || r.Response[0].Meta.RegularMarketPrice <= 0 { continue @@ -87,27 +87,48 @@ func fetchReferencePrices(client *http.Client) map[string]refQuote { AsOfSec: r.Response[0].Meta.RegularMarketTime, } } - return prices } - var flat map[string]struct { - RegularMarketPrice float64 `json:"regularMarketPrice"` - Timestamp []int64 `json:"timestamp"` - } - if err := json.Unmarshal(raw, &flat); err == nil { - for sym, v := range flat { - if v.RegularMarketPrice <= 0 { - continue - } - q := refQuote{Price: v.RegularMarketPrice} - if n := len(v.Timestamp); n > 0 { - q.AsOfSec = v.Timestamp[n-1] + if len(prices) == 0 { + // Flat spark shape (observed live 2026-07-13): + // {"MSFT":{"timestamp":[...],"close":[...],"previousClose":X},...} + // Price = last non-null close; previousClose is the fallback when + // the close array is empty (market closed all day). + var flat map[string]struct { + Timestamp []int64 `json:"timestamp"` + Close []*float64 `json:"close"` + PreviousClose *float64 `json:"previousClose"` + } + if err := json.Unmarshal(raw, &flat); err == nil { + for sym, v := range flat { + q := refQuote{} + for i := len(v.Close) - 1; i >= 0; i-- { + if v.Close[i] != nil && *v.Close[i] > 0 { + q.Price = *v.Close[i] + if i < len(v.Timestamp) { + q.AsOfSec = v.Timestamp[i] + } + break + } + } + if q.Price == 0 && v.PreviousClose != nil && *v.PreviousClose > 0 { + q.Price = *v.PreviousClose + } + if q.Price > 0 { + prices[strings.ToLower(sym)] = q + } } - prices[strings.ToLower(sym)] = q } - return prices } - tspSourceCall.WithLabelValues("yahoo", "parse").Inc() - return nil + if len(prices) == 0 { + tspSourceCall.WithLabelValues("yahoo", "parse_empty").Inc() + head := string(raw) + if len(head) > 400 { + head = head[:400] + } + fmt.Printf("[yahoo] spark yielded no symbols; body head: %s\n", head) + return nil + } + return prices } // fetchTradingPeriods reads currentTradingPeriod from one chart call. From 3002075b881b15c66810f4b504475f126d489dd8 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 19:32:14 +0200 Subject: [PATCH 23/50] tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes --- benchmarks/tokenized-stock-peg.yml | 4 ++-- harnesses/tokenized-stock-peg/cmd/script/reference.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmarks/tokenized-stock-peg.yml b/benchmarks/tokenized-stock-peg.yml index 26048327..3a4f6206 100644 --- a/benchmarks/tokenized-stock-peg.yml +++ b/benchmarks/tokenized-stock-peg.yml @@ -48,6 +48,8 @@ methodology: - "Cohort: the 11 official tokenized equities (name pattern * Robinhood Token, shared verified Stock implementation) whose USDG pool has real liquidity and swap history: NVDA, AAPL, GOOGL, TSLA, PLTR, META, AMD, MSFT, AMZN, SPY, MU. Pool fees range 0.3% to 2%." - "Excluded, with reasons: SPCX (SpaceX pool holds ~$196k but SpaceX is not listed, no reference price exists), SNDK and QQQ (pool depth under $2k, deviation would be noise), CRCL (pool at zero liquidity), ~80 other official tokens (issuer-seeded placeholder pools at 90-95% fee, zero swaps), HOOD (never issued onchain, all HOOD tokens on the chain are third-party spam)." - "Thin liquidity is part of the story, not a defect: pool depths run $24k to $330k, so a single mid-size swap can move the onchain price meaningfully off the reference until arbitrage closes it. The bench measures how fast that closure actually happens." + - "Reading grid: an AMM price can sit anywhere inside the pool's fee band without creating an arbitrage opportunity, so a 2% fee pool resting 150 bps off its reference is economically at equilibrium, not broken. Cross-symbol ranking therefore partly reflects each pool's fee tier (disclosed in every tag); the within-symbol trend over time is the purest signal." + - "Quote-asset caveat: pool prices are denominated in USDG and compared against USD references. A USDG peg wobble would appear as a correlated deviation across all 11 symbols simultaneously, which is the signature to check before reading a broad move as tracking error." - "Caveats: the issuer can pause or blocklist a token (the harness drops the sample and lets the series age out rather than freezing), and the Stock contract carries a split multiplier; a corporate action can look like a one-day deviation spike until reconciled." findings: @@ -75,8 +77,6 @@ prometheus: window: 24h freshness_metric: tsp_deviation_bps -rank_matrix_query: avg by (asset) (quantile_over_time(0.50, tsp_deviation_bps{market_state="regular"}[24h])) - # Metrics emitted by the tokenized-stock-peg harness: # tsp_deviation_bps{asset, market_state} gauge, abs deviation in bps # tsp_price_onchain_usdg{asset} gauge, v4 pool spot diff --git a/harnesses/tokenized-stock-peg/cmd/script/reference.go b/harnesses/tokenized-stock-peg/cmd/script/reference.go index 7062061f..3ebc5c3b 100644 --- a/harnesses/tokenized-stock-peg/cmd/script/reference.go +++ b/harnesses/tokenized-stock-peg/cmd/script/reference.go @@ -48,7 +48,7 @@ func fetchReferencePrices(client *http.Client) map[string]refQuote { for _, a := range assets { syms = append(syms, a.Symbol) } - url := sparkHost + "?symbols=" + strings.Join(syms, ",") + "&range=1d&interval=5m" + url := sparkHost + "?symbols=" + strings.Join(syms, ",") + "&range=1d&interval=1m" raw, status := yahooGet(client, url) if raw == nil { tspSourceCall.WithLabelValues("yahoo", status).Inc() From 88991bbd0f69a9f13c69bb3b018c80ae204480b5 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 19:42:15 +0200 Subject: [PATCH 24/50] rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages --- benchmarks/tokenized-stock-peg.yml | 2 +- public/logos/aapl.svg | 1 + public/logos/amd.svg | 1 + public/logos/amzn.png | Bin 0 -> 6589 bytes public/logos/googl.svg | 1 + public/logos/meta.svg | 1 + public/logos/msft.svg | 1 + public/logos/mu.png | Bin 0 -> 1521 bytes public/logos/nvda.svg | 1 + public/logos/pltr.svg | 1 + public/logos/spy.svg | 35 ++++++++++++++++++ public/logos/tsla.svg | 1 + src/components/provider-logo.tsx | 2 ++ src/data/provider-registry.ts | 55 +++++++++++++++++++++++++++++ src/lib/categories.ts | 10 +++++- src/lib/category-colors.ts | 2 ++ src/lib/logo-manifest.ts | 11 ++++++ src/lib/spec-schema.ts | 1 + src/types/benchmark.ts | 2 +- 19 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 public/logos/aapl.svg create mode 100644 public/logos/amd.svg create mode 100644 public/logos/amzn.png create mode 100644 public/logos/googl.svg create mode 100644 public/logos/meta.svg create mode 100644 public/logos/msft.svg create mode 100644 public/logos/mu.png create mode 100644 public/logos/nvda.svg create mode 100644 public/logos/pltr.svg create mode 100644 public/logos/spy.svg create mode 100644 public/logos/tsla.svg diff --git a/benchmarks/tokenized-stock-peg.yml b/benchmarks/tokenized-stock-peg.yml index 3a4f6206..df9691c0 100644 --- a/benchmarks/tokenized-stock-peg.yml +++ b/benchmarks/tokenized-stock-peg.yml @@ -7,7 +7,7 @@ seo_title: "Tokenized stock price tracker 2026" seo_description: "Do tokenized stocks track the real market? AAPL, TSLA, NVDA and 8 more on Robinhood Chain vs their Nasdaq price, deviation in bps, live and keyless." subtitle: "Absolute deviation between each tokenized equity's Uniswap v4 pool price on Robinhood Chain and its real market reference price, in basis points, labeled by market session. The only live public measurement of whether onchain stocks actually track their underlying." -category: Trading +category: RWA status: live metric: Price deviation unit: bps diff --git a/public/logos/aapl.svg b/public/logos/aapl.svg new file mode 100644 index 00000000..915e8909 --- /dev/null +++ b/public/logos/aapl.svg @@ -0,0 +1 @@ +Apple \ No newline at end of file diff --git a/public/logos/amd.svg b/public/logos/amd.svg new file mode 100644 index 00000000..ae3de073 --- /dev/null +++ b/public/logos/amd.svg @@ -0,0 +1 @@ +AMD \ No newline at end of file diff --git a/public/logos/amzn.png b/public/logos/amzn.png new file mode 100644 index 0000000000000000000000000000000000000000..d466c7923d8987b70668899fbe25d8aad867d5e8 GIT binary patch literal 6589 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 L&4mhcbmIR5E(C>_ literal 0 HcmV?d00001 diff --git a/public/logos/googl.svg b/public/logos/googl.svg new file mode 100644 index 00000000..088288fa --- /dev/null +++ b/public/logos/googl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/meta.svg b/public/logos/meta.svg new file mode 100644 index 00000000..385fb0a1 --- /dev/null +++ b/public/logos/meta.svg @@ -0,0 +1 @@ +Meta \ No newline at end of file diff --git a/public/logos/msft.svg b/public/logos/msft.svg new file mode 100644 index 00000000..5334aa7c --- /dev/null +++ b/public/logos/msft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/mu.png b/public/logos/mu.png new file mode 100644 index 0000000000000000000000000000000000000000..f8f9a2946a00db91ec70b25b4a256e9430a04e8d GIT binary patch literal 1521 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i985rwk9x$eC8vmO{P8a>(VyNluxw-&I>P56V6EUdJUQE5GS*Cc{(8o|53|=5&!2Z- z`G?`@Pk1D@9FNMbVm@%Yk{&6FUMUydo(;bIJ2t+)vs^vkOO5Ph;k=WZhj}svMsLNM xt;@_A_RLMEUx9%h>~txm=xgaAe_-kT|G%`C$o^Zi3ZDWw44$rjF6*2UngEJ<>LLID literal 0 HcmV?d00001 diff --git a/public/logos/nvda.svg b/public/logos/nvda.svg new file mode 100644 index 00000000..e427e2ce --- /dev/null +++ b/public/logos/nvda.svg @@ -0,0 +1 @@ +NVIDIA \ No newline at end of file diff --git a/public/logos/pltr.svg b/public/logos/pltr.svg new file mode 100644 index 00000000..6bb694d7 --- /dev/null +++ b/public/logos/pltr.svg @@ -0,0 +1 @@ +Palantir \ No newline at end of file diff --git a/public/logos/spy.svg b/public/logos/spy.svg new file mode 100644 index 00000000..d3d540a1 --- /dev/null +++ b/public/logos/spy.svg @@ -0,0 +1,35 @@ + + + diff --git a/public/logos/tsla.svg b/public/logos/tsla.svg new file mode 100644 index 00000000..e990fab5 --- /dev/null +++ b/public/logos/tsla.svg @@ -0,0 +1 @@ +Tesla \ No newline at end of file diff --git a/src/components/provider-logo.tsx b/src/components/provider-logo.tsx index db7f1af0..8f52e362 100644 --- a/src/components/provider-logo.tsx +++ b/src/components/provider-logo.tsx @@ -80,6 +80,8 @@ export function ProviderLogo({ // hairline shadow so they pop on both light and dark page backgrounds. const NEEDS_LIGHT_CHIP = new Set([ "aptos", + "aapl", + "pltr", "leorpc", "blinklabs", "mobula", diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index a10cc7c8..e63a1ed6 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -225,6 +225,61 @@ export const PROVIDER_REGISTRY: Record = { description: "LeoRPC is a community Solana RPC operation exposing a publicly documented FREE tier through a query key, one of the few remaining keyless ways to read Solana mainnet.", }, + aapl: { + url: "https://www.apple.com", + description: + "Apple tokenized equity issued by Robinhood on Robinhood Chain (contract 0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + nvda: { + url: "https://www.nvidia.com", + description: + "Nvidia tokenized equity issued by Robinhood on Robinhood Chain (contract 0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + googl: { + url: "https://abc.xyz", + description: + "Alphabet tokenized equity issued by Robinhood on Robinhood Chain (contract 0x2e0847E8910a9732eB3fb1bb4b70a580ADAD4FE3), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + tsla: { + url: "https://www.tesla.com", + description: + "Tesla tokenized equity issued by Robinhood on Robinhood Chain (contract 0x322F0929c4625eD5bAd873c95208D54E1c003b2d), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + pltr: { + url: "https://www.palantir.com", + description: + "Palantir tokenized equity issued by Robinhood on Robinhood Chain (contract 0x894E1EC2D74FFE5AEF8Dc8A9e84686acCB964F2A), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + meta: { + url: "https://www.meta.com", + description: + "Meta Platforms tokenized equity issued by Robinhood on Robinhood Chain (contract 0xc0D6457C16Cc70d6790Dd43521C899C87ce02f35), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + amd: { + url: "https://www.amd.com", + description: + "AMD tokenized equity issued by Robinhood on Robinhood Chain (contract 0x86923f96303D656E4aa86D9d42D1e57ad2023fdC), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + msft: { + url: "https://www.microsoft.com", + description: + "Microsoft tokenized equity issued by Robinhood on Robinhood Chain (contract 0xe93237C50D904957Cf27E7B1133b510C669c2e74), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + amzn: { + url: "https://www.amazon.com", + description: + "Amazon tokenized equity issued by Robinhood on Robinhood Chain (contract 0x12f190a9F9d7D37a250758b26824B97CE941bF54), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + spy: { + url: "https://www.ssga.com", + description: + "SPDR S&P 500 ETF (State Street) tokenized equity issued by Robinhood on Robinhood Chain (contract 0x117cc2133c37B721F49dE2A7a74833232B3B4C0C), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, + mu: { + url: "https://www.micron.com", + description: + "Micron Technology tokenized equity issued by Robinhood on Robinhood Chain (contract 0xfF080c8ce2E5feadaCa0Da81314Ae59D232d4afD), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + }, bloxroute: { url: "https://bloxroute.com", description: diff --git a/src/lib/categories.ts b/src/lib/categories.ts index d432767f..091bde4e 100644 --- a/src/lib/categories.ts +++ b/src/lib/categories.ts @@ -30,7 +30,8 @@ export type Category = | "Wallets" | "RPCs" | "NFT APIs" - | "Explorers"; + | "Explorers" + | "RWA"; export type CategoryEntry = { /** URL slug. Lowercase, kebab-case, ASCII. */ @@ -93,6 +94,13 @@ export const CATEGORIES: readonly CategoryEntry[] = [ description: "Live measurements for block explorer APIs. Compare how many chains each family actually serves with a working indexer, probe-verified daily against the vendors' own registries.", }, + { + slug: "rwa", + label: "RWA", + heading: "RWA benchmarks", + description: + "Live measurements for tokenized real world assets. How closely tokenized stocks and treasuries track their underlying market price, measured onchain around the clock, including nights and weekends when the real markets are closed.", + }, { slug: "nft-apis", label: "NFT APIs", diff --git a/src/lib/category-colors.ts b/src/lib/category-colors.ts index e06035d5..6cf03306 100644 --- a/src/lib/category-colors.ts +++ b/src/lib/category-colors.ts @@ -18,4 +18,6 @@ export const CATEGORY_COLOR: Record = { // in use. Picks up OpenSea/Alchemy/Moralis brand palettes which all // cluster around blues/purples. "NFT APIs": "#6366f1", + // RWA: teal, the tradfi-meets-onchain lane gets its own hue. + RWA: "#0d9488", }; diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 0b15d77e..757247cc 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -165,6 +165,17 @@ const RAW: Record = { astralane: "/logos/astralane.svg", solanavibestation: "/logos/solanavibestation.png", leorpc: "/logos/leorpc.png", + aapl: "/logos/aapl.svg", + nvda: "/logos/nvda.svg", + googl: "/logos/googl.svg", + tsla: "/logos/tsla.svg", + pltr: "/logos/pltr.svg", + meta: "/logos/meta.svg", + amd: "/logos/amd.svg", + msft: "/logos/msft.svg", + amzn: "/logos/amzn.png", + spy: "/logos/spy.svg", + mu: "/logos/mu.png", // ─── Buyback audit (bench 018) ─── sky: "/logos/sky.svg", diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts index 47fc2eeb..75535ac0 100644 --- a/src/lib/spec-schema.ts +++ b/src/lib/spec-schema.ts @@ -158,6 +158,7 @@ export const Category = z.enum([ "RPCs", "NFT APIs", "Explorers", + "RWA", ]); // Em-dash (—) and en-dash (–) are the classic "AI tells" that hurt our brand diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index c213c3ef..47d29a00 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -215,7 +215,7 @@ export type Benchmark = { kind?: { value: string; label: string }[]; venue?: { value: string; label: string }[]; }; - category: "Aggregators" | "Bridges" | "Blockchains" | "Trading" | "Wallets" | "RPCs" | "NFT APIs" | "Explorers"; + category: "Aggregators" | "Bridges" | "Blockchains" | "Trading" | "Wallets" | "RPCs" | "NFT APIs" | "Explorers" | "RWA"; results: ProviderResult[]; /** Per-chain leader, computed only on the unfiltered ("All chains") view * when the spec declares `dimensions.chain`. Key = chain slug from the From a3128eb2ab821864f8efc05e4c7ddd24fb4c144e Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 19:57:10 +0200 Subject: [PATCH 25/50] rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family --- benchmarks/tokenized-stock-peg.yml | 110 ++++----- benchmarks/usdy-nav-basis.yml | 90 +++++++ benchmarks/xstocks-peg.yml | 207 ++++++++++++++++ .../tokenized-stock-peg/cmd/script/main.go | 4 +- .../tokenized-stock-peg/cmd/script/metrics.go | 6 +- harnesses/usdy-nav-basis/Dockerfile | 22 ++ harnesses/usdy-nav-basis/cmd/script/loghub.go | 114 +++++++++ harnesses/usdy-nav-basis/cmd/script/main.go | 232 ++++++++++++++++++ harnesses/usdy-nav-basis/go.mod | 18 ++ harnesses/usdy-nav-basis/go.sum | 46 ++++ harnesses/xstocks-peg/Dockerfile | 22 ++ harnesses/xstocks-peg/cmd/script/config.go | 60 +++++ harnesses/xstocks-peg/cmd/script/jupiter.go | 143 +++++++++++ harnesses/xstocks-peg/cmd/script/loghub.go | 114 +++++++++ harnesses/xstocks-peg/cmd/script/main.go | 83 +++++++ harnesses/xstocks-peg/cmd/script/metrics.go | 67 +++++ harnesses/xstocks-peg/cmd/script/reference.go | 207 ++++++++++++++++ harnesses/xstocks-peg/go.mod | 18 ++ harnesses/xstocks-peg/go.sum | 46 ++++ src/lib/removed-benches.ts | 2 + 20 files changed, 1551 insertions(+), 60 deletions(-) create mode 100644 benchmarks/usdy-nav-basis.yml create mode 100644 benchmarks/xstocks-peg.yml create mode 100644 harnesses/usdy-nav-basis/Dockerfile create mode 100644 harnesses/usdy-nav-basis/cmd/script/loghub.go create mode 100644 harnesses/usdy-nav-basis/cmd/script/main.go create mode 100644 harnesses/usdy-nav-basis/go.mod create mode 100644 harnesses/usdy-nav-basis/go.sum create mode 100644 harnesses/xstocks-peg/Dockerfile create mode 100644 harnesses/xstocks-peg/cmd/script/config.go create mode 100644 harnesses/xstocks-peg/cmd/script/jupiter.go create mode 100644 harnesses/xstocks-peg/cmd/script/loghub.go create mode 100644 harnesses/xstocks-peg/cmd/script/main.go create mode 100644 harnesses/xstocks-peg/cmd/script/metrics.go create mode 100644 harnesses/xstocks-peg/cmd/script/reference.go create mode 100644 harnesses/xstocks-peg/go.mod create mode 100644 harnesses/xstocks-peg/go.sum diff --git a/benchmarks/tokenized-stock-peg.yml b/benchmarks/tokenized-stock-peg.yml index df9691c0..9fa21efc 100644 --- a/benchmarks/tokenized-stock-peg.yml +++ b/benchmarks/tokenized-stock-peg.yml @@ -93,119 +93,119 @@ providers: tag: "Nvidia, ~$290k pool depth, 0.3% fee" formula: "p50 over 24h of the absolute deviation (bps) between the NVDA Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="nvda", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="nvda", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="nvda", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="nvda", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="nvda"}[24h]) - series: tsp_deviation_bps{asset="nvda", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"} - slug: aapl name: AAPL tag: "Apple, ~$330k pool depth, 1% fee" formula: "p50 over 24h of the absolute deviation (bps) between the AAPL Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="aapl", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="aapl", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="aapl", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="aapl", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="aapl"}[24h]) - series: tsp_deviation_bps{asset="aapl", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"} - slug: googl name: GOOGL tag: "Alphabet, ~$280k pool depth, 1% fee" formula: "p50 over 24h of the absolute deviation (bps) between the GOOGL Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="googl", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="googl", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="googl", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="googl", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="googl"}[24h]) - series: tsp_deviation_bps{asset="googl", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"} - slug: tsla name: TSLA tag: "Tesla, most active pool of the cohort, 0.3% fee" formula: "p50 over 24h of the absolute deviation (bps) between the TSLA Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="tsla", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="tsla", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="tsla", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="tsla", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="tsla"}[24h]) - series: tsp_deviation_bps{asset="tsla", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"} - slug: pltr name: PLTR tag: "Palantir, ~$31k pool depth, 1% fee" formula: "p50 over 24h of the absolute deviation (bps) between the PLTR Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="pltr", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="pltr", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="pltr", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="pltr", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="pltr"}[24h]) - series: tsp_deviation_bps{asset="pltr", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"} - slug: meta name: META tag: "Meta, ~$89k pool depth, 0.3% fee" formula: "p50 over 24h of the absolute deviation (bps) between the META Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="meta", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="meta", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="meta", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="meta", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="meta"}[24h]) - series: tsp_deviation_bps{asset="meta", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"} - slug: amd name: AMD tag: "AMD, ~$25k pool depth, 1% fee" formula: "p50 over 24h of the absolute deviation (bps) between the AMD Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="amd", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="amd", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="amd", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="amd", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="amd"}[24h]) - series: tsp_deviation_bps{asset="amd", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"} - slug: msft name: MSFT tag: "Microsoft, ~$50k pool depth, 2% fee" formula: "p50 over 24h of the absolute deviation (bps) between the MSFT Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="msft", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="msft", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="msft", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="msft", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="msft"}[24h]) - series: tsp_deviation_bps{asset="msft", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"} - slug: amzn name: AMZN tag: "Amazon, ~$24k pool depth, 2% fee" formula: "p50 over 24h of the absolute deviation (bps) between the AMZN Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="amzn", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="amzn", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="amzn", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="amzn", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="amzn"}[24h]) - series: tsp_deviation_bps{asset="amzn", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"} - slug: spy name: SPY tag: "S&P 500 ETF, ~$65k pool depth, 1% fee" formula: "p50 over 24h of the absolute deviation (bps) between the SPY Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="spy", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="spy", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="spy", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="spy", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="spy"}[24h]) - series: tsp_deviation_bps{asset="spy", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"} - slug: mu name: MU tag: "Micron, ~$35k pool depth, 1% fee" formula: "p50 over 24h of the absolute deviation (bps) between the MU Uniswap v4 pool price on Robinhood Chain and the Yahoo reference, regular market hours only." queries: - p50: quantile_over_time(0.50, tsp_deviation_bps{asset="mu", market_state="regular"}[24h]) - p90: quantile_over_time(0.90, tsp_deviation_bps{asset="mu", market_state="regular"}[24h]) - p99: quantile_over_time(0.99, tsp_deviation_bps{asset="mu", market_state="regular"}[24h]) - mean: avg_over_time(tsp_deviation_bps{asset="mu", market_state="regular"}[24h]) + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="mu"}[24h]) - series: tsp_deviation_bps{asset="mu", market_state="regular"} + series: tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"} diff --git a/benchmarks/usdy-nav-basis.yml b/benchmarks/usdy-nav-basis.yml new file mode 100644 index 00000000..9b756be2 --- /dev/null +++ b/benchmarks/usdy-nav-basis.yml @@ -0,0 +1,90 @@ +# OpenChainBench. Bench № 078 + +slug: usdy-nav-basis +number: "078" +title: USDY NAV basis, live market price vs official redemption rate +seo_title: "USDY price vs NAV 2026" +seo_description: "Does USDY trade at its NAV? Live basis between Ondo's tokenized treasury market price (Orca, Pyth) and its official redemption rate, in bps, keyless." +subtitle: "Signed basis between USDY's onchain market price and the official Ondo redemption rate published on Pyth, in basis points. Tokenized treasuries are the largest RWA segment; this is the live answer to whether the market actually prices the NAV." + +category: RWA +status: live +metric: NAV basis +unit: bps +higher_is_better: false + +seo_intro: | + USDY is Ondo's yield-bearing tokenized treasury note, one of the + largest RWA tokens by float. Its official value is the redemption + rate Ondo publishes (the NAV a redeeming holder receives), but its + price on the open market is whatever the pools say. This page + measures the gap live: the Orca USDY/USDC whirlpool on Solana (the + deepest genuine venue, about $2.9M) and the Pyth market composite, + each against the Pyth redemption rate feed, every 60 seconds, + keyless on every leg. A persistent negative basis means the market + discounts the NAV (liquidity preference, exit friction); a positive + one means buyers pay a premium over redemption value. Most tokenized + treasuries (OUSG, BUIDL, BENJI) are transfer-restricted and never + trade on open pools, so their NAV can never be market-tested; USDY + is the rare one where the question is measurable at all. + +abstract: | + Every 60 seconds the harness reads the Ondo redemption rate and the + Pyth USDY/USD market composite from one Hermes call, and the Orca + USDY/USDC whirlpool price from one Solana getAccountInfo (sqrtPrice + decoded from the account bytes). Basis = (market - NAV) / NAV in + signed basis points per venue. No keys, no transactions. + +methodology: + - "NAV leg: the Pyth Hermes feed Crypto.USDY/USD.RR (the redemption rate Ondo publishes onchain), fetched keyless. The same Hermes call carries the Pyth USDY/USD market composite, which doubles as a venue row." + - "Market leg, Orca: the USDY/USDC whirlpool on Solana (~$2.9M, the deepest genuine USDY pool anywhere), price decoded straight from getAccountInfo bytes (sqrtPrice u128 at offset 65, both mints 6 decimals). Keyless against a public RPC." + - "Basis: signed, (market - NAV) / NAV x 10000. Negative = the market discounts the NAV. The ranking sorts by absolute basis; the sign is the story and both are displayed." + - "Excluded, verified 2026-07-13: the Arbitrum Camelot USDY/USDC pool holds 232 USDY against 7M USDC, effectively drained, its stale price sitting ~345bps under NAV with near zero volume. Kept out of the ranking as the textbook example of why pool depth gates peg quality; revisited monthly." + - "Also not measurable, disclosed: OUSG, BUIDL and BENJI are transfer-restricted mint/redeem instruments with no genuine open pools, so no market test of their NAV exists to publish." + - "USDY is yield accruing: the redemption rate rises daily, so a naive USD peg comparison would show permanent drift. Comparing against the live RR feed removes that by construction." + +findings: + - "{{best_name}} trades closest to NAV at {{best_p50}} (p50 absolute basis, 24h) across {{count}} measured venues." + - "{{name:orca-solana}} ({{p50:orca-solana}}) is the deepest genuine USDY venue anywhere at about $2.9M; its basis is the closest thing to a market verdict on Ondo's published NAV." + - "{{name:pyth-market}} ({{p50:pyth-market}}) aggregates offchain and onchain USDY trading into one composite; its spread against the RR feed is the cleanest single number for the NAV discount." + - "The excluded Camelot pool on Arbitrum is the finding that did not make the table: drained to 232 USDY, price frozen ~345bps under NAV. Depth is not a detail in RWA pricing, it is the whole game." + +faq: + - q: "Does USDY trade at its NAV?" + a: "Close to it, with a measurable basis that this page tracks live. At verification the Orca pool sat within a few bps of the redemption rate and the Pyth market composite about 10bps under. A persistent discount reflects exit friction and liquidity preference, not a broken product; the point is that it is now measured instead of assumed." + - q: "Why only two venues?" + a: "Because genuine USDY liquidity is rarer than the token's float suggests. The Arbitrum Camelot pool is drained and excluded (232 USDY left, price frozen well under NAV), and most other tokenized treasuries never trade openly at all. Two honest venues beat five misleading ones." + - q: "What does a negative basis mean?" + a: "The market prices USDY under its redemption value. Redeeming through Ondo takes time and has minimums, so sellers who want out now accept a small discount. The size and persistence of that discount is exactly what this bench publishes." + - q: "Why is USDY measurable when OUSG and BUIDL are not?" + a: "OUSG, BUIDL and BENJI are transfer-restricted: they move between allowlisted addresses and cannot trade on open AMMs, so no market price exists to compare against NAV. USDY circulates freely and has genuine pools, which makes it the one tokenized treasury where the NAV question can be answered by measurement." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/usdy-nav-basis + +prometheus: + window: 24h + freshness_metric: usdy_basis_bps + +providers: + - slug: orca-solana + name: Orca (Solana) + tag: "USDY/USDC whirlpool, ~$2.9M, deepest genuine USDY venue" + formula: "p50 over 24h of the absolute signed basis (bps) between the Orca whirlpool USDY/USDC price and the Pyth redemption rate." + queries: + p50: quantile_over_time(0.50, abs(usdy_basis_bps{venue="orca-solana"})[24h:]) + p90: quantile_over_time(0.90, abs(usdy_basis_bps{venue="orca-solana"})[24h:]) + p99: quantile_over_time(0.99, abs(usdy_basis_bps{venue="orca-solana"})[24h:]) + mean: avg_over_time(abs(usdy_basis_bps{venue="orca-solana"})[24h:]) + success: avg_over_time(usdy_health{venue="orca-solana"}[24h]) + series: usdy_basis_bps{venue="orca-solana"} + - slug: pyth-market + name: Pyth market composite + tag: "Crypto.USDY/USD aggregate vs the RR feed, same oracle network" + formula: "p50 over 24h of the absolute signed basis (bps) between the Pyth USDY/USD market composite and the Pyth redemption rate." + queries: + p50: quantile_over_time(0.50, abs(usdy_basis_bps{venue="pyth-market"})[24h:]) + p90: quantile_over_time(0.90, abs(usdy_basis_bps{venue="pyth-market"})[24h:]) + p99: quantile_over_time(0.99, abs(usdy_basis_bps{venue="pyth-market"})[24h:]) + mean: avg_over_time(abs(usdy_basis_bps{venue="pyth-market"})[24h:]) + success: avg_over_time(usdy_health{venue="pyth-market"}[24h]) + series: usdy_basis_bps{venue="pyth-market"} diff --git a/benchmarks/xstocks-peg.yml b/benchmarks/xstocks-peg.yml new file mode 100644 index 00000000..9f7d8d8e --- /dev/null +++ b/benchmarks/xstocks-peg.yml @@ -0,0 +1,207 @@ +# OpenChainBench. Bench № 077 + +slug: xstocks-peg +number: "077" +title: xStocks price accuracy, live onchain vs Nasdaq across 12 equities on Solana +seo_title: "xStocks price tracker 2026" +seo_description: "Do Backed's xStocks track the real market? AAPLx, TSLAx, NVDAx and 9 more on Solana vs their Nasdaq price, deviation in bps, live and keyless." +subtitle: "Absolute deviation between each xStock's executable Jupiter price on Solana and its real market reference, in basis points, labeled by market session. Companion to the Robinhood Chain tokenized stock bench: same methodology, different issuer, so the two pages read as a head to head." + +category: RWA +status: live +metric: Price deviation +unit: bps +higher_is_better: false + +seo_intro: | + Backed's xStocks are the other big tokenized equity program: 20+ US + stocks and ETFs issued on Solana and distributed through Kraken and + Bybit, trading around the clock on Raydium and Orca with pool depths + that reach several million dollars. This page measures how closely + each xStock's executable price tracks its real market reference, + every 60 seconds, keyless on both legs. The price measured is the + Jupiter aggregated quote for actually swapping one share in each + direction, so it is an executable mid, not a theoretical pool spot. + Sessions are labeled pre, regular, post and closed, and the closed + series is the weekend drift panel. The same methodology runs against + Robinhood Chain's tokenized equities on the companion bench, which + turns the two pages into the first public issuer head to head for + tokenized stocks. Fun fact this page makes measurable: HOODx, the + tokenized Robinhood stock, trades here on Solana, while Robinhood's + own chain does not tokenize HOOD at all. + +abstract: | + Every 60 seconds the harness quotes one share of each of 12 xStocks + through Jupiter's keyless lite API in both directions (sell to USDC, + buy back), takes the executable mid, corrects for the Token-2022 + scaled amount multiplier where present, and compares against the + Yahoo reference price with holiday-aware session labels. Deviation is + 10000 x |mid - reference| / reference in basis points. Same metric + contract as the Robinhood Chain bench with issuer="xstocks". + +methodology: + - "Onchain leg: Jupiter lite-api swap quotes, keyless, both directions per symbol (sell 1 share to USDC, buy the proceeds back), spaced 1.1s apart, ~26s per sweep for 12 symbols. Price = mid of the two implied prices: an executable aggregated price across Raydium, Orca and every routed venue, not a single pool spot." + - "Token-2022 correction: 7 of 12 xStocks mints carry a ScaledUiAmount multiplier (~1.0009 observed). One batched price/v3 call per tick supplies the live multiplier per mint (usdPrice over usdPricePrescaled), converting raw quote units to exactly one UI share. Ignoring it would bake a ~9bp systematic error into affected symbols." + - "Reference leg: Yahoo Finance spark batch, one keyless call for all 12 underliers per tick, 1 minute candles; session labels (pre, regular, post, closed) derived from Yahoo currentTradingPeriod, holiday aware. HOODx maps to HOOD, SPYx to SPY, QQQx to QQQ, COINx to COIN." + - "Deviation: 10000 x |executable mid - reference| / reference, quantiles over 24h via quantile_over_time. Headline pins market_state=\"regular\"; the closed series is the weekend drift panel." + - "Cohort: the 12 xStocks with verified Jupiter routes at under 2bp of 1-share price impact (2026-07-13): TSLAx, NVDAx, AAPLx, MSFTx, AMZNx, GOOGLx, METAx, HOODx, SPYx, QQQx, COINx, PLTRx. All mints 8 decimals, verified individually; counterfeit lookalike mints excluded by address allowlist." + - "Round-trip honesty: the sell/buy spread observed at verification was ~11bp on TSLAx. Using the mid rather than one side keeps the fee component out of the deviation number; the spread itself is executable cost, not tracking error." + - "Quote asset caveat: prices are in USDC against USD references. A USDC peg wobble would appear as a correlated deviation across all 12 symbols simultaneously." + - "Cross-issuer reading: this bench shares its metric contract with the Robinhood Chain tokenized stock bench (issuer label). Same equity, same reference, two issuers: the pages are directly comparable, with the depth difference disclosed (Solana xStocks pools run 10x to 100x deeper)." + +findings: + - "{{best_name}} tracks its reference tightest at {{best_p50}} (p50, 24h) across {{count}} measured xStocks." + - "{{name:tsla}} deviates {{p50:tsla}} (p50, 24h) against a Raydium pool holding about $2.1M, roughly 50x the depth of the equivalent Robinhood Chain pool, and the tighter tracking that buys is exactly what the cross-issuer comparison exists to show." + - "{{name:hood}} ({{p50:hood}}) is the page's inside joke made measurable: the tokenized Robinhood stock trades on Solana via a third-party issuer while Robinhood's own chain does not tokenize HOOD." + - "{{name:spy}} ({{p50:spy}}) and {{name:qqq}} ({{p50:qqq}}) give the bench its index rows, useful because index arbitrage against deep ETF markets should in theory be the tightest peg of all." + - "The number to watch on weekends: pools trade around the clock while the reference freezes at Friday close, and the closed-session drift measured here is the honest answer to what a 24/7 stock is worth on a Sunday." + +faq: + - q: "Do xStocks track their real stock price?" + a: "During regular hours the liquid names track within tens of basis points, and this page shows the live per-symbol number measured as an executable Jupiter mid, not a theoretical pool spot. Outside market hours the peg loosens by construction, and the closed-session series quantifies exactly how much." + - q: "How is this different from the Robinhood Chain tokenized stock bench?" + a: "Same methodology, different issuer and chain: Backed's xStocks on Solana measured through Jupiter, Robinhood's tokens on their own chain measured through Uniswap v4. The two benches share a metric contract, so the same equity can be compared across issuers directly, depth differences disclosed." + - q: "Why measure the Jupiter quote instead of a specific pool?" + a: "Because it is what a user actually gets: the aggregated executable route across every venue with the fee baked into the round trip. Taking the mid of both directions removes the fee component and leaves the tracking signal. A single pool spot can sit anywhere inside its fee band without being arbitrageable." + - q: "What is the ScaledUiAmount correction?" + a: "Seven xStocks mints use Token-2022 scaled amounts, where raw token units map to UI shares through a live multiplier (about 1.0009 at verification). The harness reads the multiplier every tick from Jupiter's price API and applies it, without which those symbols would carry a permanent ~9bp phantom deviation." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/xstocks-peg + +prometheus: + window: 24h + freshness_metric: tsp_deviation_bps + +providers: + - slug: tsla + name: TSLAx + tag: "Tesla xStock, deepest pool of the cohort (~$2.1M Raydium)" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for TSLAx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="tsla"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"} + - slug: nvda + name: NVDAx + tag: "Nvidia xStock, ~$2.7M Raydium pool" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for NVDAx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="nvda"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"} + - slug: aapl + name: AAPLx + tag: "Apple xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for AAPLx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="aapl"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"} + - slug: msft + name: MSFTx + tag: "Microsoft xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for MSFTx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="msft"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"} + - slug: amzn + name: AMZNx + tag: "Amazon xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for AMZNx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="amzn"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"} + - slug: googl + name: GOOGLx + tag: "Alphabet xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for GOOGLx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="googl"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"} + - slug: meta + name: METAx + tag: "Meta xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for METAx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="meta"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"} + - slug: hood + name: HOODx + tag: "Robinhood stock, tokenized by Backed, not by Robinhood" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for HOODx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="hood"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"} + - slug: spy + name: SPYx + tag: "S&P 500 ETF xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for SPYx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="spy"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"} + - slug: qqq + name: QQQx + tag: "Nasdaq 100 ETF xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for QQQx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="qqq"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"} + - slug: coin + name: COINx + tag: "Coinbase xStock" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for COINx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="coin"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"} + - slug: pltr + name: PLTRx + tag: "Palantir xStock, thinnest route of the cohort (1.8bp impact)" + formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for PLTRx on Solana and the Yahoo reference, regular market hours only." + queries: + p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h]) + p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h]) + p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h]) + mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h]) + success: avg_over_time(tsp_health{asset="pltr"}[24h]) + series: tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"} diff --git a/harnesses/tokenized-stock-peg/cmd/script/main.go b/harnesses/tokenized-stock-peg/cmd/script/main.go index 94227a9a..a12e3e9d 100644 --- a/harnesses/tokenized-stock-peg/cmd/script/main.go +++ b/harnesses/tokenized-stock-peg/cmd/script/main.go @@ -56,11 +56,11 @@ func main() { } } if hasPool { - tspPriceOnchain.WithLabelValues(sym).Set(pool) + tspPriceOnchain.WithLabelValues(sym, "robinhood").Set(pool) } if hasRef && hasPool && ref.Price > 0 { dev := math.Abs(pool-ref.Price) / ref.Price * 10000 - tspDeviationBps.WithLabelValues(sym, state).Set(dev) + tspDeviationBps.WithLabelValues(sym, state, "robinhood").Set(dev) tspHealth.WithLabelValues(sym).Set(1) flag := "" if dev > logThresholdBps && state == "regular" { diff --git a/harnesses/tokenized-stock-peg/cmd/script/metrics.go b/harnesses/tokenized-stock-peg/cmd/script/metrics.go index bd798777..82072a68 100644 --- a/harnesses/tokenized-stock-peg/cmd/script/metrics.go +++ b/harnesses/tokenized-stock-peg/cmd/script/metrics.go @@ -17,12 +17,12 @@ var ( tspDeviationBps = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "tsp_deviation_bps", Help: "Absolute onchain vs reference price deviation per tokenized stock, in bps, labeled by market session state.", - }, []string{"asset", "market_state"}) + }, []string{"asset", "market_state", "issuer"}) tspPriceOnchain = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "tsp_price_onchain_usdg", - Help: "Uniswap v4 pool spot price of the tokenized stock, in USDG.", - }, []string{"asset"}) + Help: "Venue spot price of the tokenized stock, in the venue quote stable.", + }, []string{"asset", "issuer"}) tspPriceReference = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "tsp_price_reference_usd", diff --git a/harnesses/usdy-nav-basis/Dockerfile b/harnesses/usdy-nav-basis/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/usdy-nav-basis/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/usdy-nav-basis/cmd/script/loghub.go b/harnesses/usdy-nav-basis/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/usdy-nav-basis/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/usdy-nav-basis/cmd/script/main.go b/harnesses/usdy-nav-basis/cmd/script/main.go new file mode 100644 index 00000000..d3d3e39b --- /dev/null +++ b/harnesses/usdy-nav-basis/cmd/script/main.go @@ -0,0 +1,232 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math" + "math/big" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// usdy-nav-basis: live basis between USDY's (Ondo tokenized treasury) +// onchain market price and its official redemption rate (NAV). +// +// NAV leg: Pyth Hermes "Crypto.USDY/USD.RR" redemption-rate feed. The +// same Hermes call also carries the Pyth USDY/USD market composite, +// which doubles as a cross-check venue row. Market leg: the Orca +// whirlpool USDY/USDC on Solana (deepest genuine pool, ~$2.9M), +// decoded straight from getAccountInfo (sqrtPrice u128 LE at bytes +// 65..81, both mints 6 decimals so no scale factor). +// +// Excluded, verified 2026-07-13: the Arbitrum Camelot pool holds 232 +// USDY against 7M USDC (drained), its price sits ~345 bps off NAV and +// moves nothing; kept out of the ranking, documented in the spec as +// the cautionary example of why pool depth gates peg quality. +// +// All legs keyless. One Hermes GET + one Solana RPC POST per tick. + +const ( + navFeedID = "e3d1723999820435ebab53003a542ff26847720692af92523eea613a9a28d500" + marketFeedID = "e393449f6aff8a4b6d3e1165a7c9ebec103685f3b41e60db4277b5b6d10e7326" + orcaPool = "AGXrswVDRoUf62UX9voTXv6TCGw6fBUEwDpyUd9YdZfD" + + pollInterval = 60 * time.Second + httpTimeout = 15 * time.Second +) + +var ( + basisBps = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "usdy_basis_bps", + Help: "Signed basis of the venue's USDY price vs the official redemption rate, in bps (positive = premium over NAV).", + }, []string{"venue"}) + + navGauge = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "usdy_nav_usd", + Help: "USDY official redemption rate from the Pyth RR feed.", + }) + + marketPrice = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "usdy_market_price_usd", + Help: "USDY market price per venue.", + }, []string{"venue"}) + + sourceCall = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "usdy_source_call_total", + Help: "Fetch outcomes per source.", + }, []string{"source", "result"}) + + healthGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "usdy_health", + Help: "1 when the venue produced a basis sample on the last tick.", + }, []string{"venue"}) +) + +func envDefault(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func main() { + installLogCapture() + fmt.Println("=== USDY NAV Basis Harness ===") + fmt.Println("OpenChainBench — USDY market price vs official redemption rate, keyless.") + + go func() { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + if err := http.ListenAndServe(envDefault("LISTEN_ADDR", ":2112"), mux); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + } + }() + + client := &http.Client{Timeout: httpTimeout} + tick := func() { + nav, market := fetchHermes(client) + orca := fetchOrca(client) + + if nav <= 0 { + healthGauge.WithLabelValues("orca-solana").Set(0) + healthGauge.WithLabelValues("pyth-market").Set(0) + return + } + navGauge.Set(nav) + + emit := func(venue string, price float64) { + if price <= 0 { + healthGauge.WithLabelValues(venue).Set(0) + return + } + marketPrice.WithLabelValues(venue).Set(price) + bps := (price - nav) / nav * 10000 + basisBps.WithLabelValues(venue).Set(bps) + healthGauge.WithLabelValues(venue).Set(1) + fmt.Printf("[%s] price=%.6f nav=%.6f basis=%+.1fbps\n", venue, price, nav, bps) + } + emit("orca-solana", orca) + emit("pyth-market", market) + } + + tick() + t := time.NewTicker(pollInterval) + defer t.Stop() + for range t.C { + tick() + } +} + +// fetchHermes returns (nav, market) from one Hermes call. +func fetchHermes(client *http.Client) (float64, float64) { + url := "https://hermes.pyth.network/v2/updates/price/latest?ids[]=0x" + navFeedID + "&ids[]=0x" + marketFeedID + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := client.Do(req) + if err != nil { + sourceCall.WithLabelValues("hermes", "network").Inc() + return 0, 0 + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != 200 { + sourceCall.WithLabelValues("hermes", fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return 0, 0 + } + var envel struct { + Parsed []struct { + ID string `json:"id"` + Price struct { + Price string `json:"price"` + Expo int `json:"expo"` + } `json:"price"` + } `json:"parsed"` + } + if err := json.Unmarshal(raw, &envel); err != nil { + sourceCall.WithLabelValues("hermes", "parse").Inc() + return 0, 0 + } + nav, market := 0.0, 0.0 + for _, p := range envel.Parsed { + v, err := strconv.ParseFloat(p.Price.Price, 64) + if err != nil { + continue + } + val := v * math.Pow10(p.Price.Expo) + switch strings.TrimPrefix(strings.ToLower(p.ID), "0x") { + case navFeedID: + nav = val + case marketFeedID: + market = val + } + } + sourceCall.WithLabelValues("hermes", "ok").Inc() + return nav, market +} + +// fetchOrca decodes the whirlpool sqrtPrice (u128 LE at bytes 65..81); +// USDY and USDC are both 6 decimals so price = (sqrtPrice/2^64)^2. +func fetchOrca(client *http.Client) float64 { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","id":%d,"method":"getAccountInfo","params":["%s",{"encoding":"base64"}]}`, + time.Now().UnixNano(), orcaPool, + )) + req, _ := http.NewRequest("POST", envDefault("USDY_SOLANA_RPC", "https://solana-rpc.publicnode.com"), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := client.Do(req) + if err != nil { + sourceCall.WithLabelValues("orca", "network").Inc() + return 0 + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != 200 { + sourceCall.WithLabelValues("orca", fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return 0 + } + var envel struct { + Result struct { + Value struct { + Data []string `json:"data"` + } `json:"value"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &envel); err != nil || len(envel.Result.Value.Data) == 0 { + sourceCall.WithLabelValues("orca", "parse").Inc() + return 0 + } + acct, err := base64.StdEncoding.DecodeString(envel.Result.Value.Data[0]) + if err != nil || len(acct) < 81 { + sourceCall.WithLabelValues("orca", "decode").Inc() + return 0 + } + lo := binary.LittleEndian.Uint64(acct[65:73]) + hi := binary.LittleEndian.Uint64(acct[73:81]) + sqrtPrice := new(big.Float).SetPrec(200).SetInt(new(big.Int).Add( + new(big.Int).Lsh(new(big.Int).SetUint64(hi), 64), + new(big.Int).SetUint64(lo), + )) + q64 := new(big.Float).SetPrec(200).SetInt(new(big.Int).Lsh(big.NewInt(1), 64)) + ratio := new(big.Float).Quo(sqrtPrice, q64) + price := new(big.Float).Mul(ratio, ratio) + f, _ := price.Float64() + sourceCall.WithLabelValues("orca", "ok").Inc() + return f +} diff --git a/harnesses/usdy-nav-basis/go.mod b/harnesses/usdy-nav-basis/go.mod new file mode 100644 index 00000000..1284070e --- /dev/null +++ b/harnesses/usdy-nav-basis/go.mod @@ -0,0 +1,18 @@ +module usdy-nav-basis + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/usdy-nav-basis/go.sum b/harnesses/usdy-nav-basis/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/usdy-nav-basis/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/xstocks-peg/Dockerfile b/harnesses/xstocks-peg/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/xstocks-peg/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/xstocks-peg/cmd/script/config.go b/harnesses/xstocks-peg/cmd/script/config.go new file mode 100644 index 00000000..b151be10 --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/config.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "strings" + "time" +) + +// xstocks-peg: Backed's xStocks tokenized equities on Solana vs their +// Yahoo reference, deviation in bps, session-labeled. Same metric +// contract as the Robinhood harness (tsp_* family) with +// issuer="xstocks", so cross-issuer comparisons are pure PromQL. +// +// Price read: Jupiter lite-api swap quotes in BOTH directions per +// symbol (sell 1 share to USDC, buy with the equivalent USDC); the mid +// is the executable peg price, immune to the price/v3 drift observed +// on thin routes (PLTRx v3 was $1.93 off its executable quote). +// A single batched price/v3 call per tick supplies the Token-2022 +// ScaledUiAmount multiplier (7 of 12 mints carry one, ~1.0009), which +// converts raw 1e8 quote units to exactly one UI share. +// +// Cohort: 12 xStocks with verified Jupiter routes at 1-share impact +// under 2bp (2026-07-13). All mints 8 decimals. + +const ( + usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + oneShareRaw = 100000000 // 1e8 = 1 share at 8 decimals, pre-multiplier + pollInterval = 60 * time.Second + httpTimeout = 15 * time.Second + quoteGap = 1100 * time.Millisecond // lite tier: stay well under 60 req/min + issuerLabel = "xstocks" + logThresholdBps = 100.0 +) + +type Asset struct { + Symbol string // Yahoo ticker (HOODx maps to HOOD, etc.) + Mint string +} + +var assets = []Asset{ + {Symbol: "TSLA", Mint: "XsDoVfqeBukxuZHWhdvWHBhgEHjGNst4MLodqsJHzoB"}, + {Symbol: "NVDA", Mint: "Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh"}, + {Symbol: "AAPL", Mint: "XsbEhLAtcf6HdfpFZ5xEMdqW8nfAvcsP5bdudRLJzJp"}, + {Symbol: "MSFT", Mint: "XspzcW1PRtgf6Wj92HCiZdjzKCyFekVD8P5Ueh3dRMX"}, + {Symbol: "AMZN", Mint: "Xs3eBt7uRfJX8QUs4suhyU8p2M6DoUDrJyWBa8LLZsg"}, + {Symbol: "GOOGL", Mint: "XsCPL9dNWBMvFtTmwcCA5v3xWPSMEBCszbQdiLLq6aN"}, + {Symbol: "META", Mint: "Xsa62P5mvPszXL1krVUnU5ar38bBSVcWAB6fmPCo5Zu"}, + {Symbol: "HOOD", Mint: "XsvNBAYkrDRNhA7wPHQfX3ZUXZyZLdnCQDfHZ56bzpg"}, + {Symbol: "SPY", Mint: "XsoCS1TfEyfFhfvj8EtZ528L3CaKBDBRqRapnBbDF2W"}, + {Symbol: "QQQ", Mint: "Xs8S1uUs1zvS2p7iwtsG3b6fkhpvmwz4GYU3gWAmWHZ"}, + {Symbol: "COIN", Mint: "Xs7ZdzSHLU9ftNJsii5fCeJhoRWSC32SQGzGQtePxNu"}, + {Symbol: "PLTR", Mint: "XsoBhf2ufR8fTyNSjqfU71DYGaE6Z3SUGAidpzriAA4"}, +} + +func listenAddr() string { + if v := strings.TrimSpace(os.Getenv("LISTEN_ADDR")); v != "" { + return v + } + return ":2112" +} diff --git a/harnesses/xstocks-peg/cmd/script/jupiter.go b/harnesses/xstocks-peg/cmd/script/jupiter.go new file mode 100644 index 00000000..a34a4708 --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/jupiter.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// Jupiter lite-api legs. Two swap quotes per symbol per tick (sell one +// share, buy one share worth of USDC back), spaced quoteGap apart to +// stay far under the lite tier's 60 req/min. The mid of the two +// implied prices is the executable peg price. One batched price/v3 +// call per tick provides the ScaledUiAmount multiplier per mint. + +const jupUA = "OpenChainBench/1.0 (+https://openchainbench.com)" + +func jupGet(client *http.Client, url string) ([]byte, string) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, "request_build" + } + req.Header.Set("User-Agent", jupUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, "network" + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, "read" + } + if resp.StatusCode != 200 { + return nil, fmt.Sprintf("http_%d", resp.StatusCode) + } + return raw, "ok" +} + +// fetchMultipliers reads price/v3 for every mint in one call and +// derives the ScaledUiAmount multiplier as usdPrice / usdPricePrescaled +// (1.0 when the mint carries no scaling config). +func fetchMultipliers(client *http.Client) map[string]float64 { + ids := make([]string, 0, len(assets)) + for _, a := range assets { + ids = append(ids, a.Mint) + } + raw, status := jupGet(client, "https://lite-api.jup.ag/price/v3?ids="+strings.Join(ids, ",")) + if raw == nil { + tspSourceCall.WithLabelValues("jup_price", status).Inc() + return nil + } + var flat map[string]struct { + USDPrice float64 `json:"usdPrice"` + USDPricePrescaled float64 `json:"usdPricePrescaled"` + } + if err := json.Unmarshal(raw, &flat); err != nil { + tspSourceCall.WithLabelValues("jup_price", "parse").Inc() + return nil + } + out := make(map[string]float64, len(flat)) + for mint, v := range flat { + m := 1.0 + if v.USDPricePrescaled > 0 && v.USDPrice > 0 { + m = v.USDPrice / v.USDPricePrescaled + } + out[mint] = m + } + tspSourceCall.WithLabelValues("jup_price", "ok").Inc() + return out +} + +type quoteResp struct { + OutAmount string `json:"outAmount"` +} + +func quoteOut(client *http.Client, inMint, outMint string, amount int64) (float64, bool) { + url := fmt.Sprintf( + "https://lite-api.jup.ag/swap/v1/quote?inputMint=%s&outputMint=%s&amount=%d&slippageBps=100", + inMint, outMint, amount, + ) + raw, status := jupGet(client, url) + if raw == nil { + tspSourceCall.WithLabelValues("jup_quote", status).Inc() + return 0, false + } + var q quoteResp + if err := json.Unmarshal(raw, &q); err != nil || q.OutAmount == "" { + tspSourceCall.WithLabelValues("jup_quote", "parse").Inc() + return 0, false + } + n, err := strconv.ParseFloat(q.OutAmount, 64) + if err != nil || n <= 0 { + tspSourceCall.WithLabelValues("jup_quote", "decode").Inc() + return 0, false + } + tspSourceCall.WithLabelValues("jup_quote", "ok").Inc() + return n, true +} + +// fetchOnchainPrices returns the executable mid price in USDC per UI +// share for every symbol. Sequential with quoteGap spacing: ~26s for +// the 12-symbol cohort, comfortably inside the 60s tick. +func fetchOnchainPrices(client *http.Client, multipliers map[string]float64) map[string]float64 { + prices := make(map[string]float64, len(assets)) + start := time.Now() + for _, a := range assets { + mult := 1.0 + if m, ok := multipliers[a.Mint]; ok && m > 0 { + mult = m + } + + // Sell leg: 1 raw share -> USDC. + sellOut, okSell := quoteOut(client, a.Mint, usdcMint, oneShareRaw) + time.Sleep(quoteGap) + sellPx := 0.0 + if okSell { + sellPx = sellOut / 1e6 * mult + } + + // Buy leg: spend the sell proceeds, see how many shares return. + buyPx := 0.0 + if okSell { + gotRaw, okBuy := quoteOut(client, usdcMint, a.Mint, int64(sellOut)) + if okBuy && gotRaw > 0 { + buyPx = (sellOut / 1e6) / (gotRaw / 1e8 / mult) + } + time.Sleep(quoteGap) + } + + switch { + case sellPx > 0 && buyPx > 0: + prices[strings.ToLower(a.Symbol)] = (sellPx + buyPx) / 2 + case sellPx > 0: + prices[strings.ToLower(a.Symbol)] = sellPx + } + } + tspSourceLatency.WithLabelValues("onchain").Set(float64(time.Since(start).Milliseconds())) + return prices +} diff --git a/harnesses/xstocks-peg/cmd/script/loghub.go b/harnesses/xstocks-peg/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/xstocks-peg/cmd/script/main.go b/harnesses/xstocks-peg/cmd/script/main.go new file mode 100644 index 00000000..5cc18415 --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "math" + "net/http" + "strings" + "time" +) + +func main() { + installLogCapture() + fmt.Println("=== xStocks Peg Harness ===") + fmt.Println("OpenChainBench — Backed xStocks on Solana vs Nasdaq reference.") + fmt.Printf("Cohort: %d assets | poll: %s | Jupiter lite-api\n", len(assets), pollInterval) + for _, a := range assets { + fmt.Printf(" - %-6s mint=%s\n", a.Symbol, a.Mint) + } + + go func() { + if err := startMetricsServer(listenAddr()); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + } + }() + + client := &http.Client{Timeout: httpTimeout} + var periods *tradingPeriods + + tick := func() { + now := time.Now() + if periods == nil || now.Sub(periods.FetchedAt) > 30*time.Minute { + if tp := fetchTradingPeriods(client); tp != nil { + periods = tp + } + } + state := periods.state(now) + for _, s := range []string{"pre", "regular", "post", "closed", "unknown"} { + v := 0.0 + if s == state { + v = 1.0 + } + tspMarketState.WithLabelValues(s).Set(v) + } + + refs := fetchReferencePrices(client) + multipliers := fetchMultipliers(client) + onchain := fetchOnchainPrices(client, multipliers) + + for _, a := range assets { + sym := strings.ToLower(a.Symbol) + ref, hasRef := refs[sym] + pool, hasPool := onchain[sym] + if hasRef { + tspPriceReference.WithLabelValues(sym).Set(ref.Price) + if ref.AsOfSec > 0 { + tspRefAge.WithLabelValues(sym).Set(float64(now.Unix() - ref.AsOfSec)) + } + } + if hasPool { + tspPriceOnchain.WithLabelValues(sym, issuerLabel).Set(pool) + } + if hasRef && hasPool && ref.Price > 0 { + dev := math.Abs(pool-ref.Price) / ref.Price * 10000 + tspDeviationBps.WithLabelValues(sym, state, issuerLabel).Set(dev) + tspHealth.WithLabelValues(sym).Set(1) + flag := "" + if dev > logThresholdBps && state == "regular" { + flag = " <-- wide" + } + fmt.Printf("[%s][%s] pool=%.2f ref=%.2f dev=%.1fbps%s\n", sym, state, pool, ref.Price, dev, flag) + } else { + tspHealth.WithLabelValues(sym).Set(0) + } + } + } + + tick() + t := time.NewTicker(pollInterval) + defer t.Stop() + for range t.C { + tick() + } +} diff --git a/harnesses/xstocks-peg/cmd/script/metrics.go b/harnesses/xstocks-peg/cmd/script/metrics.go new file mode 100644 index 00000000..82072a68 --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/metrics.go @@ -0,0 +1,67 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + // Headline: absolute deviation of the onchain pool price from the + // Yahoo reference, in basis points, labeled with the market session + // the sample was taken in. The bench pins its ranking to + // market_state="regular"; the closed-state series is the weekend / + // overnight drift panel. + tspDeviationBps = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_deviation_bps", + Help: "Absolute onchain vs reference price deviation per tokenized stock, in bps, labeled by market session state.", + }, []string{"asset", "market_state", "issuer"}) + + tspPriceOnchain = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_price_onchain_usdg", + Help: "Venue spot price of the tokenized stock, in the venue quote stable.", + }, []string{"asset", "issuer"}) + + tspPriceReference = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_price_reference_usd", + Help: "Reference equity price from Yahoo Finance (regularMarketPrice; last close when the market is closed).", + }, []string{"asset"}) + + tspRefAge = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_reference_age_seconds", + Help: "Age of the reference price sample (now minus regularMarketTime). Large outside regular hours by design.", + }, []string{"asset"}) + + tspMarketState = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_market_session", + Help: "1 for the currently active market session label, 0 otherwise.", + }, []string{"market_state"}) + + tspSourceLatency = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_source_latency_milliseconds", + Help: "Round-trip latency of the last fetch per source.", + }, []string{"source"}) + + tspSourceCall = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "tsp_source_call_total", + Help: "Fetch outcomes per source (onchain batch, yahoo spark, yahoo chart).", + }, []string{"source", "result"}) + + tspHealth = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "tsp_health", + Help: "1 when the last tick produced a deviation sample for the asset, 0 otherwise.", + }, []string{"asset"}) +) + +func startMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/xstocks-peg/cmd/script/reference.go b/harnesses/xstocks-peg/cmd/script/reference.go new file mode 100644 index 00000000..3ebc5c3b --- /dev/null +++ b/harnesses/xstocks-peg/cmd/script/reference.go @@ -0,0 +1,207 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Reference leg: Yahoo Finance, keyless. One spark batch call fetches +// the latest price for every symbol; one chart call (AAPL as the +// bellwether) fetches the day's exact session windows, which Yahoo +// publishes holiday-aware so the harness never maintains an NYSE +// calendar. Verified 2026-07-13 from the harness host: clean 200s with +// a browser User-Agent (datacenter IP), 70-130ms. +// +// Market state is derived from currentTradingPeriod epochs: +// pre / regular / post / closed. Deviation samples carry the state as +// a label so the bench can pin its headline to regular hours and read +// the weekend drift from the closed-state series. + +const ( + sparkHost = "https://query1.finance.yahoo.com/v8/finance/spark" + chartHost = "https://query1.finance.yahoo.com/v8/finance/chart/AAPL" + yahooUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" +) + +type refQuote struct { + Price float64 + AsOfSec int64 +} + +type tradingPeriods struct { + PreStart, PreEnd int64 + RegularStart, RegularEnd int64 + PostStart, PostEnd int64 + FetchedAt time.Time +} + +// fetchReferencePrices returns the freshest Yahoo price per symbol +// (lowercased) from one spark batch call. When the market is closed +// the spark meta still carries regularMarketPrice = last close, which +// is exactly the weekend reference we want. +func fetchReferencePrices(client *http.Client) map[string]refQuote { + syms := make([]string, 0, len(assets)) + for _, a := range assets { + syms = append(syms, a.Symbol) + } + url := sparkHost + "?symbols=" + strings.Join(syms, ",") + "&range=1d&interval=1m" + raw, status := yahooGet(client, url) + if raw == nil { + tspSourceCall.WithLabelValues("yahoo", status).Inc() + // Rotate to query2 once before giving up this tick. + raw, status = yahooGet(client, strings.Replace(url, "query1", "query2", 1)) + if raw == nil { + tspSourceCall.WithLabelValues("yahoo", status).Inc() + return nil + } + } + tspSourceCall.WithLabelValues("yahoo", "ok").Inc() + + // Spark response: {"spark":{"result":[{"symbol":"AAPL","response":[{"meta":{...}}]}]}} + // or the flatter {"AAPL":{...}} shape depending on edge; handle both. + prices := make(map[string]refQuote, len(syms)) + var envel struct { + Spark struct { + Result []struct { + Symbol string `json:"symbol"` + Response []struct { + Meta struct { + RegularMarketPrice float64 `json:"regularMarketPrice"` + RegularMarketTime int64 `json:"regularMarketTime"` + } `json:"meta"` + } `json:"response"` + } `json:"result"` + } `json:"spark"` + } + if err := json.Unmarshal(raw, &envel); err == nil { + for _, r := range envel.Spark.Result { + if len(r.Response) == 0 || r.Response[0].Meta.RegularMarketPrice <= 0 { + continue + } + prices[strings.ToLower(r.Symbol)] = refQuote{ + Price: r.Response[0].Meta.RegularMarketPrice, + AsOfSec: r.Response[0].Meta.RegularMarketTime, + } + } + } + if len(prices) == 0 { + // Flat spark shape (observed live 2026-07-13): + // {"MSFT":{"timestamp":[...],"close":[...],"previousClose":X},...} + // Price = last non-null close; previousClose is the fallback when + // the close array is empty (market closed all day). + var flat map[string]struct { + Timestamp []int64 `json:"timestamp"` + Close []*float64 `json:"close"` + PreviousClose *float64 `json:"previousClose"` + } + if err := json.Unmarshal(raw, &flat); err == nil { + for sym, v := range flat { + q := refQuote{} + for i := len(v.Close) - 1; i >= 0; i-- { + if v.Close[i] != nil && *v.Close[i] > 0 { + q.Price = *v.Close[i] + if i < len(v.Timestamp) { + q.AsOfSec = v.Timestamp[i] + } + break + } + } + if q.Price == 0 && v.PreviousClose != nil && *v.PreviousClose > 0 { + q.Price = *v.PreviousClose + } + if q.Price > 0 { + prices[strings.ToLower(sym)] = q + } + } + } + } + if len(prices) == 0 { + tspSourceCall.WithLabelValues("yahoo", "parse_empty").Inc() + head := string(raw) + if len(head) > 400 { + head = head[:400] + } + fmt.Printf("[yahoo] spark yielded no symbols; body head: %s\n", head) + return nil + } + return prices +} + +// fetchTradingPeriods reads currentTradingPeriod from one chart call. +// Refreshed every 30 minutes; between refreshes marketState() reuses +// the cached windows. +func fetchTradingPeriods(client *http.Client) *tradingPeriods { + raw, status := yahooGet(client, chartHost+"?range=1d&interval=5m") + if raw == nil { + tspSourceCall.WithLabelValues("yahoo_chart", status).Inc() + return nil + } + var envel struct { + Chart struct { + Result []struct { + Meta struct { + CurrentTradingPeriod struct { + Pre struct{ Start, End int64 } `json:"pre"` + Regular struct{ Start, End int64 } `json:"regular"` + Post struct{ Start, End int64 } `json:"post"` + } `json:"currentTradingPeriod"` + } `json:"meta"` + } `json:"result"` + } `json:"chart"` + } + if err := json.Unmarshal(raw, &envel); err != nil || len(envel.Chart.Result) == 0 { + tspSourceCall.WithLabelValues("yahoo_chart", "parse").Inc() + return nil + } + m := envel.Chart.Result[0].Meta.CurrentTradingPeriod + tspSourceCall.WithLabelValues("yahoo_chart", "ok").Inc() + return &tradingPeriods{ + PreStart: m.Pre.Start, PreEnd: m.Pre.End, + RegularStart: m.Regular.Start, RegularEnd: m.Regular.End, + PostStart: m.Post.Start, PostEnd: m.Post.End, + FetchedAt: time.Now(), + } +} + +func (tp *tradingPeriods) state(now time.Time) string { + if tp == nil { + return "unknown" + } + u := now.Unix() + switch { + case u >= tp.RegularStart && u < tp.RegularEnd: + return "regular" + case u >= tp.PreStart && u < tp.PreEnd: + return "pre" + case u >= tp.PostStart && u < tp.PostEnd: + return "post" + default: + return "closed" + } +} + +func yahooGet(client *http.Client, url string) ([]byte, string) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, "request_build" + } + req.Header.Set("User-Agent", yahooUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, "network" + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<22)) + if err != nil { + return nil, "read" + } + if resp.StatusCode != 200 { + return nil, fmt.Sprintf("http_%d", resp.StatusCode) + } + return raw, "ok" +} diff --git a/harnesses/xstocks-peg/go.mod b/harnesses/xstocks-peg/go.mod new file mode 100644 index 00000000..815ee407 --- /dev/null +++ b/harnesses/xstocks-peg/go.mod @@ -0,0 +1,18 @@ +module xstocks-peg + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/xstocks-peg/go.sum b/harnesses/xstocks-peg/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/xstocks-peg/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts index 7f6c5ad2..52c2afe9 100644 --- a/src/lib/removed-benches.ts +++ b/src/lib/removed-benches.ts @@ -38,5 +38,7 @@ export const REMOVED_BENCH_SLUGS = new Set([ "explorer-chain-coverage", "solana-rpc", "tokenized-stock-peg", + "xstocks-peg", + "usdy-nav-basis", "portfolio-chain-coverage", ]); From 8d06d98fcff025f4e5443b70905b31100f64bc6f Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 20:35:25 +0200 Subject: [PATCH 26/50] rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions --- public/logos/orca.png | Bin 0 -> 16297 bytes public/logos/pyth.jpg | Bin 0 -> 7124 bytes public/logos/qqq.png | Bin 0 -> 1528 bytes src/data/provider-registry.ts | 43 +++++++++++++++++++++++++++------- src/lib/logo-manifest.ts | 5 ++++ 5 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 public/logos/orca.png create mode 100644 public/logos/pyth.jpg create mode 100644 public/logos/qqq.png diff --git a/public/logos/orca.png b/public/logos/orca.png new file mode 100644 index 0000000000000000000000000000000000000000..35bf2a7d6d63425276f8a7356ddfdf1688b85db9 GIT binary patch literal 16297 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 G{(k^o6)v&> literal 0 HcmV?d00001 diff --git a/public/logos/qqq.png b/public/logos/qqq.png new file mode 100644 index 0000000000000000000000000000000000000000..dab7e3001cee0056a67227e029594c219c1191a5 GIT binary patch literal 1528 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i985rwk9x{Wb61xg* zIUDwtz1%hbdLARUW&ht+Z98iP$2Scu8<~ZU@OcOfOHNDfw}!2AkK0u<>0bYPX5I#^ z`Sc5Idc`Wjo}A6>Yu@gfe^a<2{^LzLq$h;Uv`x1-?5Q~>vDuuVX72gnntDN5bK6a3 zv9i18-*Ae6lJTKQHFPQ~aD+KsB6_~zu{XR7@y|EYuVktNmg4{aUp^vk)o$G)1}xDT NJYD@<);T3K0RW`B>T&=8 literal 0 HcmV?d00001 diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index e63a1ed6..9e23d722 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -228,32 +228,32 @@ export const PROVIDER_REGISTRY: Record = { aapl: { url: "https://www.apple.com", description: - "Apple tokenized equity issued by Robinhood on Robinhood Chain (contract 0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + "Apple tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, nvda: { url: "https://www.nvidia.com", description: - "Nvidia tokenized equity issued by Robinhood on Robinhood Chain (contract 0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + "Nvidia tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, googl: { url: "https://abc.xyz", description: - "Alphabet tokenized equity issued by Robinhood on Robinhood Chain (contract 0x2e0847E8910a9732eB3fb1bb4b70a580ADAD4FE3), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + "Alphabet tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, tsla: { url: "https://www.tesla.com", description: - "Tesla tokenized equity issued by Robinhood on Robinhood Chain (contract 0x322F0929c4625eD5bAd873c95208D54E1c003b2d), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + "Tesla tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, pltr: { url: "https://www.palantir.com", description: - "Palantir tokenized equity issued by Robinhood on Robinhood Chain (contract 0x894E1EC2D74FFE5AEF8Dc8A9e84686acCB964F2A), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + "Palantir tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, meta: { url: "https://www.meta.com", description: - "Meta Platforms tokenized equity issued by Robinhood on Robinhood Chain (contract 0xc0D6457C16Cc70d6790Dd43521C899C87ce02f35), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + "Meta Platforms tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, amd: { url: "https://www.amd.com", @@ -263,23 +263,48 @@ export const PROVIDER_REGISTRY: Record = { msft: { url: "https://www.microsoft.com", description: - "Microsoft tokenized equity issued by Robinhood on Robinhood Chain (contract 0xe93237C50D904957Cf27E7B1133b510C669c2e74), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + "Microsoft tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, amzn: { url: "https://www.amazon.com", description: - "Amazon tokenized equity issued by Robinhood on Robinhood Chain (contract 0x12f190a9F9d7D37a250758b26824B97CE941bF54), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + "Amazon tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, spy: { url: "https://www.ssga.com", description: - "SPDR S&P 500 ETF (State Street) tokenized equity issued by Robinhood on Robinhood Chain (contract 0x117cc2133c37B721F49dE2A7a74833232B3B4C0C), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", + "SPDR S&P 500 ETF (State Street) tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, mu: { url: "https://www.micron.com", description: "Micron Technology tokenized equity issued by Robinhood on Robinhood Chain (contract 0xfF080c8ce2E5feadaCa0Da81314Ae59D232d4afD), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.", }, + hood: { + url: "https://robinhood.com", + description: + "Robinhood Markets tokenized equity, issued by Backed as HOODx on Solana. Notably absent from Robinhood's own chain: the only onchain HOOD is third party. Tracked live against the Nasdaq price on the xStocks peg benchmark.", + }, + qqq: { + url: "https://www.invesco.com", + description: + "Invesco QQQ Nasdaq 100 ETF, tokenized by Backed as QQQx on Solana. Tracked live against the real ETF price on the xStocks peg benchmark.", + }, + coin: { + url: "https://www.coinbase.com", + description: + "Coinbase tokenized equity, issued by Backed as COINx on Solana, a crypto exchange's stock trading on a blockchain. Tracked live against the Nasdaq price on the xStocks peg benchmark.", + }, + "orca-solana": { + url: "https://www.orca.so", + description: + "Orca is Solana's concentrated liquidity DEX. Its USDY/USDC whirlpool is the deepest genuine venue for Ondo's tokenized treasury and the market leg of the USDY NAV basis benchmark.", + }, + "pyth-market": { + url: "https://pyth.network", + description: + "Pyth Network's USDY/USD market composite aggregates USDY trading into one feed. Measured against Pyth's own USDY redemption rate feed on the NAV basis benchmark.", + }, bloxroute: { url: "https://bloxroute.com", description: diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 757247cc..a4f1ac4d 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -176,6 +176,9 @@ const RAW: Record = { amzn: "/logos/amzn.png", spy: "/logos/spy.svg", mu: "/logos/mu.png", + qqq: "/logos/qqq.png", + "orca-solana": "/logos/orca.png", + "pyth-market": "/logos/pyth.jpg", // ─── Buyback audit (bench 018) ─── sky: "/logos/sky.svg", @@ -378,6 +381,8 @@ const ALIASES: Record = { "sonic-official": "sonic", "monad-official": "monad", "solana-official": "solana", + hood: "robinhood", + coin: "coinbase", "megaeth-official": "megaeth", "celo-official": "celo", "blast-official": "blast", From 5bca1b3083c737a56653d6c9f64408e956f88cde Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 20:36:21 +0200 Subject: [PATCH 27/50] registry: googl links to google.com, abc.xyz 403s aggressively --- src/data/provider-registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index 9e23d722..fbffb0af 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -236,7 +236,7 @@ export const PROVIDER_REGISTRY: Record = { "Nvidia tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, googl: { - url: "https://abc.xyz", + url: "https://www.google.com", description: "Alphabet tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.", }, From ed3678fc76b9f1655c70d9355eefe6b2b60bc8c8 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 20:48:25 +0200 Subject: [PATCH 28/50] cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves --- benchmarks/token-deployment-cost.yml | 4 +-- .../cmd/script/cosmos_cardano_stellar.go | 29 +++++++++++++++---- .../transaction-fee/cmd/script/cardano.go | 17 +++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/benchmarks/token-deployment-cost.yml b/benchmarks/token-deployment-cost.yml index 6c00a430..38f3af8c 100644 --- a/benchmarks/token-deployment-cost.yml +++ b/benchmarks/token-deployment-cost.yml @@ -8,7 +8,7 @@ seo_description: "Live USD cost to create a fungible token on 7 non-EVM L1 chain subtitle: "Live USD cost to bring a fungible token into existence on each chain, using that chain's canonical method. 7 non-EVM L1 chains, 5 minute refresh, no transactions broadcast." seo_intro: | - This page answers a question every memecoin founder, RWA issuer and non-EVM L1 marketing team asks. How much does it actually cost in dollars to create a token on each non-EVM chain right now. We track 7 non-EVM L1 chains in parallel and refresh the number every 5 minutes. Solana uses the SPL mint plus the Associated Token Account plus the Metaplex metadata account so the comparison is fair against ERC20 which embeds name and symbol natively. Sui and Aptos use Move publish gas budgets. Cardano reads coins_per_utxo_size from koios and applies the Conway minUTxO formula. Stellar uses the canonical issuer plus distribution plus trustline two account flow. Cosmos TokenFactory chains (Osmosis, Injective) read denom_creation_fee from the LCD. Every number is reproducible from source. No transactions are broadcast. EVM chains are excluded from this leaderboard for now while we finalise the canonical OpenZeppelin v5 ERC20 artifact bytecode used in eth_estimateGas. + This page answers a question every memecoin founder, RWA issuer and non-EVM L1 marketing team asks. How much does it actually cost in dollars to create a token on each non-EVM chain right now. We track 7 non-EVM L1 chains in parallel and refresh the number every 5 minutes. Solana uses the SPL mint plus the Associated Token Account plus the Metaplex metadata account so the comparison is fair against ERC20 which embeds name and symbol natively. Sui and Aptos use Move publish gas budgets. Cardano reads coins_per_utxo_size from koios and applies the Conway minUTxO formula. Stellar uses the canonical issuer plus distribution plus trustline two account flow at protocol-minimum reserves. Cosmos TokenFactory chains (Osmosis, Injective) read denom_creation_fee from the LCD. Every number is reproducible from source. No transactions are broadcast. EVM chains are excluded from this leaderboard for now while we finalise the canonical OpenZeppelin v5 ERC20 artifact bytecode used in eth_estimateGas. faq: - q: "What does this benchmark measure?" @@ -58,7 +58,7 @@ methodology: - "Aptos. /v1/estimate_gas_price.gas_estimate octas per unit times 150000 gas units for a canonical FA standard publish. Converted to USD via APT price." - "Cosmos TokenFactory (Osmosis, Injective). LCD //tokenfactory/v1beta1/params.denom_creation_fee. Empty array (Osmosis) means gas only, rendered as less than 0.001 dollar. Injective charges a flat 0.1 INJ." - "Cardano. Koios /epoch_params.coins_per_utxo_size. Apply Conway minUTxO formula = (160 byte overhead + 70 byte single asset bundle) × coins_per_utxo_size + 180000 lovelace mint tx fee. Converted to USD via ADA price." - - "Stellar. Horizon /ledgers.base_reserve_in_stroops and base_fee_in_stroops on the latest ledger. Total cost = 3 base reserves (issuer account + distribution account + trustline on distribution) + 2 base fees (create_account + change_trust). Converted to USD via XLM price." + - "Stellar. Horizon /ledgers.base_reserve_in_stroops and base_fee_in_stroops on the latest ledger. Total cost = 5 base reserves (the protocol minimum of 2 per new account for issuer and distribution, plus 1 for the trustline) + 2 base fees (create_account + change_trust). Reserves are locked capital, refundable on account closure, counted here because bringing the asset into existence requires them upfront. Converted to USD via XLM price." - "USD prices. api.mobula.io/api/1/market/multi-data?symbols=SOL,SUI,APT,OSMO,INJ,ADA,XLM polled every 5 minutes." - "Failures. Any upstream error increments token_deployment_samples_total{chain,status=error} and leaves the previous gauge value in place." - "EVM chains excluded. Ethereum, BNB Chain, Avalanche, Polygon, Arbitrum, Optimism, Base, Blast, Mantle, opBNB, Celo, Scroll and Linea were previously exposed but relied on a placeholder init bytecode that undercounted real ERC20 deploy cost by 30 to 500x. Removed until the canonical OZ v5 artifact is finalised." diff --git a/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go b/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go index 1aa7c5f6..061ae2fa 100644 --- a/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go +++ b/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go @@ -175,7 +175,18 @@ type koiosEpochParams []struct { CoinsPerUtxoSize json.Number `json:"coins_per_utxo_size"` } +// Cached for an hour: epoch params move once per 5-day epoch and the +// shared koios public tier (5k/day per IP) is also consumed by the +// transaction-fee harness on this host. +var cardanoCostCache struct { + sample Sample + fetchedAt time.Time +} + func (s *cardanoSampler) Sample(ch ChainConfig) (Sample, error) { + if time.Since(cardanoCostCache.fetchedAt) < time.Hour && cardanoCostCache.sample.CostNative > 0 { + return cardanoCostCache.sample, nil + } resp, err := s.http.Get(ch.RPCURL + "/epoch_params") if err != nil { return Sample{}, err @@ -200,13 +211,16 @@ func (s *cardanoSampler) Sample(ch ChainConfig) (Sample, error) { } utxoSize := float64(cardanoEntryOverheadBytes + cardanoBundleBytes) lovelace := coins*utxoSize + cardanoMintTxFeeLovelace - return Sample{CostNative: lovelace, NativeUnit: "lovelace", GasUnits: math.NaN()}, nil + out := Sample{CostNative: lovelace, NativeUnit: "lovelace", GasUnits: math.NaN()} + cardanoCostCache.sample = out + cardanoCostCache.fetchedAt = time.Now() + return out, nil } // ----- Stellar ---------------------------------------------------------- -// Stellar custom asset cost is 3 base reserves locked (issuer account + -// distribution account + trustline on distribution) + 2 tx fees +// Stellar custom asset cost is 5 base reserves locked (2 per new +// account for issuer + distribution, 1 for the trustline) + 2 tx fees // (create_account + change_trust). base_reserve_in_stroops and // base_fee_in_stroops are in /ledgers — we use the latest ledger. // Reusing the issuer as the distribution account is an anti-pattern @@ -240,8 +254,11 @@ func (s *stellarSampler) Sample(ch ChainConfig) (Sample, error) { return Sample{}, fmt.Errorf("empty ledgers response") } rec := l.Embedded.Records[0] - // 3 × base_reserve (issuer + distribution + trustline) + 2 × base_fee - // (create_account + change_trust). - stroops := 3*rec.BaseReserve + 2*rec.BaseFee + // 5 × base_reserve + 2 × base_fee (create_account + change_trust). + // Protocol minimums: every new account locks 2 base reserves and + // each trustline adds 1, so issuer (2) + distribution (2) + + // trustline on distribution (1) = 5. The previous formula counted + // 3 and understated the locked capital by a full XLM. + stroops := 5*rec.BaseReserve + 2*rec.BaseFee return Sample{CostNative: float64(stroops), NativeUnit: "stroop", GasUnits: math.NaN()}, nil } diff --git a/harnesses/transaction-fee/cmd/script/cardano.go b/harnesses/transaction-fee/cmd/script/cardano.go index 4cf92d28..0c7bbec6 100644 --- a/harnesses/transaction-fee/cmd/script/cardano.go +++ b/harnesses/transaction-fee/cmd/script/cardano.go @@ -51,8 +51,25 @@ type koiosEpochParams struct { MinFeeB float64 `json:"min_fee_b"` } +// Koios epoch params change once per 5-day epoch, but this fetcher runs +// on the 30s harness cycle. Caching for an hour keeps the total koios +// load around 24 calls/day (both OCB harnesses combined stay far under +// the 5,000/day public tier that we exhausted before this cache). +var cardanoParamCache struct { + minFeeA, minFeeB float64 + fetchedAt time.Time +} + func (f *cardanoFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + if time.Since(cardanoParamCache.fetchedAt) < time.Hour && cardanoParamCache.minFeeA > 0 { + feeLovelace := cardanoParamCache.minFeeB + (cardanoTypicalTxBytes * cardanoParamCache.minFeeA) + return []FeeSample{{Chain: ch.Slug, Tier: "single", NativeFee: feeLovelace}}, nil + } minFeeA, minFeeB, err := f.fetchProtocolParams(ch.RPCURL) + if err == nil { + cardanoParamCache.minFeeA, cardanoParamCache.minFeeB = minFeeA, minFeeB + cardanoParamCache.fetchedAt = time.Now() + } if err != nil { // Fall back to known-good constants rather than emit nothing. fmt.Printf("[cardano] koios fetch failed, using fallback constants: %v\n", err) From 29a45944cb8bf89a4517ecb3db5baa71087ba58c Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:49:23 +0200 Subject: [PATCH 29/50] fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152) * spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) * bridge-monitor: sub-50ms buckets + nanosecond precision on Observe Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000] to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents on solver-cached corridors (HyperCore in particular) returns via a 1Click coordinator fast path in <50ms, and the previous scheme lumped every one of those observations into (0, 50] so histogram_quantile linearly interpolated to ~25ms regardless of true value. The p50 shown on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a bucket floor, not a measurement. Sub-50 buckets let the real bimodality surface. Also swap Observe(float64(quoteLatency.Milliseconds())) for Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6 per-bridge observers (across, debridge, lifi, mobula, nearintents, relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0, same bug class as the RPC harness fix in PR #1128. Not a hot path here (network RTT dominates), but keeps precision consistent with the new low buckets and cheap to fix while we are already touching each file. Bench YAML methodology + FAQ updated to reflect the new bucket set. --------- Co-authored-by: Florent Tapponnier --- benchmarks/bridge-quote-latency.yml | 4 ++-- harnesses/bridge-monitor/cmd/monitor/across_bridge.go | 2 +- .../bridge-monitor/cmd/monitor/debridge_bridge.go | 2 +- harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go | 2 +- harnesses/bridge-monitor/cmd/monitor/metrics.go | 10 ++++++++-- harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go | 2 +- .../bridge-monitor/cmd/monitor/nearintents_bridge.go | 2 +- harnesses/bridge-monitor/cmd/monitor/relay_bridge.go | 2 +- 8 files changed, 16 insertions(+), 10 deletions(-) diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index 0e98a1d9..2f494ca3 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -54,7 +54,7 @@ methodology: - "Notional sizes: $5, $50, $300 per quote." - "Cadence: full sweep (4 routes × 3 amounts × N bridges) every 5 minutes for 24 hours." - "Region: eu-west. Single point of measurement; multi-region requires running additional monitor instances." - - "Histogram buckets: 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms." + - "Histogram buckets: 10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms. Sub-50ms buckets added 2026-07-13 so solver-cached fast paths (Near Intents on HyperCore in particular) surface their true p50 instead of being floored at the interpolation of the (0, 50] bucket." - "Failures (quote_failed, execution_failed, unsupported route) excluded from latency aggregates and counted toward success rate." # Per-corridor leader rewrite deferred: this YAML does not yet declare @@ -89,7 +89,7 @@ faq: - q: "Are intent and relay bridges always faster than aggregators?" a: "On a single-route query, usually. Intent layers like Relay quote against a pool of pre-positioned solvers, so the API is a thin price-discovery call. Aggregators like LI.FI run a route-search graph over N underlying bridges per request, which adds 100 to 400 ms of irreducible work. On multi-hop or rare-corridor queries the aggregator advantage in coverage matters more than the latency gap, and the comparison flips." - q: "How does OpenChainBench measure bridge quote latency?" - a: "The harness issues identical quote requests against every bridge for the same route and notional, every five minutes, from a single eu-west origin. Each request times wall-clock duration from send to last byte received, then publishes a Prometheus histogram (50, 100, 200, 500, 1000, 2000, 5000, 10000 ms buckets). p50, p90 and p99 are derived via `histogram_quantile` over a rolling 24-hour window. Errored quotes (quote_failed, unsupported route, timeout) are excluded from the latency aggregate and counted toward success rate so a fast-but-broken bridge cannot game the headline." + a: "The harness issues identical quote requests against every bridge for the same route and notional, every five minutes, from a single eu-west origin. Each request times wall-clock duration from send to last byte received, then publishes a Prometheus histogram (10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms buckets). p50, p90 and p99 are derived via `histogram_quantile` over a rolling 24-hour window. Errored quotes (quote_failed, unsupported route, timeout) are excluded from the latency aggregate and counted toward success rate so a fast-but-broken bridge cannot game the headline." - q: "Does quote latency affect the price the user gets?" a: "Indirectly, yes. A slower quote API leaves a larger window between the moment the price is quoted and the moment the user signs. On volatile corridors the underlying spot can drift in that window, which surfaces as a quoted-vs-realized gap. Intent layers compensate by re-quoting at signing time; aggregators usually pass through a slippage parameter. The fee benchmark on this site (`/benchmarks/bridge-fee`) measures the realized cost; this page measures the latency half of the same flow." diff --git a/harnesses/bridge-monitor/cmd/monitor/across_bridge.go b/harnesses/bridge-monitor/cmd/monitor/across_bridge.go index fa43f673..d7f6cb9b 100644 --- a/harnesses/bridge-monitor/cmd/monitor/across_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/across_bridge.go @@ -188,7 +188,7 @@ func (a *AcrossBridge) TestRoute(route TestRoute, amount, amountUsd float64, raw // Latency is only meaningful for quotes that returned a usable route // (same rule as the other bridges). Fast 4xx rejections must not enter // the histogram or they skew the leaderboard. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) // fees.total.amountUsd is Across's own USD valuation of input value minus diff --git a/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go b/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go index 2c15fd65..031eb0c3 100644 --- a/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go @@ -121,7 +121,7 @@ func (d *DebridgeBridge) TestRoute(route TestRoute, amount, amountUsd float64, r // (the published methodology measures exactly that). Fast failures, e.g. // Cloudflare 403s answered in 30ms, must not enter the histogram: they // made deBridge look 15x faster the moment its API started rejecting us. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) // Debridge returns input (with opEx ajusté) and output diff --git a/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go b/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go index 82c928e1..ba7b9a58 100644 --- a/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go @@ -151,7 +151,7 @@ func (l *LiFiBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawUn // (the published methodology measures exactly that). Fast failures, e.g. // Cloudflare 403s answered in 30ms, must not enter the histogram: they // made deBridge look 15x faster the moment its API started rejecting us. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) inUsd, _ := strconv.ParseFloat(quote.Estimate.FromAmountUSD, 64) diff --git a/harnesses/bridge-monitor/cmd/monitor/metrics.go b/harnesses/bridge-monitor/cmd/monitor/metrics.go index bd42f6c5..5f2fa0f3 100644 --- a/harnesses/bridge-monitor/cmd/monitor/metrics.go +++ b/harnesses/bridge-monitor/cmd/monitor/metrics.go @@ -6,11 +6,17 @@ import ( ) var ( - // Quote latency (time to get quote response) + // Quote latency (time to get bridge quote in ms). Sub-50ms buckets + // (10, 25) added 2026-07-13 because Near Intents on solver-cached + // corridors (HyperCore in particular) returns in <50ms via the 1Click + // coordinator's cached-price fast path, and the previous [50, ...] + // scheme lumped every one of those observations into the (0, 50] + // bucket. histogram_quantile then linearly interpolated to ~25ms + // regardless of the true value, hiding the real bimodality. bridgeQuoteLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "bridge_quote_latency_ms", Help: "Latency to get bridge quote in milliseconds", - Buckets: []float64{50, 100, 200, 500, 1000, 2000, 5000, 10000}, + Buckets: []float64{10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000}, }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) // Execution latency (broadcast to funds received) diff --git a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go index 551428d2..4f1ab858 100644 --- a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go @@ -363,7 +363,7 @@ func (m *MobulaBridge) TestRoute(route TestRoute, amount, amountUsd float64, reg } // Success-only: see debridge_bridge.go, failures must not enter the histogram. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) diff --git a/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go b/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go index 645f2f0c..2958a8e6 100644 --- a/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go @@ -219,7 +219,7 @@ func (n *NearIntentsBridge) TestRoute(route TestRoute, amount, amountUsd float64 // Latency is only meaningful for quotes that returned a usable response // (same rule as the other bridges). Fast 4xx rejections must not enter // the histogram or they skew the leader board. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) inUsd, _ := strconv.ParseFloat(quote.Quote.AmountInUsd, 64) diff --git a/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go b/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go index 708aa052..7350d132 100644 --- a/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go @@ -188,7 +188,7 @@ func (r *RelayBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawU // (the published methodology measures exactly that). Fast failures, e.g. // Cloudflare 403s answered in 30ms, must not enter the histogram: they // made deBridge look 15x faster the moment its API started rejecting us. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) inUsd, _ := strconv.ParseFloat(quote.Details.CurrencyIn.AmountUsd, 64) From e1dc0eefc6633e82b2e5afb7b8c97bfb1eab5f47 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:12:13 +0200 Subject: [PATCH 30/50] fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 3 machine-readable surface bugs surfaced by GEO audit 1. /api/citable/{date} unit scaling. Snapshot route emitted `value: 627` for `aggregator-head-lag` while the live /api/citable emitted `value: 0.63` on the same measurement. The live route already wraps with valueInDeclaredUnit(raw, b.unit) but the snapshot route was left with the raw internal-ms value. Wrap the same way; sub-1 s benches now render in the declared `s` unit on both routes. 2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=. getBenchmark accepted a filters object but the route never parsed the URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the cross-chain aggregate. Parse the four dimension params, pass through to the loader; unknown values fall back to unfiltered inside the loader. Also widen getBenchmark options type to include kind and venue — the underlying loadBenchmark already supports both. 3. Draft benches leaked a fake freshness signal. Draft placeholder set lastRunAt to new Date for type safety (Benchmark.lastRunAt is non-null), and both citable routes passed it straight through as asOf. LLM crawlers treating asOf as ground truth would think a draft bench was measured every minute. Null asOf in the JSON when status is draft; the internal type stays intact. * fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified The GEO audit review found the initial fix in commit f6f84b8 only covered the two /api/citable JSON surfaces, but the same "draft benches spoof freshness" class of bug lives on three more machine-readable channels that LLM crawlers also key on: - /api/stat/{slug} asOf field. - MCP tool responses (list_benchmarks, get_benchmark, resource template, and the plain-text "Last sample" line). - JSON-LD dateModified on the bench page, per-chain sub-page, answer page and alternatives page — Google, Bing, Perplexity all consume dateModified as a freshness ranking signal. Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every call site. Draft benches now uniformly omit or null the freshness timestamp; live benches unchanged. On the visible /answers page, the "Data as of..." line and its