diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml index 23cf7413..8603a8af 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -194,3 +194,45 @@ providers: success: avg(avg_over_time(bridge_quote_success{bridge="near-intents", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) sample_size: sum(count_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) series: avg_over_time(bridge_cost_percent{bridge="near-intents", amount_usd="300", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) + + - slug: squid + name: Squid Router + tag: Aggregator (Axelar) + formula: "Per-corridor 24h median of total cost percent on a $300 USDC quote from Squid Router's v2 API, averaged across supported EVM corridors (Base to Arb). Cost = fromAmountUSD minus toAmountUSD including all relayer and gas fees." + type: aggregator + queries: + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="squid", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="squid", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="squid", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="squid", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300"}[1h]) + + - slug: socket + name: Socket Protocol + tag: Aggregator + formula: "Per-corridor 24h median of total cost percent on a $300 USDC best-output route from Socket Protocol's v2 API, averaged across supported EVM corridors (Base to Arb). Routes through underlying bridges (Across, Stargate, CCTP); cost = inputValueInUsd minus outputValueInUsd." + type: aggregator + queries: + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="socket", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="socket", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="socket", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="socket", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300"}[1h]) + + - slug: mayan + name: Mayan Finance + tag: Intent layer (Wormhole) + formula: "Per-corridor 24h median of total cost percent on a $300 USDC quote from Mayan Finance's v3 API (Swift fast path), averaged across supported Solana corridors (Sol to Base, Arb to Sol). Cost = input minus expectedAmountOut including relayer and swap fees." + type: intent + queries: + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mayan", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="mayan", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="mayan", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="mayan", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="mayan", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="mayan", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="mayan", amount_usd="300"}[1h]) diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index 42a94e43..e5920176 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -234,3 +234,42 @@ providers: success: avg(avg_over_time(bridge_quote_success{bridge="across"}[24h])) sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="across"}[24h])) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="across"}[1h]))) + + - slug: squid + name: Squid Router + tag: Aggregator (Axelar) + formula: "Median wall-clock ms over 24h for Squid Router's v2 API to return a best-output USDC route, sampled every 5 minutes across supported EVM corridors (Base to Arb) and 3 notionals from 3 regions. Solana corridors excluded: Squid v2 Solana source returns a server-side error." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="squid"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="squid"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="squid"}[24h]))) + mean: sum(rate(bridge_quote_latency_ms_sum{bridge="squid"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="squid"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="squid"}[24h])) + sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="squid"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="squid"}[1h]))) + + - slug: socket + name: Socket Protocol + tag: Aggregator + formula: "Median wall-clock ms over 24h for Socket Protocol's v2 API to return the best-output USDC route across underlying bridges (Across, Stargate, CCTP), sampled every 5 minutes across supported EVM corridors (Base to Arb) and 3 notionals from 3 regions. Solana is receive-only in Socket's chain list." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="socket"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="socket"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="socket"}[24h]))) + mean: sum(rate(bridge_quote_latency_ms_sum{bridge="socket"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="socket"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="socket"}[24h])) + sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="socket"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="socket"}[1h]))) + + - slug: mayan + name: Mayan Finance + tag: Intent layer (Wormhole) + formula: "Median wall-clock ms over 24h for Mayan Finance's v3 quote API to return a Swift or MCTP USDC route, sampled every 5 minutes across supported Solana corridors (Sol to Base, Arb to Sol) and 3 notionals from 3 regions. Pure EVM corridors are outside Mayan's scope." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mayan"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mayan"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mayan"}[24h]))) + mean: sum(rate(bridge_quote_latency_ms_sum{bridge="mayan"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="mayan"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="mayan"}[24h])) + sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="mayan"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mayan"}[1h]))) diff --git a/harnesses/bridge-monitor/cmd/monitor/config.go b/harnesses/bridge-monitor/cmd/monitor/config.go index 573c6cb3..0b828f92 100644 --- a/harnesses/bridge-monitor/cmd/monitor/config.go +++ b/harnesses/bridge-monitor/cmd/monitor/config.go @@ -17,6 +17,8 @@ type Config struct { DebridgeAPIKey string LiFiAPIKey string NearIntentsAPIKey string + SquidIntegratorID string + SocketAPIKey string // Wallet Configuration WalletEVMPrivateKey string @@ -74,6 +76,8 @@ func loadEnv() (*Config, error) { config.DebridgeAPIKey = strings.TrimSpace(os.Getenv("DEBRIDGE_API_KEY")) config.LiFiAPIKey = strings.TrimSpace(os.Getenv("LIFI_API_KEY")) config.NearIntentsAPIKey = strings.TrimSpace(os.Getenv("NEARINTENTS_API_KEY")) + config.SquidIntegratorID = strings.TrimSpace(os.Getenv("SQUID_INTEGRATOR_ID")) + config.SocketAPIKey = strings.TrimSpace(os.Getenv("SOCKET_API_KEY")) // Wallet configuration config.WalletEVMPrivateKey = strings.TrimSpace(os.Getenv("WALLET_EVM_PRIVATE_KEY")) diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index ca506334..7c5cf010 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -70,6 +70,15 @@ func main() { log.Println("⚠️ Near Intents bridge initialized (anonymous mode - adds ~10 bps appFee and lower solver priority)") } + squidBridge := NewSquidBridge(config.SquidIntegratorID) + log.Println("✅ Squid bridge initialized (public integrator ID)") + + socketBridge := NewSocketBridge(config.SocketAPIKey) + log.Println("✅ Socket bridge initialized (public demo key)") + + mayanBridge := NewMayanBridge() + log.Println("✅ Mayan bridge initialized (no key needed)") + // Initialize wallet manager walletManager, err := NewWalletManager(config) if err != nil { @@ -233,7 +242,7 @@ func main() { } // Run quote tests immediately on startup (all routes) - runQuoteTests(mobulaBridge, relayBridge, debridgeBridge, lifiBridge, acrossBridge, nearIntentsBridge, allRoutes, config.MonitorRegion, solAddress, evmAddress) + runQuoteTests(mobulaBridge, relayBridge, debridgeBridge, lifiBridge, acrossBridge, nearIntentsBridge, squidBridge, socketBridge, mayanBridge, allRoutes, config.MonitorRegion, solAddress, evmAddress) // If in dry-run mode, run a single dry-run test if config.ExecutionMode == "dry-run" && executor != nil { @@ -281,7 +290,7 @@ func main() { done := make(chan struct{}) go func() { defer close(done) - runQuoteTests(mobulaBridge, relayBridge, debridgeBridge, lifiBridge, acrossBridge, nearIntentsBridge, GetTestRoutes(), config.MonitorRegion, solAddress, evmAddress) + runQuoteTests(mobulaBridge, relayBridge, debridgeBridge, lifiBridge, acrossBridge, nearIntentsBridge, squidBridge, socketBridge, mayanBridge, GetTestRoutes(), config.MonitorRegion, solAddress, evmAddress) }() select { case <-done: @@ -568,6 +577,9 @@ func runQuoteTests( lifiBridge *LiFiBridge, acrossBridge *AcrossBridge, nearIntentsBridge *NearIntentsBridge, + squidBridge *SquidBridge, + socketBridge *SocketBridge, + mayanBridge *MayanBridge, routes []TestRoute, region, solAddress, evmAddress string, ) { timestamp := time.Now().Format("2006-01-02 15:04:05") @@ -613,6 +625,23 @@ func runQuoteTests( // honest (no fake unsupported-route failures). nearIntentsBridge.TestRoute(route, amount, amountUsd, rawUnits, region, solAddress, evmAddress) time.Sleep(500 * time.Millisecond) + + // Squid Router (Axelar): EVM-only (Base/Arb). Solana source returns + // server-side 500 — TestRoute returns early for unsupported corridors. + squidBridge.TestRoute(route, amount, amountUsd, rawUnits, region, solAddress, evmAddress) + time.Sleep(500 * time.Millisecond) + + // Socket Protocol (Bungee): EVM-only aggregator (Base/Arb). + // Returns best-output route across Across, Stargate, CCTP, etc. + socketBridge.TestRoute(route, amount, amountUsd, rawUnits, region, solAddress, evmAddress) + time.Sleep(500 * time.Millisecond) + + // Mayan Finance: Solana-EVM only. TestRoute returns early for pure + // EVM corridors. May emit ROUTE_NOT_FOUND on some corridors during + // API outages; harness records those as quote_failed so success rate + // reflects availability rather than latency. + mayanBridge.TestRoute(route, amount, amountUsd, rawUnits, region, solAddress, evmAddress) + time.Sleep(500 * time.Millisecond) } } diff --git a/harnesses/bridge-monitor/cmd/monitor/mayan_bridge.go b/harnesses/bridge-monitor/cmd/monitor/mayan_bridge.go new file mode 100644 index 00000000..003013f8 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/mayan_bridge.go @@ -0,0 +1,180 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// MayanBridge integrates the Mayan Finance v3 quote API. Mayan is a Solana- +// native intent layer that routes via Wormhole (Swift fast path ~2-5s, +// MCTP Circle-based ~20s). Public REST, keyless (verified live 2026-07-27, +// though ROUTE_NOT_FOUND may be transient on some corridors — the harness +// records failures as quote_failed and excludes them from latency aggregates +// so any outage window is surfaced as a drop in success rate, not a latency +// floor). +// +// Coverage: any corridor involving Solana. EVM↔EVM routes are outside Mayan's +// product scope and return early without emitting metrics (honesty convention +// shared with Across / Near Intents). +// +// Chain name mapping follows Mayan's API conventions (lowercase, no numbers). +type MayanBridge struct { + client *http.Client +} + +func NewMayanBridge() *MayanBridge { + return &MayanBridge{client: &http.Client{Timeout: 45 * time.Second}} +} + +func mayanChainName(chain string) string { + switch strings.ToLower(chain) { + case "solana": + return "solana" + case "base": + return "base" + case "arbitrum": + return "arbitrum" + } + return "" +} + +// mayanHasSolana returns true when at least one end of the route is Solana. +// Mayan's value prop is Solana-EVM bridging; pure EVM pairs are out of scope. +func mayanHasSolana(route TestRoute) bool { + return strings.EqualFold(route.FromChain, "Solana") || strings.EqualFold(route.ToChain, "Solana") +} + +type mayanQuoteResponse struct { + // Happy path: array of route options + Routes []struct { + ExpectedAmountOut float64 `json:"expectedAmountOut"` + MiminumAmountOut float64 `json:"minimumAmountOut"` + Price float64 `json:"price"` + PriceImpact float64 `json:"priceImpact"` + SwapFee float64 `json:"swapFee"` + RedeemRelayerFee float64 `json:"redeemRelayerFee"` + SendRelayerFee float64 `json:"sendRelayerFee"` + ClientRelayerFee float64 `json:"clientRelayerFee"` + Eta float64 `json:"eta"` + Type string `json:"type"` // "SWIFT" | "MCTP" | "WH" + } `json:"routes"` + // Error shape + Code string `json:"code,omitempty"` + Message string `json:"msg,omitempty"` +} + +func (m *MayanBridge) getQuote(route TestRoute, amountUsd float64) (*mayanQuoteResponse, time.Duration, error) { + start := time.Now() + + params := url.Values{} + // Mayan takes the human-readable amount in USD equivalent, not raw token units. + params.Set("amount", strconv.FormatFloat(amountUsd, 'f', 2, 64)) + params.Set("fromToken", route.FromToken) + params.Set("toToken", route.ToToken) + params.Set("fromChain", mayanChainName(route.FromChain)) + params.Set("toChain", mayanChainName(route.ToChain)) + params.Set("slippage", "0.5") + params.Set("sdkVersion", "15") // latest as of 2026-07-27 + + req, err := http.NewRequest(http.MethodGet, "https://price-api.mayan.finance/v3/quote?"+params.Encode(), nil) + if err != nil { + return nil, time.Since(start), fmt.Errorf("mayan request create: %w", err) + } + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + req.Header.Set("Accept", "application/json") + + resp, err := m.client.Do(req) + if err != nil { + return nil, time.Since(start), fmt.Errorf("mayan request: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + latency := time.Since(start) + if resp.StatusCode != http.StatusOK { + return nil, latency, fmt.Errorf("mayan %d: %s", resp.StatusCode, truncate(string(raw), 300)) + } + + var out mayanQuoteResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, latency, fmt.Errorf("mayan decode: %w", err) + } + if out.Code != "" { + return nil, latency, fmt.Errorf("mayan %s: %s", out.Code, out.Message) + } + if len(out.Routes) == 0 { + return nil, latency, fmt.Errorf("mayan: no routes returned") + } + return &out, latency, nil +} + +func (m *MayanBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawUnits string, region, solAddress, evmAddress string) { + amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) + labels := []string{"mayan", route.FromChain, route.ToChain, route.FromToken, route.ToToken, amountStr, region, route.ToChain} + + // Mayan is Solana-EVM only. Skip pure-EVM corridors and HyperCore. + if !mayanHasSolana(route) { + return + } + if mayanChainName(route.FromChain) == "" || mayanChainName(route.ToChain) == "" { + return + } + + result, quoteLatency, err := m.getQuote(route, amountUsd) + if err != nil { + log.Printf("[MAYAN][%s][%.0f USD] ❌ %v", route.Name, amountUsd, err) + bridgeErrors.WithLabelValues(append(labels, "quote_failed")...).Inc() + bridgeQuoteSuccess.WithLabelValues(labels...).Set(0) + return + } + + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) + bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) + + // Use the best (first) route. Mayan sorts by output amount descending. + best := result.Routes[0] + + // Mayan quotes output in destination token units (USDC = 6 decimals). + // expectedAmountOut is in token base units; normalise to USD (1 USDC = $1). + outUsd := best.ExpectedAmountOut / 1e6 + + // All-in cost: relayer fees + swap fee. Mayan does not expose a single + // USD total; we reconstruct from the fee fields (all in source token units, + // 6-decimal USDC normalised to USD here). + bridgeFeeUsd := (best.RedeemRelayerFee + best.SendRelayerFee + best.ClientRelayerFee) / 1e6 + swapFeeUsd := best.SwapFee / 1e6 + gasUsd := 0.0 // gas is rolled into relayer fees for Mayan + costUsd := amountUsd - outUsd + if costUsd < 0 { + costUsd = 0 + } + costPct := 0.0 + if amountUsd > 0 { + costPct = (costUsd / amountUsd) * 100 + } + slippage := swapFeeUsd + if slippage < 0 { + slippage = 0 + } + + bridgeFeesUSD.WithLabelValues(labels...).Set(bridgeFeeUsd) + bridgeFeesPercent.WithLabelValues(labels...).Set((bridgeFeeUsd / amountUsd) * 100) + bridgeCostUSD.WithLabelValues(labels...).Set(costUsd) + bridgeCostPercent.WithLabelValues(labels...).Set(costPct) + bridgeSlippageUSD.WithLabelValues(labels...).Set(slippage) + bridgeGasUSD.WithLabelValues(labels...).Set(gasUsd) + bridgeFixFeeUSD.WithLabelValues(labels...).Set(0) + bridgeOutputUSD.WithLabelValues(labels...).Set(outUsd) + bridgeEstimatedTimeMs.WithLabelValues(labels...).Set(best.Eta * 1000) + + log.Printf("[MAYAN][%s][%.0f USD] ✅ Quote: %dms | Cost: $%.4f (%.3f%%) | Type: %s | Est: %.0fs", + route.Name, amountUsd, quoteLatency.Milliseconds(), + costUsd, costPct, best.Type, best.Eta) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/socket_bridge.go b/harnesses/bridge-monitor/cmd/monitor/socket_bridge.go new file mode 100644 index 00000000..5b640ebf --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/socket_bridge.go @@ -0,0 +1,173 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// SocketBridge integrates the Socket Protocol v2 API (Bungee aggregator). +// Socket is a bridge aggregator: it routes across Across, Stargate, CCTP and +// others under the hood and returns the best output route. +// +// Auth: uses the public demo API key documented on socket.tech/developer. +// Set SOCKET_API_KEY env var to override with a registered key. +// +// Coverage: EVM-only. Base → Arbitrum, Arbitrum → Base are the supported +// corridors from this bench's route set. Solana is listed as chainId 900 but +// has sendingEnabled=false in Socket's chain list (verified 2026-07-27). +// HyperCore is not in Socket's chain registry. +type SocketBridge struct { + client *http.Client + apiKey string +} + +func NewSocketBridge(apiKey string) *SocketBridge { + key := apiKey + if key == "" { + key = "72a5b4b0-e727-48be-8aa1-5da9d62fe635" // Socket public demo key + } + return &SocketBridge{ + client: &http.Client{Timeout: 45 * time.Second}, + apiKey: key, + } +} + +func socketChainID(chain string) string { + switch strings.ToLower(chain) { + case "base": + return "8453" + case "arbitrum": + return "42161" + } + return "" +} + +type socketRoute struct { + OutputValueInUsd float64 `json:"outputValueInUsd"` + InputValueInUsd float64 `json:"inputValueInUsd"` + TotalGasFeesInUsd float64 `json:"totalGasFeesInUsd"` + ServiceTime int `json:"serviceTime"` + BridgeRoute struct { + BridgeFeeAmount string `json:"bridgeFeeAmount"` + } `json:"bridgeRoute"` +} + +type socketQuoteResponse struct { + Success bool `json:"success"` + Result struct { + Routes []socketRoute `json:"routes"` + } `json:"result"` +} + +func (s *SocketBridge) getQuote(route TestRoute, rawAmount, fromChain, toChain, evmAddress string) (*socketRoute, time.Duration, error) { + start := time.Now() + + params := url.Values{} + params.Set("fromChainId", fromChain) + params.Set("toChainId", toChain) + params.Set("fromTokenAddress", route.FromToken) + params.Set("toTokenAddress", route.ToToken) + params.Set("fromAmount", rawAmount) + params.Set("userAddress", evmAddress) + params.Set("uniqueRoutesPerBridge", "true") + params.Set("sort", "output") + params.Set("singleTxOnly", "true") + + req, err := http.NewRequest(http.MethodGet, "https://api.socket.tech/v2/quote?"+params.Encode(), nil) + if err != nil { + return nil, time.Since(start), fmt.Errorf("socket request create: %w", err) + } + req.Header.Set("API-KEY", s.apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return nil, time.Since(start), fmt.Errorf("socket request: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + latency := time.Since(start) + if resp.StatusCode != http.StatusOK { + return nil, latency, fmt.Errorf("socket %d: %s", resp.StatusCode, truncate(string(raw), 300)) + } + + var out socketQuoteResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, latency, fmt.Errorf("socket decode: %w", err) + } + if !out.Success || len(out.Result.Routes) == 0 { + return nil, latency, fmt.Errorf("socket: no routes returned") + } + // Routes are sorted by output (best first) per the sort=output param. + best := out.Result.Routes[0] + return &best, latency, nil +} + +func (s *SocketBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawUnits string, region, solAddress, evmAddress string) { + amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) + labels := []string{"socket", route.FromChain, route.ToChain, route.FromToken, route.ToToken, amountStr, region, route.ToChain} + + fromChain := socketChainID(route.FromChain) + toChain := socketChainID(route.ToChain) + if fromChain == "" || toChain == "" { + return + } + + // Honour SOCKET_API_KEY env override at call time (allows key rotation without + // rebuilding the binary). + apiKey := s.apiKey + if k := strings.TrimSpace(os.Getenv("SOCKET_API_KEY")); k != "" { + apiKey = k + } + s.apiKey = apiKey + + best, quoteLatency, err := s.getQuote(route, rawUnits, fromChain, toChain, evmAddress) + if err != nil { + log.Printf("[SOCKET][%s][%.0f USD] ❌ %v", route.Name, amountUsd, err) + bridgeErrors.WithLabelValues(append(labels, "quote_failed")...).Inc() + bridgeQuoteSuccess.WithLabelValues(labels...).Set(0) + return + } + + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) + bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) + + inUsd := best.InputValueInUsd + outUsd := best.OutputValueInUsd + gasUsd := best.TotalGasFeesInUsd + costUsd := inUsd - outUsd + if costUsd < 0 { + costUsd = 0 + } + costPct := 0.0 + if amountUsd > 0 { + costPct = (costUsd / amountUsd) * 100 + } + bridgeFeeUsd := costUsd - gasUsd + if bridgeFeeUsd < 0 { + bridgeFeeUsd = 0 + } + + bridgeFeesUSD.WithLabelValues(labels...).Set(bridgeFeeUsd) + bridgeFeesPercent.WithLabelValues(labels...).Set((bridgeFeeUsd / amountUsd) * 100) + bridgeCostUSD.WithLabelValues(labels...).Set(costUsd) + bridgeCostPercent.WithLabelValues(labels...).Set(costPct) + bridgeSlippageUSD.WithLabelValues(labels...).Set(0) + bridgeGasUSD.WithLabelValues(labels...).Set(gasUsd) + bridgeFixFeeUSD.WithLabelValues(labels...).Set(0) + bridgeOutputUSD.WithLabelValues(labels...).Set(outUsd) + bridgeEstimatedTimeMs.WithLabelValues(labels...).Set(float64(best.ServiceTime) * 1000) + + log.Printf("[SOCKET][%s][%.0f USD] ✅ Quote: %dms | Cost: $%.4f (%.3f%%) | Est: %ds", + route.Name, amountUsd, quoteLatency.Milliseconds(), + costUsd, costPct, best.ServiceTime) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go b/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go new file mode 100644 index 00000000..5e8e0313 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go @@ -0,0 +1,198 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +// SquidBridge integrates the Squid Router v2 API (Axelar-powered cross-chain +// routing). Public REST with a free integrator ID, no registration required +// for the public `squid-swap-widget` key (verified live 2026-07-27). +// +// Coverage: EVM-only. Base → Arbitrum and Arbitrum → Base are supported. +// Solana as origin chain returns a server-side 500 ("Cannot read properties +// of undefined (reading 'isEvmos')") — Squid v2 Solana support is broken at +// the API level as of 2026-07-27. HyperCore is not in Squid's chain list. +// Both cases return early without emitting metrics. +// +// The SQUID_INTEGRATOR_ID env var overrides the default public key, allowing +// a registered ID to be swapped in without a code change. +type SquidBridge struct { + client *http.Client + integratorID string +} + +func NewSquidBridge(integratorID string) *SquidBridge { + id := integratorID + if id == "" { + id = "squid-swap-widget" + } + return &SquidBridge{ + client: &http.Client{Timeout: 45 * time.Second}, + integratorID: id, + } +} + +func squidChainID(chain string) string { + switch strings.ToLower(chain) { + case "base": + return "8453" + case "arbitrum": + return "42161" + } + return "" +} + +type squidRouteRequest struct { + FromChain string `json:"fromChain"` + ToChain string `json:"toChain"` + FromToken string `json:"fromToken"` + ToToken string `json:"toToken"` + FromAmount string `json:"fromAmount"` + FromAddress string `json:"fromAddress"` + ToAddress string `json:"toAddress"` + Slippage int `json:"slippage"` +} + +type squidFeeCost struct { + Name string `json:"name"` + AmountUsd string `json:"amountUsd"` +} + +type squidGasCost struct { + Type string `json:"type"` + AmountUsd string `json:"amountUsd"` +} + +type squidRouteResponse struct { + Route struct { + Estimate struct { + FromAmountUSD string `json:"fromAmountUSD"` + ToAmountUSD string `json:"toAmountUSD"` + FeeCosts []squidFeeCost `json:"feeCosts"` + GasCosts []squidGasCost `json:"gasCosts"` + EstimatedRouteDuration int `json:"estimatedRouteDuration"` + } `json:"estimate"` + } `json:"route"` + // Error shape + Message string `json:"message,omitempty"` +} + +func (s *SquidBridge) getQuote(route TestRoute, rawAmount, evmAddress string) (*squidRouteResponse, time.Duration, error) { + start := time.Now() + + body := squidRouteRequest{ + FromChain: squidChainID(route.FromChain), + ToChain: squidChainID(route.ToChain), + FromToken: route.FromToken, + ToToken: route.ToToken, + FromAmount: rawAmount, + FromAddress: evmAddress, + ToAddress: evmAddress, + Slippage: 1, + } + bodyBytes, _ := json.Marshal(body) + + req, err := http.NewRequest(http.MethodPost, "https://apiplus.squidrouter.com/v2/route", bytes.NewReader(bodyBytes)) + if err != nil { + return nil, time.Since(start), fmt.Errorf("squid request create: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-integrator-id", s.integratorID) + + resp, err := s.client.Do(req) + if err != nil { + return nil, time.Since(start), fmt.Errorf("squid request: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + latency := time.Since(start) + if resp.StatusCode != http.StatusOK { + return nil, latency, fmt.Errorf("squid %d: %s", resp.StatusCode, truncate(string(raw), 300)) + } + + var out squidRouteResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, latency, fmt.Errorf("squid decode: %w", err) + } + if out.Message != "" { + return nil, latency, fmt.Errorf("squid error: %s", out.Message) + } + if out.Route.Estimate.ToAmountUSD == "" { + return nil, latency, fmt.Errorf("squid: empty toAmountUSD in response") + } + return &out, latency, nil +} + +func (s *SquidBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawUnits string, region, solAddress, evmAddress string) { + amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) + labels := []string{"squid", route.FromChain, route.ToChain, route.FromToken, route.ToToken, amountStr, region, route.ToChain} + + // Squid v2: EVM chains only. Solana source returns 500 server error. + // HyperCore is not in Squid's chain list. + if squidChainID(route.FromChain) == "" || squidChainID(route.ToChain) == "" { + return + } + + quote, quoteLatency, err := s.getQuote(route, rawUnits, evmAddress) + if err != nil { + log.Printf("[SQUID][%s][%.0f USD] ❌ %v", route.Name, amountUsd, err) + bridgeErrors.WithLabelValues(append(labels, "quote_failed")...).Inc() + bridgeQuoteSuccess.WithLabelValues(labels...).Set(0) + return + } + + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) + bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) + + inUsd, _ := strconv.ParseFloat(quote.Route.Estimate.FromAmountUSD, 64) + outUsd, _ := strconv.ParseFloat(quote.Route.Estimate.ToAmountUSD, 64) + + // Sum all protocol fee costs (relayer gas receiver, etc.) + var bridgeFeeUsd float64 + for _, f := range quote.Route.Estimate.FeeCosts { + v, _ := strconv.ParseFloat(f.AmountUsd, 64) + bridgeFeeUsd += v + } + // Sum execution gas costs + var gasUsd float64 + for _, g := range quote.Route.Estimate.GasCosts { + v, _ := strconv.ParseFloat(g.AmountUsd, 64) + gasUsd += v + } + + costUsd := inUsd - outUsd + if costUsd < 0 { + costUsd = 0 + } + costPct := 0.0 + if amountUsd > 0 { + costPct = (costUsd / amountUsd) * 100 + } + slippage := costUsd - bridgeFeeUsd - gasUsd + if slippage < 0 { + slippage = 0 + } + + bridgeFeesUSD.WithLabelValues(labels...).Set(bridgeFeeUsd) + bridgeFeesPercent.WithLabelValues(labels...).Set((bridgeFeeUsd / amountUsd) * 100) + bridgeCostUSD.WithLabelValues(labels...).Set(costUsd) + bridgeCostPercent.WithLabelValues(labels...).Set(costPct) + bridgeSlippageUSD.WithLabelValues(labels...).Set(slippage) + bridgeGasUSD.WithLabelValues(labels...).Set(gasUsd) + bridgeFixFeeUSD.WithLabelValues(labels...).Set(0) + bridgeOutputUSD.WithLabelValues(labels...).Set(outUsd) + bridgeEstimatedTimeMs.WithLabelValues(labels...).Set(float64(quote.Route.Estimate.EstimatedRouteDuration) * 1000) + + log.Printf("[SQUID][%s][%.0f USD] ✅ Quote: %dms | Cost: $%.4f (%.3f%%) | Est: %ds", + route.Name, amountUsd, quoteLatency.Milliseconds(), + costUsd, costPct, quote.Route.Estimate.EstimatedRouteDuration) +}