From 6203246b5c7e9b56242a94df892808d44f08cae9 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:22:12 +0200 Subject: [PATCH 1/6] bridge-monitor: 3/30 tiers, 5-route conserving loop, HL withdraw, proactive equalizer --- .../bridge-monitor/cmd/monitor/config.go | 49 ++-- .../bridge-monitor/cmd/monitor/executor.go | 114 ++++---- .../cmd/monitor/hyperliquid_withdraw.go | 274 ++++++++++++++++++ .../cmd/monitor/hyperliquid_withdraw_test.go | 95 ++++++ harnesses/bridge-monitor/cmd/monitor/main.go | 204 ++++++++++--- .../bridge-monitor/cmd/monitor/metrics.go | 6 +- .../cmd/monitor/mobula_bridge.go | 168 ++++++----- .../bridge-monitor/cmd/monitor/reaper.go | 17 +- .../bridge-monitor/cmd/monitor/rebalancer.go | 58 ++++ .../bridge-monitor/cmd/monitor/scheduler.go | 192 +++++------- .../cmd/monitor/selfheal_test.go | 27 +- .../cmd/monitor/wallet_snapshot.go | 8 +- 12 files changed, 877 insertions(+), 335 deletions(-) create mode 100644 harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw_test.go diff --git a/harnesses/bridge-monitor/cmd/monitor/config.go b/harnesses/bridge-monitor/cmd/monitor/config.go index 0b828f92c..e0d279bb9 100644 --- a/harnesses/bridge-monitor/cmd/monitor/config.go +++ b/harnesses/bridge-monitor/cmd/monitor/config.go @@ -12,13 +12,13 @@ import ( type Config struct { // API Keys - RelayAPIKey string - MobulaAPIKey string - DebridgeAPIKey string - LiFiAPIKey string - NearIntentsAPIKey string - SquidIntegratorID string - SocketAPIKey string + RelayAPIKey string + MobulaAPIKey string + DebridgeAPIKey string + LiFiAPIKey string + NearIntentsAPIKey string + SquidIntegratorID string + SocketAPIKey string // Wallet Configuration WalletEVMPrivateKey string @@ -27,13 +27,14 @@ type Config struct { WalletSOLAddress string // Execution Configuration - ExecutionMode string // "dry-run", "single-test", "production" - Freq5USD time.Duration // Frequency for $5 tests - Freq50USD time.Duration // Frequency for $50 tests - Freq300USD time.Duration // Frequency for $300 tests - EnableDebridge bool // Execute Debridge (expensive) - MaxDailySpendUSD float64 // Safety cap - TestAmountUSD float64 // Override test amount (for testing with small amounts) + ExecutionMode string // "dry-run", "single-test", "production" + Freq3USD time.Duration // Frequency for $3 tests + Freq30USD time.Duration // Frequency for $30 tests + EnableDebridge bool // Execute Debridge (expensive) + EnableR4RoundTrip bool // Execute R4 TRUMP<->BRETT round-trip (memes, not fee-neutral) + EnableR5Hypercore bool // Execute R5 Arb<->HyperCore round-trip (needs HL withdraw path) + MaxDailySpendUSD float64 // Safety cap + TestAmountUSD float64 // Override test amount (for testing with small amounts) // General MonitorRegion string @@ -91,14 +92,19 @@ func loadEnv() (*Config, error) { config.ExecutionMode = "dry-run" // Safe default } - // Frequencies (default: $5 daily, $50 2x/week, $300 2x/month) - config.Freq5USD = parseDuration(os.Getenv("FREQ_5_USD"), 24*time.Hour) - config.Freq50USD = parseDuration(os.Getenv("FREQ_50_USD"), 84*time.Hour) // ~3.5 days - config.Freq300USD = parseDuration(os.Getenv("FREQ_300_USD"), 168*time.Hour) // 7 days (weekly) + // Frequencies (default: $3 daily, $30 2x/week) + config.Freq3USD = parseDuration(os.Getenv("FREQ_3_USD"), 24*time.Hour) + config.Freq30USD = parseDuration(os.Getenv("FREQ_30_USD"), 84*time.Hour) // ~3.5 days // Debridge execution (default: disabled, too expensive) config.EnableDebridge = os.Getenv("ENABLE_DEBRIDGE_EXEC") == "true" + // R4 (TRUMP<->BRETT) and R5 (Arb<->HyperCore) round-trip execution are + // opt-in: both move value across non-stable or asymmetric legs, so they + // stay quote-only until deliberately enabled and validated in single-test. + config.EnableR4RoundTrip = os.Getenv("ENABLE_R4_ROUNDTRIP_EXEC") == "true" + config.EnableR5Hypercore = os.Getenv("ENABLE_R5_HYPERCORE_EXEC") == "true" + // Max daily spend (default: $10/day for safety) config.MaxDailySpendUSD = parseFloat(os.Getenv("MAX_DAILY_SPEND_USD"), 10.0) @@ -216,10 +222,11 @@ func (c *Config) LogConfig() { log.Printf(" EVM Private Key: %s", maskKey(c.WalletEVMPrivateKey)) log.Printf(" Solana Private Key: %s", maskKey(c.WalletSOLPrivateKey)) log.Printf(" Execution Mode: %s", c.ExecutionMode) - log.Printf(" $5 Frequency: %v", c.Freq5USD) - log.Printf(" $50 Frequency: %v", c.Freq50USD) - log.Printf(" $300 Frequency: %v", c.Freq300USD) + log.Printf(" $3 Frequency: %v", c.Freq3USD) + log.Printf(" $30 Frequency: %v", c.Freq30USD) log.Printf(" Debridge Execution: %v", c.EnableDebridge) + log.Printf(" R4 Round-trip Execution: %v", c.EnableR4RoundTrip) + log.Printf(" R5 HyperCore Execution: %v", c.EnableR5Hypercore) log.Printf(" Max Daily Spend: $%.2f", c.MaxDailySpendUSD) if c.TestAmountUSD > 0 { log.Printf(" Test Amount Override: $%.2f", c.TestAmountUSD) diff --git a/harnesses/bridge-monitor/cmd/monitor/executor.go b/harnesses/bridge-monitor/cmd/monitor/executor.go index 7428375fc..60f4eb7b3 100644 --- a/harnesses/bridge-monitor/cmd/monitor/executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/executor.go @@ -25,9 +25,8 @@ const ( // 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 + Freq3USD time.Duration // How often to run $3 tests + Freq30USD time.Duration // How often to run $30 tests EnableDebridge bool // Whether to execute Debridge (expensive) MaxDailySpendUSD float64 // Safety cap on daily spending } @@ -38,8 +37,8 @@ type ExecutionResult struct { Route TestRoute AmountUSD float64 QuoteLatencyMs int64 - ExecutionLatencyMs int64 // Time from broadcast to funds received - E2ELatencyMs int64 // Time from quote start to funds received + ExecutionLatencyMs int64 // Time from broadcast to funds received + E2ELatencyMs int64 // Time from quote start to funds received Success bool Reverted bool Error error @@ -280,12 +279,9 @@ func (e *Executor) RunReal(route TestRoute, amountUSD float64) []*ExecutionResul return results } - // Calculate raw units - for USDC/USDT amount equals USD, for TRUMP convert - amount := amountUSD - if route.Name == "TRUMP_SOL_BRETT_BASE" { - amount = amountUSD / TokenPriceUSD("TRUMP", 2.55) // live TRUMP price (5min cache) - } - rawUnits := toRawUnits(amount) + // Calculate raw units. Stables map 1:1 at 6 decimals; TRUMP/BRETT convert + // via live price and their own decimals (see sourceNativeAmount). + amount, rawUnits := sourceNativeAmount(route, amountUSD) // Execute on each bridge (except Debridge - too expensive) bridges := []string{"mobula", "relay", "lifi"} @@ -333,11 +329,9 @@ func (e *Executor) RunBridgeOnRoute(bridge string, route TestRoute, amountUSD fl return nil } - amount := amountUSD - if route.Name == "TRUMP_SOL_BRETT_BASE" { - amount = amountUSD / TokenPriceUSD("TRUMP", 2.55) // live TRUMP price (5min cache) - } - rawUnits := toRawUnits(amount) + // Calculate raw units. Stables map 1:1 at 6 decimals; TRUMP/BRETT convert + // via live price and their own decimals (see sourceNativeAmount). + amount, rawUnits := sourceNativeAmount(route, amountUSD) result := e.executeOnBridge(bridge, route, amount, amountUSD, rawUnits) if result == nil { @@ -944,6 +938,16 @@ func (e *Executor) recordExecutionMetrics(result *ExecutionResult) { // getSourceTokenName returns the source token name for balance checking (legacy - fallback) func getSourceTokenName(route TestRoute) string { + // Resolve by source token address first so reverse legs (e.g. the R4 + // return BRETT→TRUMP) are classified correctly regardless of route name. + switch strings.ToLower(route.FromToken) { + case "6p6xghyf7aee6tzksmfsko444wqop15icusqi2jfgipn": + return "TRUMP" + case "0x532f27101965dd16442e59d40670faf5ebb142e4": + return "BRETT" + case "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9": + return "USDT" + } switch route.Name { case "TRUMP_SOL_BRETT_BASE": return "TRUMP" @@ -954,6 +958,23 @@ func getSourceTokenName(route TestRoute) string { } } +// sourceNativeAmount converts a USD notional into the source token's native +// amount and its raw on-chain units, handling non-stable / non-6-decimal +// sources: TRUMP (6 dec) and BRETT (18 dec, only ever a source on the R4 +// return leg). USDC/USDT map 1:1 at 6 decimals. +func sourceNativeAmount(route TestRoute, amountUSD float64) (float64, string) { + switch getSourceTokenName(route) { + case "TRUMP": + amt := amountUSD / TokenPriceUSD("TRUMP", 2.55) + return amt, toRawUnits(amt) + case "BRETT": + amt := amountUSD / TokenPriceUSD("BRETT", 0.0072) + return amt, toRawUnitsDec(amt, 18) + default: + return amountUSD, toRawUnits(amountUSD) + } +} + // getAvailableBalance looks up balance first by contract address (robust against symbol mismatch // like USDT vs USDT0), then falls back to symbol name. func getAvailableBalance(balances map[string]map[string]float64, route TestRoute) float64 { @@ -973,12 +994,9 @@ func getAvailableBalance(balances map[string]map[string]float64, route TestRoute func (e *Executor) testBridgeDryRun(bridge string, route TestRoute, amountUSD float64) { log.Printf(" [%s] Simulating $%.0f %s...", bridge, amountUSD, route.Name) - // Calculate raw units - for USDC/USDT amount equals USD, for TRUMP convert - amount := amountUSD - if route.Name == "TRUMP_SOL_BRETT_BASE" { - amount = amountUSD / TokenPriceUSD("TRUMP", 2.55) // live TRUMP price (5min cache) - } - rawUnits := toRawUnits(amount) + // Calculate raw units. Stables map 1:1 at 6 decimals; TRUMP/BRETT convert + // via live price and their own decimals (see sourceNativeAmount). + amount, rawUnits := sourceNativeAmount(route, amountUSD) // Determine sender address based on source chain senderAddress := e.walletManager.EVMAddress @@ -1110,9 +1128,8 @@ func (e *Executor) ValidateSetup() error { // Check execution config log.Printf(" ⚙️ Mode: %s", e.config.Mode) - log.Printf(" ⚙️ $5 frequency: %v", e.config.Freq5USD) - log.Printf(" ⚙️ $50 frequency: %v", e.config.Freq50USD) - log.Printf(" ⚙️ $300 frequency: %v", e.config.Freq300USD) + log.Printf(" ⚙️ $3 frequency: %v", e.config.Freq3USD) + log.Printf(" ⚙️ $30 frequency: %v", e.config.Freq30USD) log.Printf(" ⚙️ Debridge execution: %v", e.config.EnableDebridge) log.Printf(" ⚙️ Max daily spend: $%.2f", e.config.MaxDailySpendUSD) @@ -1124,34 +1141,30 @@ func (e *Executor) ValidateSetup() error { func (e *Executor) EstimateMonthlyCost() { log.Println("💰 Estimating monthly costs...") - // Costs per execution (from analysis). $300 is the new "large ticket" tier - // (down from $500 — capital constraint on current wallet, see README). - costPer5 := 1.55 // M/R/L combined for 3 routes - costPer50 := 2.85 - costPer300 := 9.02 // ~$3.01/cycle × 3 bridges = 9 TX + // Costs per execution (from analysis). Two tiers only ($3 daily, $30 2x/wk); + // the fee is dominated by fixed per-TX gas, so the small notionals cost + // almost the same as the retired $5/$50 rungs. + costPer3 := 1.55 // M/R/L combined for 3 routes + costPer30 := 2.85 if e.config.EnableDebridge { - costPer5 += 8.30 - costPer50 += 8.95 - costPer300 += 11.00 + costPer3 += 8.30 + costPer30 += 8.95 } // Calculate monthly executions based on frequency daysInMonth := 30.0 - exec5PerMonth := (24 * daysInMonth) / e.config.Freq5USD.Hours() - exec50PerMonth := (24 * daysInMonth) / e.config.Freq50USD.Hours() - exec300PerMonth := (24 * daysInMonth) / e.config.Freq300USD.Hours() + exec3PerMonth := (24 * daysInMonth) / e.config.Freq3USD.Hours() + exec30PerMonth := (24 * daysInMonth) / e.config.Freq30USD.Hours() - cost5 := exec5PerMonth * costPer5 - cost50 := exec50PerMonth * costPer50 - cost300 := exec300PerMonth * costPer300 + cost3 := exec3PerMonth * costPer3 + cost30 := exec30PerMonth * costPer30 - totalMonthly := cost5 + cost50 + cost300 + totalMonthly := cost3 + cost30 - log.Printf(" $5 tests: %.0f/month × $%.2f = $%.2f", exec5PerMonth, costPer5, cost5) - log.Printf(" $50 tests: %.0f/month × $%.2f = $%.2f", exec50PerMonth, costPer50, cost50) - log.Printf(" $300 tests: %.0f/month × $%.2f = $%.2f", exec300PerMonth, costPer300, cost300) + log.Printf(" $3 tests: %.0f/month × $%.2f = $%.2f", exec3PerMonth, costPer3, cost3) + log.Printf(" $30 tests: %.0f/month × $%.2f = $%.2f", exec30PerMonth, costPer30, cost30) log.Printf(" ─────────────────────────────────") log.Printf(" TOTAL: $%.2f/month", totalMonthly) @@ -1173,14 +1186,13 @@ func (e *Executor) EstimateMonthlyCost() { // PrintExecutionPlan shows what will be executed func (e *Executor) PrintExecutionPlan() { plan := map[string]interface{}{ - "mode": e.config.Mode, - "freq_5_usd": e.config.Freq5USD.String(), - "freq_50_usd": e.config.Freq50USD.String(), - "freq_300_usd": e.config.Freq300USD.String(), - "enable_debridge": e.config.EnableDebridge, - "max_daily_spend": e.config.MaxDailySpendUSD, - "evm_address": e.walletManager.EVMAddress, - "solana_address": e.walletManager.SolanaAddress, + "mode": e.config.Mode, + "freq_3_usd": e.config.Freq3USD.String(), + "freq_30_usd": e.config.Freq30USD.String(), + "enable_debridge": e.config.EnableDebridge, + "max_daily_spend": e.config.MaxDailySpendUSD, + "evm_address": e.walletManager.EVMAddress, + "solana_address": e.walletManager.SolanaAddress, } planJSON, _ := json.MarshalIndent(plan, "", " ") diff --git a/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw.go b/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw.go new file mode 100644 index 000000000..cdc35d5f6 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw.go @@ -0,0 +1,274 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "math/big" + "net/http" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +// Hyperliquid native withdrawal (HyperCore -> Arbitrum). +// +// R5's deposit leg (Arb USDC -> HyperCore) is a normal provider bridge, so it +// is benchmarked per bridge like every other route. But HyperCore has no +// provider bridge OUT: withdrawing back to Arbitrum is an HL-native "withdraw3" +// signed action, not something Mobula/Relay/LiFi quote. Without this file R5 +// would be a one-way capital drain; with it the loop conserves Arb USDC. +// +// withdraw3 is a user-signed action (EIP-712), NOT an L1 agent action, so it +// signs with the wallet's own EVM key against the fixed HyperliquidSignTransaction +// domain. Funds land on Arbitrum to the same address after HL validator +// processing (a few minutes), minus HL's flat withdrawal fee (~$1). Minimum +// withdrawal is $2, so the $3/$30 tiers both clear it (the $1 flat fee on a $3 +// round-trip is steep, but it is the true cost of the HyperCore return path and +// is what the benchmark is meant to expose). + +const ( + hyperliquidAPIBase = "https://api.hyperliquid.xyz" + hyperliquidChainName = "Mainnet" + // Arbitrum chain id; withdraw3 must sign and declare the same value. + hyperliquidSignatureChainID = "0xa4b1" // 42161 + hyperliquidSignatureChainIDInt = 42161 + // HL rejects withdrawals below this notional. + hyperliquidMinWithdrawUSD = 2.0 +) + +// HyperliquidClient performs read (info) and write (exchange) calls against the +// Hyperliquid API using the bridge-monitor's EVM key for signing. +type HyperliquidClient struct { + http *http.Client + tx *TxExecutor // provides the EVM private key + address +} + +// NewHyperliquidClient returns nil when no EVM key is available (quote-only +// mode): callers must treat nil as "HL withdraw unavailable" and skip R5 exec. +func NewHyperliquidClient(tx *TxExecutor) *HyperliquidClient { + if tx == nil || tx.EVMPrivateKey() == nil { + return nil + } + return &HyperliquidClient{ + http: &http.Client{Timeout: 30 * time.Second}, + tx: tx, + } +} + +// clearinghouseState is the subset of HL's perp account state we need: the USDC +// amount that is free to withdraw right now. +type clearinghouseState struct { + Withdrawable string `json:"withdrawable"` +} + +// Withdrawable returns the USDC (in USD) currently withdrawable from the perp +// account of `address` on HyperCore. Used to confirm an R5 deposit filled and +// to size the return withdrawal. +func (c *HyperliquidClient) Withdrawable(address string) (float64, error) { + body := map[string]string{"type": "clearinghouseState", "user": strings.ToLower(address)} + raw, err := c.postJSON(hyperliquidAPIBase+"/info", body) + if err != nil { + return 0, err + } + var st clearinghouseState + if err := json.Unmarshal(raw, &st); err != nil { + return 0, fmt.Errorf("parse clearinghouseState: %w (body: %s)", err, truncateHL(string(raw), 200)) + } + if st.Withdrawable == "" { + return 0, nil + } + v, err := strconv.ParseFloat(st.Withdrawable, 64) + if err != nil { + return 0, fmt.Errorf("parse withdrawable %q: %w", st.Withdrawable, err) + } + return v, nil +} + +// withdraw3Action is the exact JSON object that gets both signed and sent. +// Field order matters for HL's action hashing on some actions; withdraw3 is +// EIP-712 so order in the struct is not hashed, but we keep it stable anyway. +type withdraw3Action struct { + Type string `json:"type"` + HyperliquidChain string `json:"hyperliquidChain"` + SignatureChainID string `json:"signatureChainId"` + Amount string `json:"amount"` + Time uint64 `json:"time"` + Destination string `json:"destination"` +} + +type hlSignature struct { + R string `json:"r"` + S string `json:"s"` + V uint64 `json:"v"` +} + +type exchangeRequest struct { + Action withdraw3Action `json:"action"` + Nonce uint64 `json:"nonce"` + Signature hlSignature `json:"signature"` +} + +type exchangeResponse struct { + Status string `json:"status"` + Response json.RawMessage `json:"response"` +} + +// Withdraw signs and submits a withdraw3 action moving `amountUSD` of USDC from +// the HyperCore perp account back to `destination` on Arbitrum. Returns nil on +// HL "status":"ok". It never moves more than what is withdrawable and refuses +// amounts below HL's minimum. +func (c *HyperliquidClient) Withdraw(amountUSD float64, destination string) error { + if amountUSD < hyperliquidMinWithdrawUSD { + return fmt.Errorf("withdraw amount $%.2f below HL minimum $%.2f", amountUSD, hyperliquidMinWithdrawUSD) + } + dest := strings.ToLower(strings.TrimSpace(destination)) + if !strings.HasPrefix(dest, "0x") || len(dest) != 42 { + return fmt.Errorf("invalid destination address %q", destination) + } + + nonce := uint64(time.Now().UnixMilli()) + // HL expects the amount as a plain decimal string. Trim trailing zeros so + // "3" stays "3" and "2.5" stays "2.5". + amountStr := strconv.FormatFloat(amountUSD, 'f', -1, 64) + + action := withdraw3Action{ + Type: "withdraw3", + HyperliquidChain: hyperliquidChainName, + SignatureChainID: hyperliquidSignatureChainID, + Amount: amountStr, + Time: nonce, + Destination: dest, + } + + sig, err := c.signWithdraw(action) + if err != nil { + return fmt.Errorf("sign withdraw3: %w", err) + } + + reqBody := exchangeRequest{Action: action, Nonce: nonce, Signature: sig} + raw, err := c.postJSON(hyperliquidAPIBase+"/exchange", reqBody) + if err != nil { + return err + } + + var resp exchangeResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return fmt.Errorf("parse exchange response: %w (body: %s)", err, truncateHL(string(raw), 300)) + } + if !strings.EqualFold(resp.Status, "ok") { + return fmt.Errorf("HL withdraw rejected: %s", truncateHL(string(raw), 300)) + } + log.Printf("🏦 HL withdraw3 accepted: $%s USDC -> %s (nonce %d)", amountStr, dest, nonce) + return nil +} + +// signWithdraw builds the EIP-712 digest for the withdraw3 action and signs it +// with the wallet's EVM key. The domain and type list are fixed by HL and MUST +// match exactly or the signer recovers to the wrong address and HL rejects it. +func (c *HyperliquidClient) signWithdraw(a withdraw3Action) (hlSignature, error) { + digest, err := withdraw3Digest(a) + if err != nil { + return hlSignature{}, fmt.Errorf("typed-data hash: %w", err) + } + + sig, err := crypto.Sign(digest, c.tx.EVMPrivateKey()) + if err != nil { + return hlSignature{}, fmt.Errorf("crypto.Sign: %w", err) + } + // crypto.Sign yields [R || S || V] with V in {0,1}; HL wants {27,28}. + v := uint64(sig[64]) + 27 + return hlSignature{ + R: "0x" + toHex32(sig[0:32]), + S: "0x" + toHex32(sig[32:64]), + V: v, + }, nil +} + +// withdraw3Digest builds the EIP-712 signing digest for a withdraw3 action. +// The domain and type list are fixed by Hyperliquid and MUST match exactly or +// the recovered signer address is wrong and HL rejects the withdrawal. +func withdraw3Digest(a withdraw3Action) ([]byte, error) { + typedData := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": []apitypes.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + "HyperliquidTransaction:Withdraw": []apitypes.Type{ + {Name: "hyperliquidChain", Type: "string"}, + {Name: "destination", Type: "string"}, + {Name: "amount", Type: "string"}, + {Name: "time", Type: "uint64"}, + }, + }, + PrimaryType: "HyperliquidTransaction:Withdraw", + Domain: apitypes.TypedDataDomain{ + Name: "HyperliquidSignTransaction", + Version: "1", + ChainId: math.NewHexOrDecimal256(hyperliquidSignatureChainIDInt), + VerifyingContract: "0x0000000000000000000000000000000000000000", + }, + Message: apitypes.TypedDataMessage{ + "hyperliquidChain": a.HyperliquidChain, + "destination": a.Destination, + "amount": a.Amount, + "time": new(big.Int).SetUint64(a.Time), + }, + } + digest, _, err := apitypes.TypedDataAndHash(typedData) + return digest, err +} + +// postJSON marshals body, POSTs it, and returns the response bytes. Non-2xx +// responses still return their body (HL puts error detail in a 200 or 4xx JSON) +// alongside an error. +func (c *HyperliquidClient) postJSON(url string, body any) ([]byte, error) { + buf, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return raw, fmt.Errorf("HL %s returned %d: %s", url, resp.StatusCode, truncateHL(string(raw), 300)) + } + return raw, nil +} + +func toHex32(b []byte) string { + const hexdigits = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, v := range b { + out[i*2] = hexdigits[v>>4] + out[i*2+1] = hexdigits[v&0x0f] + } + return string(out) +} + +func truncateHL(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw_test.go b/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw_test.go new file mode 100644 index 000000000..0db7cc8a9 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/hyperliquid_withdraw_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// TestSignWithdrawRecoversSigner proves the withdraw3 EIP-712 signature is +// internally consistent: signing with the wallet key and recovering from the +// same digest yields the wallet's own address. This validates the domain, type +// list, digest, and the v=27/28 normalization without needing the live HL API. +func TestSignWithdrawRecoversSigner(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + want := crypto.PubkeyToAddress(key.PublicKey) + + c := NewHyperliquidClient(&TxExecutor{evmPrivateKey: key}) + if c == nil { + t.Fatal("NewHyperliquidClient returned nil with a key present") + } + + action := withdraw3Action{ + Type: "withdraw3", + HyperliquidChain: hyperliquidChainName, + SignatureChainID: hyperliquidSignatureChainID, + Amount: "30", + Time: 1725000000000, + Destination: strings.ToLower(want.Hex()), + } + + sig, err := c.signWithdraw(action) + if err != nil { + t.Fatalf("signWithdraw: %v", err) + } + if sig.V != 27 && sig.V != 28 { + t.Fatalf("v not normalized to 27/28: got %d", sig.V) + } + + digest, err := withdraw3Digest(action) + if err != nil { + t.Fatalf("digest: %v", err) + } + + raw := make([]byte, 65) + copy(raw[0:32], common.FromHex(sig.R)) + copy(raw[32:64], common.FromHex(sig.S)) + raw[64] = byte(sig.V - 27) + + pub, err := crypto.SigToPub(digest, raw) + if err != nil { + t.Fatalf("SigToPub: %v", err) + } + got := crypto.PubkeyToAddress(*pub) + if got != want { + t.Fatalf("recovered %s, want %s", got, want) + } +} + +// TestWithdrawRejectsBelowMinimum guards the HL minimum-withdrawal check. +func TestWithdrawRejectsBelowMinimum(t *testing.T) { + key, _ := crypto.GenerateKey() + c := NewHyperliquidClient(&TxExecutor{evmPrivateKey: key}) + err := c.Withdraw(1.0, crypto.PubkeyToAddress(key.PublicKey).Hex()) + if err == nil { + t.Fatal("expected rejection for $1 withdrawal below HL minimum") + } +} + +// TestReverseRoute swaps origin and destination and flags the Solana source +// correctly, which the R4 return leg (BRETT->TRUMP) depends on. +func TestReverseRoute(t *testing.T) { + r4, ok := R4Route() + if !ok { + t.Fatal("R4 route not found") + } + rev := ReverseRoute(r4) + if rev.FromChain != r4.ToChain || rev.ToChain != r4.FromChain { + t.Fatalf("chains not swapped: %s->%s", rev.FromChain, rev.ToChain) + } + if rev.FromToken != r4.ToToken || rev.ToToken != r4.FromToken { + t.Fatalf("tokens not swapped") + } + // R4 forward is Solana->Base, so the return is Base->Solana: not Solana-sourced. + if rev.IsSolanaSrc { + t.Fatalf("return leg should not be Solana-sourced") + } + if getSourceTokenName(rev) != "BRETT" { + t.Fatalf("return source token should be BRETT, got %s", getSourceTokenName(rev)) + } +} diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index 67d90d995..bd13466cb 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "math" + "math/big" "net/http" "os" "runtime" @@ -123,9 +124,8 @@ func main() { if config.ExecutionMode != "" { execConfig := &ExecutionConfig{ Mode: ExecutionMode(config.ExecutionMode), - Freq5USD: config.Freq5USD, - Freq50USD: config.Freq50USD, - Freq300USD: config.Freq300USD, + Freq3USD: config.Freq3USD, + Freq30USD: config.Freq30USD, EnableDebridge: config.EnableDebridge, MaxDailySpendUSD: config.MaxDailySpendUSD, } @@ -152,6 +152,14 @@ func main() { } } + // Hyperliquid client for the R5 return leg (HyperCore -> Arbitrum native + // withdraw). Nil when no EVM key is loaded (quote-only mode): runR5RoundTrip + // treats nil as "R5 execution unavailable" and skips. + var hlClient *HyperliquidClient + if executor != nil { + hlClient = NewHyperliquidClient(executor.txExecutor) + } + // Pre-seed the self-healing series so alerting rules match from startup. initSelfHealingMetrics() @@ -368,49 +376,150 @@ func main() { // corrective transfers only in production and unpaused. StartReaper(balanceChecker, rebalancer, slackNotifier, config.ExecutionMode, paused) - // Track last meme execution day to run weekly - lastMemeDay := -1 - // Main loop — scheduler-only (quote loop now lives in its own goroutine). + // Two tiers: $3 daily, $30 Mon+Thu. Each tick equalizes the stable legs, + // runs the conserving triangle, then the opt-in R4/R5 round-trips. for { select { - case <-getSchedulerChan(scheduler, "$5"): - // $5 execution loop - daily at 10:00 UTC - if executor != nil && config.ExecutionMode == "production" { - 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. - 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() - } - } + case <-getSchedulerChan(scheduler, "$3"): + runLoopTier(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, hlClient, config, 3.0, "daily") + case <-getSchedulerChan(scheduler, "$30"): + runLoopTier(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, hlClient, config, 30.0, "Mon+Thu") + } + } +} + +// runLoopTier runs one scheduled tier end to end: the conserving stable +// triangle (R1/R2/R3, which also proactively equalizes its legs), then the +// opt-in R4 (TRUMP<->BRETT) and R5 (Arb<->HyperCore) round-trips. No-ops +// outside production mode. +func runLoopTier(executor *Executor, bc *BalanceChecker, slack *SlackNotifier, + rebalancer *Rebalancer, gasTopper *GasTopper, hl *HyperliquidClient, config *Config, + tier float64, label string, +) { + if executor == nil || config.ExecutionMode != "production" { + return + } + runTierIfViable(executor, bc, slack, rebalancer, gasTopper, GetTriangleRoutes(), tier, label) + if config.EnableR4RoundTrip { + runR4RoundTrip(executor, slack, tier) + } + if config.EnableR5Hypercore { + runR5RoundTrip(executor, bc, slack, hl, tier) + } +} - case <-getSchedulerChan(scheduler, "$50"): - if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 50.0, "Mon+Thu") +// runR4RoundTrip executes R4 (TRUMP->BRETT) and immediately its return leg +// (BRETT->TRUMP) per bridge, so the meme pool is restored each cycle. Memes +// carry real slippage, so the standing TRUMP/BRETT pools absorb the drift and +// are reseeded periodically (this is not fee-neutral by design). +func runR4RoundTrip(executor *Executor, slack *SlackNotifier, tier float64) { + r4, ok := R4Route() + if !ok { + return + } + ret := ReverseRoute(r4) + execMu.Lock() + defer execMu.Unlock() + log.Printf("🎭 R4 round-trip $%.0f (TRUMP<->BRETT)...", tier) + for _, bridge := range []string{"mobula", "relay", "lifi"} { + dep := executor.RunBridgeOnRoute(bridge, r4, tier) + time.Sleep(2 * time.Second) + if dep == nil || !dep.Success { + log.Printf(" ⚠️ R4 deposit via %s failed — skipping its return leg (TRUMP not spent)", bridge) + continue + } + retRes := executor.RunBridgeOnRoute(bridge, ret, tier) + time.Sleep(2 * time.Second) + if retRes == nil || !retRes.Success { + log.Printf(" ⚠️ R4 return via %s failed — TRUMP pool short until reseed", bridge) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, "R4-return-"+bridge, "BRETT->TRUMP return leg failed; TRUMP pool will need a reseed") } + } + } +} - case <-getSchedulerChan(scheduler, "$300"): - if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 300.0, "Mon weekly") +// runR5RoundTrip executes R5 (Arb USDC -> HyperCore) per bridge, then withdraws +// the credited amount back to Arbitrum via the HL-native withdraw3 action so +// the loop conserves Arb USDC. Skips the withdraw (leaving funds for the reaper +// / alert) whenever the HL credit is not observed, to avoid over-withdrawing. +func runR5RoundTrip(executor *Executor, bc *BalanceChecker, slack *SlackNotifier, hl *HyperliquidClient, tier float64) { + r5, ok := R5Route() + if !ok { + return + } + if hl == nil { + log.Println("⚠️ R5 skipped: HyperliquidClient unavailable (no EVM key)") + return + } + evm := executor.walletManager.EVMAddress + execMu.Lock() + defer execMu.Unlock() + log.Printf("🏦 R5 round-trip $%.0f (Arb USDC <-> HyperCore)...", tier) + for _, bridge := range []string{"mobula", "relay", "lifi"} { + before, err := hl.Withdrawable(evm) + if err != nil { + log.Printf(" ⚠️ R5 %s: cannot read HL withdrawable pre-deposit: %v — skipping", bridge, err) + continue + } + dep := executor.RunBridgeOnRoute(bridge, r5, tier) + time.Sleep(2 * time.Second) + if dep == nil || !dep.Success { + log.Printf(" ⚠️ R5 deposit via %s failed — skipping withdraw", bridge) + continue + } + delta := waitForHLCredit(hl, evm, before, tier, 5*time.Minute) + if delta < hyperliquidMinWithdrawUSD { + log.Printf(" ⚠️ R5 %s: HL credit not observed (delta $%.2f) within timeout — leaving funds on HL for reaper/alert", bridge, delta) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, "R5-withdraw-"+bridge, "HL credit not observed; withdraw skipped to avoid over-withdraw") + } + continue + } + // Never withdraw more than the round-trip notional plus a small margin. + amt := delta + if amt > tier*rebalanceBufferFactor { + amt = tier * rebalanceBufferFactor + } + if err := hl.Withdraw(amt, evm); err != nil { + log.Printf(" ⚠️ R5 %s withdraw failed: %v", bridge, err) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, "R5-withdraw-"+bridge, "HL withdraw failed: "+err.Error()) } } + time.Sleep(2 * time.Second) } } +// waitForHLCredit polls HL withdrawable until it rises by at least the deposit +// notional (minus a tolerance for HL fees/rounding) or the timeout elapses. +// Returns the observed increase in USD (0 if nothing credited). +func waitForHLCredit(hl *HyperliquidClient, addr string, before, tier float64, timeout time.Duration) float64 { + deadline := time.Now().Add(timeout) + target := tier * 0.9 // accept a 10% haircut for HL fees / rounding + for time.Now().Before(deadline) { + time.Sleep(15 * time.Second) + now, err := hl.Withdrawable(addr) + if err != nil { + continue + } + if now-before >= target { + return now - before + } + } + last, err := hl.Withdrawable(addr) + if err != nil { + return 0 + } + return last - before +} + // 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. +// the scheduled tier. Partial data beats none: if $30 cannot run we still +// want the $3 datapoint from the same slot. func downgradeLadder(tier float64) []float64 { - all := []float64{300, 50, 5} + all := []float64{30, 3} var out []float64 for _, t := range all { if t <= tier { @@ -451,6 +560,16 @@ func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifie // counter and a single slot could broadcast up to six transfers. budget := newSlotBudget() + // Proactive equalization: top every stable home leg up to the largest tier + // plus buffer BEFORE checking viability, so the loop self-heals even when + // nothing is blocked yet. Shares the same slot budget as the reactive + // per-rung rebalancing below, so the two together never exceed the cap. + if rebalancer != nil { + if balances, degraded, err := bc.GetAllBalancesDetailed(); err == nil && !degraded { + rebalancer.EqualizeStableLegs(balances, budget) + } + } + for i, amount := range ladder { balances, degraded, err := bc.GetAllBalancesDetailed() if err != nil || degraded { @@ -548,12 +667,10 @@ func getSchedulerChan(s *Scheduler, amount string) <-chan struct{} { return nil } switch amount { - case "$5": - return s.Exec5Chan() - case "$50": - return s.Exec50Chan() - case "$300": - return s.Exec300Chan() + case "$3": + return s.Exec3Chan() + case "$30": + return s.Exec30Chan() } return nil } @@ -566,6 +683,15 @@ func toRawUnits(amount float64) string { return strconv.FormatInt(int64(math.Round(raw)), 10) } +// toRawUnitsDec is toRawUnits for a token whose decimals differ from the 6-dec +// stable default (e.g. BRETT at 18). Uses big.Float so 18-decimal magnitudes +// don't overflow int64 before rounding. +func toRawUnitsDec(amount float64, decimals int) string { + scaled := new(big.Float).Mul(big.NewFloat(amount), big.NewFloat(math.Pow10(decimals))) + i, _ := scaled.Int(nil) + return i.String() +} + // runQuoteTests runs quote-only tests (FREE, no execution) func runQuoteTests( mobulaBridge *MobulaBridge, diff --git a/harnesses/bridge-monitor/cmd/monitor/metrics.go b/harnesses/bridge-monitor/cmd/monitor/metrics.go index 5f2fa0f30..94705b692 100644 --- a/harnesses/bridge-monitor/cmd/monitor/metrics.go +++ b/harnesses/bridge-monitor/cmd/monitor/metrics.go @@ -178,12 +178,10 @@ 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", "in_flight"} { + for _, outcome := range []string{"attempted", "succeeded", "failed", "capped", "in_flight", "equalize"} { bridgeRebalanceAttempts.WithLabelValues(outcome).Add(0) } - bridgeTierDowngraded.WithLabelValues("300", "50").Set(0) - bridgeTierDowngraded.WithLabelValues("300", "5").Set(0) - bridgeTierDowngraded.WithLabelValues("50", "5").Set(0) + bridgeTierDowngraded.WithLabelValues("30", "3").Set(0) bridgeStrandedHours.WithLabelValues("Solana", "USDC").Set(0) bridgeStrandedHours.WithLabelValues("Base", "USDC").Set(0) bridgeStrandedHours.WithLabelValues("Arbitrum", "USDT0").Set(0) diff --git a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go index 706c294ae..76c2fd468 100644 --- a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go @@ -71,10 +71,10 @@ type MobulaQuoteResponse struct { type MobulaStatusResponse struct { Data struct { - Status string `json:"status"` // "pending", "filled", "refunded", "failed" - LatencyMs int64 `json:"latencyMs"` - FromTxHash string `json:"fromTxHash"` - ToTxHash string `json:"toTxHash"` + Status string `json:"status"` // "pending", "filled", "refunded", "failed" + LatencyMs int64 `json:"latencyMs"` + FromTxHash string `json:"fromTxHash"` + ToTxHash string `json:"toTxHash"` } `json:"data"` } @@ -320,8 +320,8 @@ func (m *MobulaBridge) VerifyRoutes() (*MobulaRoutesResponse, error) { type TestRoute struct { Name string - FromChain string // Human-readable label (e.g. "Solana") - FromChainAPI string // API-specific id (e.g. "solana:solana") + FromChain string // Human-readable label (e.g. "Solana") + FromChainAPI string // API-specific id (e.g. "solana:solana") FromToken string ToChain string ToChainAPI string @@ -346,87 +346,87 @@ func GetTestRoutes() []TestRoute { // amounts always reflect the real token value — fixes the bug where a stale // hardcoded $2.87 made our $300-labelled quote actually send $266 worth. trumpPrice := TokenPriceUSD("TRUMP", 2.55) // current price ~$2.55, fallback if API unreachable - trump5 := 5.0 / trumpPrice - trump50 := 50.0 / trumpPrice - trump300 := 300.0 / trumpPrice + trump3 := 3.0 / trumpPrice + trump30 := 30.0 / trumpPrice return []TestRoute{ // R1: Solana USDC → Base USDC { - Name: "USDC_SOL_BASE", - FromChain: "Solana", FromChainAPI: "solana:solana", - FromToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - ToChain: "Base", ToChainAPI: "evm:8453", - ToToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - Amounts: []float64{5, 50, 300}, - UsdAmounts: []float64{5, 50, 300}, - IsSolanaSrc: true, - WeeklyOnly: false, + Name: "USDC_SOL_BASE", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ToChain: "Base", ToChainAPI: "evm:8453", + ToToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Amounts: []float64{3, 30}, + UsdAmounts: []float64{3, 30}, + IsSolanaSrc: true, + WeeklyOnly: false, }, // R2: Base USDC → Arbitrum USDT { - Name: "USDC_BASE_USDT_ARB", - FromChain: "Base", FromChainAPI: "evm:8453", - FromToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - ToChain: "Arbitrum", ToChainAPI: "evm:42161", - ToToken: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", - Amounts: []float64{5, 50, 300}, - UsdAmounts: []float64{5, 50, 300}, - IsSolanaSrc: false, - WeeklyOnly: false, + Name: "USDC_BASE_USDT_ARB", + FromChain: "Base", FromChainAPI: "evm:8453", + FromToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + ToChain: "Arbitrum", ToChainAPI: "evm:42161", + ToToken: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + Amounts: []float64{3, 30}, + UsdAmounts: []float64{3, 30}, + IsSolanaSrc: false, + WeeklyOnly: false, }, // R3: Arbitrum USDT → Solana USDC (completes the triangle) { - Name: "USDT_ARB_USDC_SOL", - FromChain: "Arbitrum", FromChainAPI: "evm:42161", - FromToken: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", - ToChain: "Solana", ToChainAPI: "solana:solana", - ToToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - Amounts: []float64{5, 50, 300}, - UsdAmounts: []float64{5, 50, 300}, - IsSolanaSrc: false, - WeeklyOnly: false, + Name: "USDT_ARB_USDC_SOL", + FromChain: "Arbitrum", FromChainAPI: "evm:42161", + FromToken: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + ToChain: "Solana", ToChainAPI: "solana:solana", + ToToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + Amounts: []float64{3, 30}, + UsdAmounts: []float64{3, 30}, + IsSolanaSrc: false, + WeeklyOnly: false, }, - // R4: TRUMP (Solana) → BRETT (Base) - one-way. - // Amounts computed at startup from live TRUMP price (refreshed every 5min via - // TokenPriceUSD cache). Execution stays weekly at $5 only. + // R4: TRUMP (Solana) → BRETT (Base). Part of the round-trip loop when + // ENABLE_R4_ROUNDTRIP_EXEC=true: the return leg BRETT→TRUMP is built at + // runtime via ReverseRoute so meme inventory is restored each cycle. + // Amounts computed at startup from the live TRUMP price (5min cache). + // WeeklyOnly keeps it out of the always-on stable triangle. { - Name: "TRUMP_SOL_BRETT_BASE", - FromChain: "Solana", FromChainAPI: "solana:solana", - FromToken: "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", - ToChain: "Base", ToChainAPI: "evm:8453", - ToToken: "0x532f27101965dd16442E59d40670FaF5eBB142E4", - Amounts: []float64{trump5, trump50, trump300}, - UsdAmounts: []float64{5, 50, 300}, - IsSolanaSrc: true, - WeeklyOnly: true, + Name: "TRUMP_SOL_BRETT_BASE", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + ToChain: "Base", ToChainAPI: "evm:8453", + ToToken: "0x532f27101965dd16442E59d40670FaF5eBB142E4", + Amounts: []float64{trump3, trump30}, + UsdAmounts: []float64{3, 30}, + IsSolanaSrc: true, + WeeklyOnly: true, }, // R5: Arbitrum USDC → HyperCore USDC (Hyperliquid perp account). - // One-way deposit benchmark: HL is asymmetric (perp account credit, - // withdraw goes through HL's L1 signed action, not handled here). - // Phase 1 = quote-only — flagged QuoteOnly:true so GetTriangleRoutes() - // excludes it from the $5/$50/$300 execution scheduler. Quote loop - // still runs every 5min, so the dashboard gets latency/fees/cost data - // for Mobula vs Relay vs LiFi on HC. Flip to false in Phase 2 once HL - // balance reader + manual capital seed are in place. + // The deposit leg is a normal provider bridge (benchmarked per bridge). + // The return leg HyperCore → Arbitrum is HL-native (withdraw3 signed + // action, see hyperliquid_withdraw.go), executed once after the deposit + // so the loop conserves Arb USDC. QuoteOnly:true keeps it out of the + // always-on stable triangle and the 5min quote loop keeps measuring it; + // real execution is gated behind ENABLE_R5_HYPERCORE_EXEC. { - Name: "USDC_ARB_HYPERCORE", - FromChain: "Arbitrum", FromChainAPI: "evm:42161", - FromToken: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", - ToChain: "HyperCore", ToChainAPI: "hl:mainnet", - ToToken: "USDC", // Mobula uses symbol; per-bridge translators map to provider-specific addr - Amounts: []float64{5, 50, 300}, - UsdAmounts: []float64{5, 50, 300}, - IsSolanaSrc: false, - WeeklyOnly: false, - QuoteOnly: true, + Name: "USDC_ARB_HYPERCORE", + FromChain: "Arbitrum", FromChainAPI: "evm:42161", + FromToken: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + ToChain: "HyperCore", ToChainAPI: "hl:mainnet", + ToToken: "USDC", // Mobula uses symbol; per-bridge translators map to provider-specific addr + Amounts: []float64{3, 30}, + UsdAmounts: []float64{3, 30}, + IsSolanaSrc: false, + WeeklyOnly: false, + QuoteOnly: true, }, } } -// GetTriangleRoutes returns only the routes that should run in the scheduled -// $5 / $50 / $300 execution cycles — i.e. the USDC triangle (R1, R2, R3). -// Excludes WeeklyOnly (R4 meme) and QuoteOnly (R5 HyperCore — quote loop only). +// GetTriangleRoutes returns only the routes that run in every scheduled +// $3 / $30 cycle unconditionally — i.e. the self-conserving USDC triangle +// (R1, R2, R3). Excludes WeeklyOnly (R4 meme) and QuoteOnly (R5 HyperCore). func GetTriangleRoutes() []TestRoute { routes := GetTestRoutes() var triangle []TestRoute @@ -451,6 +451,37 @@ func GetMemeRoutes() []TestRoute { return meme } +// getRouteByName returns the named route from the canonical set. +func getRouteByName(name string) (TestRoute, bool) { + for _, r := range GetTestRoutes() { + if r.Name == name { + return r, true + } + } + return TestRoute{}, false +} + +// R4Route returns the TRUMP→BRETT deposit route (R4). +func R4Route() (TestRoute, bool) { return getRouteByName("TRUMP_SOL_BRETT_BASE") } + +// R5Route returns the Arb USDC→HyperCore deposit route (R5). +func R5Route() (TestRoute, bool) { return getRouteByName("USDC_ARB_HYPERCORE") } + +// ReverseRoute builds the return leg of a route by swapping origin and +// destination. Used to close R4 (BRETT→TRUMP) so the meme pool is restored +// each cycle; the amount arrays are carried over (they are USD-notional for +// UsdAmounts and recomputed per tier by the caller). Not used for R5, whose +// return leg is the HL-native withdraw rather than a provider bridge. +func ReverseRoute(r TestRoute) TestRoute { + rev := r + rev.Name = r.Name + "_RETURN" + rev.FromChain, rev.ToChain = r.ToChain, r.FromChain + rev.FromChainAPI, rev.ToChainAPI = r.ToChainAPI, r.FromChainAPI + rev.FromToken, rev.ToToken = r.ToToken, r.FromToken + rev.IsSolanaSrc = r.ToChain == "Solana" + return rev +} + // TestRoute runs a quote against a given route, using amount (token-native) + amountUsd (for labels). func (m *MobulaBridge) TestRoute(route TestRoute, amount, amountUsd float64, region, solAddress, evmAddress string) { amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) @@ -480,7 +511,6 @@ func (m *MobulaBridge) TestRoute(route TestRoute, amount, amountUsd float64, reg amount, ) - if err != nil { log.Printf("[MOBULA][%s][%.0f USD] ❌ Quote failed: %v", route.Name, amountUsd, err) bridgeErrors.WithLabelValues(append(labels, "quote_failed")...).Inc() diff --git a/harnesses/bridge-monitor/cmd/monitor/reaper.go b/harnesses/bridge-monitor/cmd/monitor/reaper.go index a43874511..509fe0291 100644 --- a/harnesses/bridge-monitor/cmd/monitor/reaper.go +++ b/harnesses/bridge-monitor/cmd/monitor/reaper.go @@ -22,24 +22,27 @@ 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 +const reaperMaxTransferUSD = 30 * 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. +// dust floor counts as stranded. TRUMP and BRETT are meme-route inventory +// (R4), native SOL and ETH are gas. Arb USDC is now a home pool: it is R5's +// source and the destination of the HL withdraw return leg, so the reaper must +// NOT sweep it (that would drain the R5 inventory each hour). 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}, + "Arbitrum": {"USDT0": true, "USDT": true, "USDC": 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. +// Arb USDC was here as an R3-refund leftover; it is now a home pool (see +// homeTokens) so it is intentionally no longer swept. var movableStranded = map[string]map[string]string{ - "Solana": {"USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, - "Base": {"USDT": "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"}, - "Arbitrum": {"USDC": arbUSDCAddr}, + "Solana": {"USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, + "Base": {"USDT": "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"}, } type strandKey struct { diff --git a/harnesses/bridge-monitor/cmd/monitor/rebalancer.go b/harnesses/bridge-monitor/cmd/monitor/rebalancer.go index 9ead71dc5..01e621d71 100644 --- a/harnesses/bridge-monitor/cmd/monitor/rebalancer.go +++ b/harnesses/bridge-monitor/cmd/monitor/rebalancer.go @@ -307,6 +307,64 @@ func (r *Rebalancer) TryUnblockTier(sim CycleSimulation, balances map[string]map } } +// EqualizeStableLegs proactively tops up every triangle home leg (Sol USDC, +// Base USDC, Arb USDT) to the largest tier plus buffer, moving from the leg +// holding the most surplus. It runs BEFORE any tier viability check so the loop +// self-heals continuously rather than only when a tier is blocked. Bounded by +// the shared slot budget and the daily spend cap; best-effort and never sources +// from a leg that would itself fall short. +func (r *Rebalancer) EqualizeStableLegs(balances map[string]map[string]float64, budget *slotBudget) { + if !r.canAct() || budget == nil { + return + } + const target = 30.0 * rebalanceBufferFactor + for _, leg := range triangleLegs { + if budget.remaining <= 0 { + return + } + if r.executor.DailySpent() >= r.executor.config.MaxDailySpendUSD { + return + } + have := legBalanceUSD(balances, leg) + if have >= target { + continue + } + sim := CycleSimulation{ + Tier: 30, + RefillChain: leg.Chain, + RefillToken: leg.Token, + RefillUSD: target - have, + } + route, amountUSD, err := BuildRefillRoute(sim, balances) + if err != nil { + // No leg holds enough surplus to cover this one right now; leave it + // for the reactive path / reaper. Too routine to alert every tick. + continue + } + if budget.blockedByInFlight(leg.Chain) { + continue + } + budget.remaining-- + bridgeRebalanceAttempts.WithLabelValues("equalize").Inc() + log.Printf("⚖️ Equalize: $%.2f %s -> %s %s (leg at $%.2f, target $%.2f)", + amountUSD, route.FromChain, sourceSymbol(route), route.ToChain, have, target) + result := r.ExecuteCheapest(route, amountUSD) + switch classifyCorrectiveResult(result) { + case outcomeSuccess: + // Reflect the move locally so the next leg sees the reduced surplus + // on the source and the increased balance on the destination. + dst := balances[route.ToChain] + if dst != nil { + dst[strings.ToLower(leg.TokenAddr)] = have + amountUSD + } + case outcomeInFlight: + budget.inFlight = true + budget.inFlightChain = leg.Chain + return + } + } +} + // 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. diff --git a/harnesses/bridge-monitor/cmd/monitor/scheduler.go b/harnesses/bridge-monitor/cmd/monitor/scheduler.go index ca7c6f3e4..1dc27512d 100644 --- a/harnesses/bridge-monitor/cmd/monitor/scheduler.go +++ b/harnesses/bridge-monitor/cmd/monitor/scheduler.go @@ -5,122 +5,107 @@ import ( "time" ) -// Schedule defines when execution tests run -// Uses fixed UTC times so redeploys don't affect the rhythm +// Schedule defines when execution tests run. +// Uses fixed UTC times so redeploys don't affect the rhythm. +// +// Two tiers only ($3 and $30 — the $300 tier was retired): the loop is now a +// small, self-balancing carousel whose point is settlement reliability, not +// large-ticket cost. $3 runs daily for a dense datapoint, $30 twice a week for +// a larger-notional read. type Schedule struct { - // $5 tests: daily at these hours UTC - Hours5USD []int // e.g., [10] = 10:00 UTC daily + // $3 tests: daily at these hours UTC + Hours3USD []int // e.g. [10] = 10:00 UTC daily - // $50 tests: specific weekdays at hour UTC + // $30 tests: specific weekdays at hour UTC // 0=Sunday, 1=Monday, ..., 6=Saturday - Weekdays50USD []time.Weekday - Hour50USD int - - // $300 tests: specific weekdays at hour UTC (Option C — weekly rhythm so - // stats converge in ~6 weeks instead of 6 months vs the monthly cadence). - Weekdays300USD []time.Weekday - Hour300USD int + Weekdays30USD []time.Weekday + Hour30USD int } -// DefaultSchedule returns the default execution schedule -// $5: daily at 10:00 UTC -// $50: Monday & Thursday at 10:00 UTC -// $300: Monday at 10:00 UTC (weekly) +// DefaultSchedule returns the default execution schedule. +// $3: daily at 10:00 UTC +// $30: Monday & Thursday at 10:00 UTC func DefaultSchedule() *Schedule { return &Schedule{ - Hours5USD: []int{10}, // 10:00 UTC daily - Weekdays50USD: []time.Weekday{time.Monday, time.Thursday}, - Hour50USD: 10, - Weekdays300USD: []time.Weekday{time.Monday}, - Hour300USD: 10, + Hours3USD: []int{10}, // 10:00 UTC daily + Weekdays30USD: []time.Weekday{time.Monday, time.Thursday}, + Hour30USD: 10, } } -// Scheduler handles fixed-time execution scheduling +// Scheduler handles fixed-time execution scheduling. type Scheduler struct { - schedule *Schedule - exec5Chan chan struct{} - exec50Chan chan struct{} - exec300Chan chan struct{} - stopChan chan struct{} + schedule *Schedule + exec3Chan chan struct{} + exec30Chan chan struct{} + stopChan chan struct{} } -// NewScheduler creates a new scheduler with fixed times +// NewScheduler creates a new scheduler with fixed times. func NewScheduler(schedule *Schedule) *Scheduler { if schedule == nil { schedule = DefaultSchedule() } return &Scheduler{ - schedule: schedule, - exec5Chan: make(chan struct{}, 1), - exec50Chan: make(chan struct{}, 1), - exec300Chan: make(chan struct{}, 1), - stopChan: make(chan struct{}), + schedule: schedule, + exec3Chan: make(chan struct{}, 1), + exec30Chan: make(chan struct{}, 1), + stopChan: make(chan struct{}), } } -// Start begins the scheduler goroutines +// Start begins the scheduler goroutines. func (s *Scheduler) Start() { log.Println("📅 Scheduler started with fixed UTC times:") - log.Printf(" $5 tests: daily at %v:00 UTC", s.schedule.Hours5USD) - log.Printf(" $50 tests: %v at %02d:00 UTC", s.schedule.Weekdays50USD, s.schedule.Hour50USD) - log.Printf(" $300 tests: %v at %02d:00 UTC", s.schedule.Weekdays300USD, s.schedule.Hour300USD) + log.Printf(" $3 tests: daily at %v:00 UTC", s.schedule.Hours3USD) + log.Printf(" $30 tests: %v at %02d:00 UTC", s.schedule.Weekdays30USD, s.schedule.Hour30USD) - // Log next scheduled times s.logNextScheduledTimes() - go s.run5USDScheduler() - go s.run50USDScheduler() - go s.run300USDScheduler() + go s.run3USDScheduler() + go s.run30USDScheduler() } -// logNextScheduledTimes logs when the next tests will run +// logNextScheduledTimes logs when the next tests will run. func (s *Scheduler) logNextScheduledTimes() { now := time.Now().UTC() - next5 := s.nextTime5USD(now) - next50 := s.nextTime50USD(now) - next300 := s.nextTime300USD(now) - - log.Printf(" Next $5 test: %s (in %v)", next5.Format("2006-01-02 15:04 UTC"), next5.Sub(now).Round(time.Minute)) - log.Printf(" Next $50 test: %s (in %v)", next50.Format("2006-01-02 15:04 UTC"), next50.Sub(now).Round(time.Minute)) - log.Printf(" Next $300 test: %s (in %v)", next300.Format("2006-01-02 15:04 UTC"), next300.Sub(now).Round(time.Minute)) -} + next3 := s.nextTime3USD(now) + next30 := s.nextTime30USD(now) -// Exec5Chan returns the channel that fires for $5 tests -func (s *Scheduler) Exec5Chan() <-chan struct{} { - return s.exec5Chan + log.Printf(" Next $3 test: %s (in %v)", next3.Format("2006-01-02 15:04 UTC"), next3.Sub(now).Round(time.Minute)) + log.Printf(" Next $30 test: %s (in %v)", next30.Format("2006-01-02 15:04 UTC"), next30.Sub(now).Round(time.Minute)) } -// Exec50Chan returns the channel that fires for $50 tests -func (s *Scheduler) Exec50Chan() <-chan struct{} { - return s.exec50Chan +// Exec3Chan returns the channel that fires for $3 tests. +func (s *Scheduler) Exec3Chan() <-chan struct{} { + return s.exec3Chan } -// Exec300Chan returns the channel that fires for $300 tests -func (s *Scheduler) Exec300Chan() <-chan struct{} { - return s.exec300Chan +// Exec30Chan returns the channel that fires for $30 tests. +func (s *Scheduler) Exec30Chan() <-chan struct{} { + return s.exec30Chan } -// Stop stops the scheduler +// Stop stops the scheduler. func (s *Scheduler) Stop() { close(s.stopChan) } -// run5USDScheduler runs $5 tests at scheduled hours daily -func (s *Scheduler) run5USDScheduler() { +// run3USDScheduler runs $3 tests at scheduled hours daily. +func (s *Scheduler) run3USDScheduler() { for { now := time.Now().UTC() - next := s.nextTime5USD(now) + next := s.nextTime3USD(now) waitDuration := next.Sub(now) - log.Printf("⏰ $5 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) + log.Printf("⏰ $3 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) select { case <-time.After(waitDuration): select { - case s.exec5Chan <- struct{}{}: - log.Println("🔔 $5 execution triggered") + case s.exec3Chan <- struct{}{}: + log.Println("🔔 $3 execution triggered") default: // Channel full, skip } @@ -130,20 +115,20 @@ func (s *Scheduler) run5USDScheduler() { } } -// run50USDScheduler runs $50 tests on specific weekdays -func (s *Scheduler) run50USDScheduler() { +// run30USDScheduler runs $30 tests on specific weekdays. +func (s *Scheduler) run30USDScheduler() { for { now := time.Now().UTC() - next := s.nextTime50USD(now) + next := s.nextTime30USD(now) waitDuration := next.Sub(now) - log.Printf("⏰ $50 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) + log.Printf("⏰ $30 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) select { case <-time.After(waitDuration): select { - case s.exec50Chan <- struct{}{}: - log.Println("🔔 $50 execution triggered") + case s.exec30Chan <- struct{}{}: + log.Println("🔔 $30 execution triggered") default: } case <-s.stopChan: @@ -152,75 +137,28 @@ func (s *Scheduler) run50USDScheduler() { } } -// run300USDScheduler runs $300 tests on specific weekdays -func (s *Scheduler) run300USDScheduler() { - for { - now := time.Now().UTC() - next := s.nextTime300USD(now) - waitDuration := next.Sub(now) - - log.Printf("⏰ $300 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) - - select { - case <-time.After(waitDuration): - select { - case s.exec300Chan <- struct{}{}: - log.Println("🔔 $300 execution triggered") - default: - } - case <-s.stopChan: - return - } - } -} - -// nextTime5USD calculates the next $5 test time -func (s *Scheduler) nextTime5USD(now time.Time) time.Time { - // Find the next scheduled hour today or tomorrow - for _, hour := range s.schedule.Hours5USD { +// nextTime3USD calculates the next $3 test time. +func (s *Scheduler) nextTime3USD(now time.Time) time.Time { + for _, hour := range s.schedule.Hours3USD { candidate := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC) if candidate.After(now) { return candidate } } - // All hours passed today, try first hour tomorrow tomorrow := now.AddDate(0, 0, 1) - return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), s.schedule.Hours5USD[0], 0, 0, 0, time.UTC) -} - -// nextTime50USD calculates the next $50 test time -func (s *Scheduler) nextTime50USD(now time.Time) time.Time { - // Start from today - candidate := time.Date(now.Year(), now.Month(), now.Day(), s.schedule.Hour50USD, 0, 0, 0, time.UTC) - - // If today's time has passed, start from tomorrow - if !candidate.After(now) { - candidate = candidate.AddDate(0, 0, 1) - } - - // Find next matching weekday - for i := 0; i < 7; i++ { - for _, wd := range s.schedule.Weekdays50USD { - if candidate.Weekday() == wd { - return candidate - } - } - candidate = candidate.AddDate(0, 0, 1) - } - - return candidate + return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), s.schedule.Hours3USD[0], 0, 0, 0, time.UTC) } -// nextTime300USD calculates the next $300 test time (weekly weekday-based) -func (s *Scheduler) nextTime300USD(now time.Time) time.Time { - candidate := time.Date(now.Year(), now.Month(), now.Day(), s.schedule.Hour300USD, 0, 0, 0, time.UTC) +// nextTime30USD calculates the next $30 test time. +func (s *Scheduler) nextTime30USD(now time.Time) time.Time { + candidate := time.Date(now.Year(), now.Month(), now.Day(), s.schedule.Hour30USD, 0, 0, 0, time.UTC) if !candidate.After(now) { candidate = candidate.AddDate(0, 0, 1) } for i := 0; i < 7; i++ { - for _, wd := range s.schedule.Weekdays300USD { + for _, wd := range s.schedule.Weekdays30USD { if candidate.Weekday() == wd { return candidate } diff --git a/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go index 7eb6d0136..4bd47b4c3 100644 --- a/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go +++ b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go @@ -103,9 +103,8 @@ func TestDowngradeLadder(t *testing.T) { tier float64 want []float64 }{ - {300, []float64{300, 50, 5}}, - {50, []float64{50, 5}}, - {5, []float64{5}}, + {30, []float64{30, 3}}, + {3, []float64{3}}, } for _, c := range cases { got := downgradeLadder(c.tier) @@ -153,9 +152,10 @@ func TestSlotBudgetSharedAcrossRungs(t *testing.T) { 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. + // Rung 1 ($30) consumes one attempt, rung 2 ($3) consumes the second: + // the SAME budget instance is threaded through the ladder plus the + // proactive equalizer, so nothing is left after two. Per-rung counters + // previously allowed far more transfers per slot. b.remaining-- b.remaining-- if b.remaining > 0 { @@ -211,12 +211,13 @@ 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). + // USDT on Solana is off-home (Solana expects USDC/SOL/TRUMP). Arb USDC is no + // longer a valid stranded probe now that it is R5's home pool. balances := balancesSnapshot(100, 100, 100) - balances["Arbitrum"]["USDC"] = 42 + balances["Solana"]["USDT"] = 42 hours := tracker.Update(balances, t0) - k := strandKey{"Arbitrum", "USDC"} + k := strandKey{"Solana", "USDT"} if hours[k] != 0 { t.Errorf("first sighting should report 0 hours, got %.2f", hours[k]) } @@ -236,13 +237,13 @@ func TestStrandedHoursComputation(t *testing.T) { // 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") + delete(balances["Solana"], "USDT") 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 + balances["Solana"]["USDT"] = 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]) @@ -254,11 +255,11 @@ func TestStrandedIgnoresDustAndAddressKeys(t *testing.T) { now := time.Now().UTC() balances := balancesSnapshot(100, 100, 100) - balances["Arbitrum"]["USDC"] = 0.5 + balances["Solana"]["USDT"] = 0.5 balances["Arbitrum"][arbUSDCAddr] = 5000 hours := tracker.Update(balances, now) - if _, ok := hours[strandKey{"Arbitrum", "USDC"}]; ok { + if _, ok := hours[strandKey{"Solana", "USDT"}]; ok { t.Error("dust below $1 must not count as stranded") } for k := range hours { diff --git a/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go b/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go index 986889295..f08cec9ee 100644 --- a/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go +++ b/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go @@ -218,9 +218,9 @@ func runDailyPnL(bc *BalanceChecker, slack *SlackNotifier) { // starts AFTER R1 has delivered ~3×amount × 99% to Base, and R3 starts AFTER R2 has // delivered ~3×amount × 99% to Arb. The per-leg requirement is therefore: // -// R1 source (Sol USDC): initial ≥ 3 × amount (no preceding inflow) -// R2 source (Base USDC): initial + R1_inflow ≥ 3×amount -// R3 source (Arb USDT): initial + R2_inflow ≥ 3×amount +// R1 source (Sol USDC): initial ≥ 3 × amount (no preceding inflow) +// R2 source (Base USDC): initial + R1_inflow ≥ 3×amount +// R3 source (Arb USDT): initial + R2_inflow ≥ 3×amount // // where inflow ≈ 3×amount × (1 - avg_fee). Using a conservative 2% cumulative loss. func formatTierHealth(balances map[string]map[string]float64) string { @@ -229,7 +229,7 @@ func formatTierHealth(balances map[string]map[string]float64) string { solUSDC := balances["Solana"]["USDC"] baseUSDC := balances["Base"]["USDC"] arbUSDT := balances["Arbitrum"]["USDT0"] - tiers := []float64{5, 50, 300} + tiers := []float64{3, 30} // Simulate the cycle for a given tier using the sequential per-bridge model // (1× tier per leg, matches cycle_sim.SimulateTriangleCycle). Shared logic From bc66f869aa0ca887c8cff0f17784ce9c00c7dabe Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:33:28 +0200 Subject: [PATCH 2/6] bridge-monitor: use api.mobula.io (was demo-api), configurable via MOBULA_API_BASE --- harnesses/bridge-monitor/cmd/monitor/balance.go | 4 ++-- harnesses/bridge-monitor/cmd/monitor/config.go | 10 ++++++++++ .../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 | 12 ++++++++---- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/harnesses/bridge-monitor/cmd/monitor/balance.go b/harnesses/bridge-monitor/cmd/monitor/balance.go index bb9f5e1dd..16de97af1 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://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s", address) + url := fmt.Sprintf(mobulaAPIBase()+"/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://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", address, chain) + url := fmt.Sprintf(mobulaAPIBase()+"/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/config.go b/harnesses/bridge-monitor/cmd/monitor/config.go index e0d279bb9..049e65d25 100644 --- a/harnesses/bridge-monitor/cmd/monitor/config.go +++ b/harnesses/bridge-monitor/cmd/monitor/config.go @@ -56,6 +56,16 @@ func parseDuration(s string, defaultVal time.Duration) time.Duration { return d } +// mobulaAPIBase returns the Mobula REST base URL (no trailing slash). Defaults +// to the production host; override with MOBULA_API_BASE. We moved off +// demo-api.mobula.io to the production api.mobula.io endpoint. +func mobulaAPIBase() string { + if v := strings.TrimSpace(os.Getenv("MOBULA_API_BASE")); v != "" { + return strings.TrimRight(v, "/") + } + return "https://api.mobula.io" +} + // parseFloat parses a float string, returns default if invalid func parseFloat(s string, defaultVal float64) float64 { if s == "" { diff --git a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go index 76c2fd468..cac4164a3 100644 --- a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go @@ -216,7 +216,7 @@ func (m *MobulaBridge) quote(originChain, originToken, destChain, destToken, sen start := time.Now() url := fmt.Sprintf( - "https://demo-api.mobula.io/api/2/bridge/quote?originChainId=%s&originToken=%s&destinationChainId=%s&destinationToken=%s&amount=%s&walletAddress=%s&apiKey=%s", + mobulaAPIBase()+"/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, @@ -261,7 +261,7 @@ func (m *MobulaBridge) quote(originChain, originToken, destChain, destToken, sen } func (m *MobulaBridge) GetStatus(txHash string) (*MobulaStatusResponse, error) { - url := fmt.Sprintf("https://demo-api.mobula.io/api/2/bridge/status/%s", txHash) + url := fmt.Sprintf(mobulaAPIBase()+"/api/2/bridge/status/%s", txHash) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -290,7 +290,7 @@ func (m *MobulaBridge) GetStatus(txHash string) (*MobulaStatusResponse, error) { } func (m *MobulaBridge) VerifyRoutes() (*MobulaRoutesResponse, error) { - url := "https://demo-api.mobula.io/api/2/bridge/routes" + url := mobulaAPIBase() + "/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 4a6779875..8bfe2ae3a 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://demo-api.mobula.io/api/1/market/data?symbol=%s", symbol) + url := fmt.Sprintf(mobulaAPIBase()+"/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 579f514b7..c5c6ae160 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://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", wallet, blockchain) + url := fmt.Sprintf(mobulaAPIBase()+"/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 a9d08380c..2dbf4b762 100644 --- a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go @@ -427,7 +427,7 @@ func (tx *TxExecutor) PollMobulaStatus(txHash string, timeout time.Duration) (*B } func (tx *TxExecutor) getMobulaStatus(txHash string) (*BridgeStatus, error) { - url := fmt.Sprintf("https://demo-api.mobula.io/api/2/bridge/status/%s", txHash) + url := fmt.Sprintf(mobulaAPIBase()+"/api/2/bridge/status/%s", txHash) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err @@ -518,9 +518,13 @@ func (tx *TxExecutor) getLiFiStatus(txHash, fromChain, toChain string) (*BridgeS } var result struct { - Status string `json:"status"` - Sending struct{ TxHash string `json:"txHash"` } `json:"sending"` - Received struct{ TxHash string `json:"txHash"` } `json:"received"` + Status string `json:"status"` + Sending struct { + TxHash string `json:"txHash"` + } `json:"sending"` + Received struct { + TxHash string `json:"txHash"` + } `json:"received"` } if err := json.Unmarshal(body, &result); err != nil { return nil, err From f1def8a1e4d05666c37fb53d86222508a74faa55 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:41:46 +0200 Subject: [PATCH 3/6] bridge-monitor: keep quote-loop notionals at 5/50/300 (execution tiers 3/30 are separate) --- .../cmd/monitor/mobula_bridge.go | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go index cac4164a3..51838c4e5 100644 --- a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go @@ -346,8 +346,9 @@ func GetTestRoutes() []TestRoute { // amounts always reflect the real token value — fixes the bug where a stale // hardcoded $2.87 made our $300-labelled quote actually send $266 worth. trumpPrice := TokenPriceUSD("TRUMP", 2.55) // current price ~$2.55, fallback if API unreachable - trump3 := 3.0 / trumpPrice - trump30 := 30.0 / trumpPrice + trump5 := 5.0 / trumpPrice + trump50 := 50.0 / trumpPrice + trump300 := 300.0 / trumpPrice return []TestRoute{ // R1: Solana USDC → Base USDC @@ -357,8 +358,8 @@ func GetTestRoutes() []TestRoute { FromToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", ToChain: "Base", ToChainAPI: "evm:8453", ToToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - Amounts: []float64{3, 30}, - UsdAmounts: []float64{3, 30}, + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, IsSolanaSrc: true, WeeklyOnly: false, }, @@ -369,8 +370,8 @@ func GetTestRoutes() []TestRoute { FromToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", ToChain: "Arbitrum", ToChainAPI: "evm:42161", ToToken: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", - Amounts: []float64{3, 30}, - UsdAmounts: []float64{3, 30}, + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, IsSolanaSrc: false, WeeklyOnly: false, }, @@ -381,8 +382,8 @@ func GetTestRoutes() []TestRoute { FromToken: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", ToChain: "Solana", ToChainAPI: "solana:solana", ToToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - Amounts: []float64{3, 30}, - UsdAmounts: []float64{3, 30}, + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, IsSolanaSrc: false, WeeklyOnly: false, }, @@ -397,8 +398,8 @@ func GetTestRoutes() []TestRoute { FromToken: "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", ToChain: "Base", ToChainAPI: "evm:8453", ToToken: "0x532f27101965dd16442E59d40670FaF5eBB142E4", - Amounts: []float64{trump3, trump30}, - UsdAmounts: []float64{3, 30}, + Amounts: []float64{trump5, trump50, trump300}, + UsdAmounts: []float64{5, 50, 300}, IsSolanaSrc: true, WeeklyOnly: true, }, @@ -415,8 +416,8 @@ func GetTestRoutes() []TestRoute { FromToken: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", ToChain: "HyperCore", ToChainAPI: "hl:mainnet", ToToken: "USDC", // Mobula uses symbol; per-bridge translators map to provider-specific addr - Amounts: []float64{3, 30}, - UsdAmounts: []float64{3, 30}, + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, IsSolanaSrc: false, WeeklyOnly: false, QuoteOnly: true, From 152db61e515904a474068257528ce9296a83f462 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:44:42 +0200 Subject: [PATCH 4/6] bench 002: scope region tabs to EU-West while us-east/sgp probes are paused --- benchmarks/bridge-quote-latency.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index 7362a22ed..820d01080 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -24,11 +24,14 @@ dimensions: - { value: Arbitrum, label: Base to Arb } - { value: Solana, label: Arb to Sol } - { value: HyperCore, label: Arb to HyperCore } + # us-east and sgp probes are temporarily paused (2026-09-10): only the + # EU-West (Paris) origin is live, so those two tabs are hidden. The + # All-regions aggregate self-corrects to EU-West within 24h as the paused + # series age out of the rolling window. Restore the two lines to bring the + # tabs back once the Railway us/sgp probes resume. region: - { value: all, label: All regions } - { value: eu-west, label: EU-West } - - { value: us-east, label: US-East } - - { value: sgp, label: Singapore } amount_usd: - { value: "300", label: "$300" } - { value: "50", label: "$50" } @@ -63,7 +66,7 @@ methodology: - "Routes: USDC pairs spanning Solana, Base and Arbitrum." - "Notional sizes: $5, $50, $300 per quote." - "Cadence: full sweep (4 routes × 3 amounts × N bridges) every 5 minutes for 24 hours." - - "Regions: eu-west, us-east, sgp. Three independent instances probe simultaneously; the All-regions aggregate uses the full union; per-region tabs isolate the view to one origin." + - "Regions: eu-west only at present. The us-east and sgp probes are temporarily paused, so the live figures reflect the EU-West (Paris) origin; the harness is built to probe three origins simultaneously and the other two tabs return when those probes resume." - "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." From e3468efaa99ba699ce70b6b1e41b4b571af76cb6 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:45:18 +0200 Subject: [PATCH 5/6] bench 001 bridge-fee: scope region tabs to EU-West while us-east/sgp probes are paused --- benchmarks/bridge-fee.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml index db25d8a07..4e024bdd9 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -22,11 +22,14 @@ dimensions: - { value: Arbitrum, label: Base to Arb } - { value: Solana, label: Arb to Sol } - { value: HyperCore, label: Arb to HyperCore } + # us-east and sgp probes are temporarily paused (2026-09-10): only the + # EU-West (Paris) origin is live, so those two tabs are hidden. The + # All-regions aggregate self-corrects to EU-West within 24h as the paused + # series age out of the rolling window. Restore the two lines when the + # Railway us/sgp probes resume. region: - { value: all, label: All regions } - { value: eu-west, label: EU-West } - - { value: us-east, label: US-East } - - { value: sgp, label: Singapore } amount_usd: - { value: "300", label: "$300" } - { value: "50", label: "$50" } @@ -65,7 +68,7 @@ methodology: - "Cadence: full sweep every 5 minutes for 24 hours." - "Aggregation: the headline number is the average of per-corridor 24h medians over the corridors a provider actually quotes. The TRUMP to BRETT calibration route is excluded. Near Intents has no USDT legs, so its aggregate covers the Solana to Base and Arbitrum to HyperCore corridors only. deBridge does not quote the Arbitrum to HyperCore corridor, so its aggregate covers the remaining three. Across also skips Arbitrum to HyperCore, so its aggregate covers the same three corridors as deBridge." - "Normalization: cost is quoted USD in minus quoted USD delivered, the same all-in definition for every provider. Near Intents bundles the destination chain withdrawal fee into its quoted amountOut (the 1Click API documents withdrawFee as already deducted), so no extra gas term is added on top." - - "Regions: eu-west, us-east, sgp. Three independent instances probe simultaneously; the All-regions aggregate uses the full union; per-region tabs isolate the view to one origin." + - "Regions: eu-west only at present. The us-east and sgp probes are temporarily paused, so the live figures reflect the EU-West (Paris) origin; the other two tabs return when those probes resume." - "Failures (quote_failed, unsupported route) excluded from cost aggregates and counted toward success rate." - "Provider types are surfaced as badges: direct protocols (deBridge) charge their own bridging fees; aggregators (LI.FI) route through whichever underlying bridge is cheapest, so their effective fee includes that bridge's cost plus any aggregator markup; intent / relay layers (Mobula, Relay, Across, Near Intents) bake cost into the spread of a settlement intent or optimistic fill rather than charging an explicit fee." - "Excluded by design: Stargate, because its keyless quote endpoint was deprecated in favor of a key-gated LayerZero API, and its on-chain quoteOFT path is same-asset EVM-only, which matches none of this bench's corridors. CCTP, because Circle's burn/mint has no quote that returns a delivered amount; standard transfers mint 1:1 and the whole cost is user-paid gas we would have to estimate ourselves, which breaks the quoted USD in minus quoted USD delivered definition used for every other row." From cfda7f7ba2265f558fe08796cf025c880262b257 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:51:14 +0200 Subject: [PATCH 6/6] bench 001/002: display EU-West only (single region) + methodology copy while us/sgp paused --- benchmarks/bridge-fee.yml | 7 ++----- benchmarks/bridge-quote-latency.yml | 31 ++++++++++++++--------------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml index 4e024bdd9..bb609b206 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -23,12 +23,9 @@ dimensions: - { value: Solana, label: Arb to Sol } - { value: HyperCore, label: Arb to HyperCore } # us-east and sgp probes are temporarily paused (2026-09-10): only the - # EU-West (Paris) origin is live, so those two tabs are hidden. The - # All-regions aggregate self-corrects to EU-West within 24h as the paused - # series age out of the rolling window. Restore the two lines when the - # Railway us/sgp probes resume. + # EU-West (Paris) origin is live, so the bench is scoped to a single region. + # Restore the all/us-east/sgp values when the Railway us/sgp probes resume. region: - - { value: all, label: All regions } - { value: eu-west, label: EU-West } amount_usd: - { value: "300", label: "$300" } diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index 820d01080..9de6245f3 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -25,12 +25,10 @@ dimensions: - { value: Solana, label: Arb to Sol } - { value: HyperCore, label: Arb to HyperCore } # us-east and sgp probes are temporarily paused (2026-09-10): only the - # EU-West (Paris) origin is live, so those two tabs are hidden. The - # All-regions aggregate self-corrects to EU-West within 24h as the paused - # series age out of the rolling window. Restore the two lines to bring the - # tabs back once the Railway us/sgp probes resume. + # EU-West (Paris) origin is live, so the bench is scoped to a single region. + # Restore the all/us-east/sgp values to bring the multi-region tabs back once + # the Railway us/sgp probes resume. region: - - { value: all, label: All regions } - { value: eu-west, label: EU-West } amount_usd: - { value: "300", label: "$300" } @@ -44,9 +42,10 @@ seo_intro: | flow into a wallet, an aggregator or a chat-app care about the same number every public roundup ignores. how many milliseconds the API burns before the user sees a price. We hammer the same USDC routes - (Solana, Base, Arbitrum) against every bridge from three geographic - origins (EU-West, US-East, Singapore), every five minutes, on three - notional sizes. p50, p90 and p99 are computed via Prometheus + (Solana, Base, Arbitrum) against every bridge from the EU-West (Paris) + origin, every five minutes, on three notional sizes. The US-East and + Singapore origins are temporarily paused, so the live numbers reflect + EU-West only. p50, p90 and p99 are computed via Prometheus `histogram_quantile` over the 24-hour window, so the leaderboard reflects sustained API performance, not a lucky cold-start. The same harness records failure mode for every request (quote_failed, @@ -94,7 +93,7 @@ prometheus: faq: - q: "Which cross-chain bridge has the lowest API latency?" - a: "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) across {{count}} measured bridges, averaged over the 4 USDC routes the harness sweeps. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the ranking reflects the last 24 hours of actual API performance from 3 regions (eu-west, us-east, sgp) under the same routes. Per-corridor latency leaders can differ from this aggregate where one provider has a solver pool closer to a specific destination chain, that breakdown is on the roadmap once corridor dimensions are surfaced in the page UI." + a: "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) across {{count}} measured bridges, averaged over the 4 USDC routes the harness sweeps. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the ranking reflects the last 24 hours of actual API performance from the EU-West (Paris) origin under the same routes. Per-corridor latency leaders can differ from this aggregate where one provider has a solver pool closer to a specific destination chain, that breakdown is on the roadmap once corridor dimensions are surfaced in the page UI." - q: "What is the difference between quote latency and bridge fill time?" a: "Quote latency is the wall-clock time the bridge API takes to return a price. Fill time is the wall-clock time the funds take to land on the destination chain after the user signs. They are decoupled: a bridge can quote in 100 ms and take 30 seconds to settle, or quote in 2 seconds and settle in 5 seconds. This benchmark measures the quote half because that is the part a wallet, an aggregator or an embedded swap UI feels first and tunes its UX around." - q: "Why does bridge quote latency matter for builders?" @@ -102,7 +101,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 three origins (eu-west, us-east, sgp) simultaneously. 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." + a: "The harness issues identical quote requests against every bridge for the same route and notional, every five minutes, from the EU-West (Paris) 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." @@ -121,7 +120,7 @@ providers: - slug: mobula name: Mobula tag: Aggregator + intent layer - formula: "Median wall-clock ms over 24h for Mobula's quote API to return a usable USDC route, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." + formula: "Median wall-clock ms over 24h for Mobula's quote API to return a usable USDC route, sampled every 5 minutes across 4 routes and 3 notionals from the EU-West (Paris) origin." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="mobula"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="mobula"}) @@ -144,7 +143,7 @@ providers: - slug: relay name: Relay tag: Cross-chain relay - formula: "Median wall-clock ms over 24h for Relay's solver-quote API to return a usable USDC route, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." + formula: "Median wall-clock ms over 24h for Relay's solver-quote API to return a usable USDC route, sampled every 5 minutes across 4 routes and 3 notionals from the EU-West (Paris) origin." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="relay"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="relay"}) @@ -167,7 +166,7 @@ providers: - slug: lifi name: LI.FI tag: Aggregator - formula: "Median wall-clock ms over 24h for LI.FI's route-search API to return a usable USDC route across underlying bridges, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." + formula: "Median wall-clock ms over 24h for LI.FI's route-search API to return a usable USDC route across underlying bridges, sampled every 5 minutes across 4 routes and 3 notionals from the EU-West (Paris) origin." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="lifi"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="lifi"}) @@ -190,7 +189,7 @@ providers: - slug: debridge name: deBridge tag: Native-token fees - formula: "Median wall-clock ms over 24h for deBridge's direct-protocol quote API to return a single-route USDC quote, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." + formula: "Median wall-clock ms over 24h for deBridge's direct-protocol quote API to return a single-route USDC quote, sampled every 5 minutes across 4 routes and 3 notionals from the EU-West (Paris) origin." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="debridge"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="debridge"}) @@ -213,7 +212,7 @@ providers: - slug: near-intents name: Near Intents tag: Intent layer (NEAR) - formula: "Median wall-clock ms over 24h for Near Intents 1Click API to return a usable USDC quote via the solver auction bus, sampled every 5 minutes across the supported routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." + formula: "Median wall-clock ms over 24h for Near Intents 1Click API to return a usable USDC quote via the solver auction bus, sampled every 5 minutes across the supported routes and 3 notionals from the EU-West (Paris) origin." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="near-intents"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="near-intents"}) @@ -236,7 +235,7 @@ providers: - slug: across name: Across tag: Intent layer - formula: "Median wall-clock ms over 24h for Across's Swap API to return a usable USDC quote across supported corridors (Sol to Base, Base to Arb) from 3 regions. Arb to Sol excluded: no destination swaps to Solana." + formula: "Median wall-clock ms over 24h for Across's Swap API to return a usable USDC quote across supported corridors (Sol to Base, Base to Arb) from the EU-West origin. Arb to Sol excluded: no destination swaps to Solana." queries: p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="across"}) p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="across"})