diff --git a/benchmarks/solana-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml index 309c8112..25000ebe 100644 --- a/benchmarks/solana-tx-landing-latency.yml +++ b/benchmarks/solana-tx-landing-latency.yml @@ -3,8 +3,8 @@ slug: solana-tx-landing-latency number: "027" title: Solana transaction landing latency -seo_title: "Solana tx landing latency 2026: Jito vs Helius vs Nozomi vs Astralane time-to-land in slots" -seo_description: "Live active benchmark of Solana transaction landing services. Slot-level time-to-land (canonical on-chain measurement) plus wall-clock milliseconds. Identical signed mainnet probes submitted to each service from us-east every hour. Methodology pre-registered." +seo_title: "Solana tx landing latency 2026: Jito vs Helius vs Nozomi vs Astralane vs Mobula time-to-land in slots" +seo_description: "Live active benchmark of Solana transaction landing services. Slot-level time-to-land (canonical on-chain measurement) plus wall-clock milliseconds. Identical signed mainnet probes submitted to each service (Jito, Helius Sender, Astralane, Nozomi, Mobula) from us-east every hour. Methodology pre-registered." subtitle: How fast does each landing service get a signed mainnet tx confirmed. Slot delta = number of Solana slots between submit and confirmed (1 slot ≈ 400 ms). Active probing, identical signed txs every hour from us-east. category: Trading status: live @@ -206,3 +206,16 @@ providers: success: sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="nozomi",region="us-east",reason="timeout"}[7d]))) sample_size: sum(increase(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) series: solana_landing_probe_latency_slots{service="nozomi",region="us-east"} + + - slug: mobula + name: Mobula Swap-Send + tag: Multi-RPC fan-out aggregator (relays via Jito / Nozomi / zeroslot) + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted via Mobula's `api.mobula.io/api/2/swap/send` (multi-RPC fan-out) from us-east. Tip wallet reuses Jito's because Mobula default-routes to Jito; attribution is via signatureSubscribe, independent of the tip wallet." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="mobula",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="mobula",region="us-east"} diff --git a/harnesses/solana-tx-landing/cmd/script/senders.go b/harnesses/solana-tx-landing/cmd/script/senders.go index d8f55c9c..e0d88ffd 100644 --- a/harnesses/solana-tx-landing/cmd/script/senders.go +++ b/harnesses/solana-tx-landing/cmd/script/senders.go @@ -87,6 +87,10 @@ func buildProbes(enabled map[Service]bool) ([]serviceProbe, error) { buildNozomiProbe(tipWallets[ServiceNozomi]), buildAstralaneProbe(tipWallets[ServiceAstralane]), buildZeroSlotProbe(tipWallets[Service0slot]), + // Mobula has no own tip wallet (relays via Jito/Nozomi/zeroslot internally). + // Embed a Jito tip transfer so Mobula's default routing sees a valid tip; + // attribution uses signatureSubscribe (active probe path), not tip-wallet. + buildMobulaProbe(tipWallets[ServiceJito]), } out := make([]serviceProbe, 0, len(candidates)) @@ -439,3 +443,109 @@ func truncate(s string, n int) string { } return s[:n] + "…" } + +// ----------------------------------------------------------------------------- +// Mobula swap-send — multi-RPC fan-out aggregator +// docs.mobula.io/rest-api-reference/endpoint/swap-send +// +// Wire shape (verified 2026-05-27): +// - POST https://api.mobula.io/api/2/swap/send +// - Authorization: Bearer +// - Body: {"chainId": "solana:solana", "signedTransaction": ""} +// - Response on success: {"data": {"transactionHash": "", "requestId": "...", +// "success": true, "lander": "jito|nozomi|..."}} +// - 201 returned for accepted submission; downstream failure surfaces inside +// the JSON body via `error` / `data.success=false`. +// +// Mobula relays internally to Jito / Nozomi / zeroslot via multi-RPC fan-out, +// so there is no dedicated Mobula tip wallet. The probe embeds a Jito tip +// transfer (same wallet as the Jito control probe) so Mobula's default +// routing sees a valid tip on the underlying lander. Active-probe attribution +// works via signatureSubscribe on the tx signature, independent of the tip +// wallet used. +// ----------------------------------------------------------------------------- + +func buildMobulaProbe(tip solana.PublicKey) serviceProbe { + key := strings.TrimSpace(os.Getenv("MOBULA_API_KEY")) + if key == "" { + fmt.Println("[prober] mobula: MOBULA_API_KEY not set — service skipped") + return serviceProbe{} + } + url := envDefault("MOBULA_ENDPOINT", "https://api.mobula.io/api/2/swap/send") + tipLamports := uint64(envInt("MOBULA_TIP_LAMPORTS", 10_000)) + + return serviceProbe{ + Service: ServiceMobula, + Mode: "default", + Endpoint: url, + TipWallet: tip, + TipLamports: tipLamports, + Sender: func(ctx context.Context, tx *solana.Transaction) (string, error) { + return postMobulaSwapSend(ctx, url, key, tx) + }, + } +} + +func postMobulaSwapSend(ctx context.Context, url, apiKey string, tx *solana.Transaction) (string, error) { + txB64, err := txToBase64(tx) + if err != nil { + return "", err + } + reqBody := map[string]any{ + "chainId": "solana:solana", + "signedTransaction": txB64, + } + buf, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal req: %w", err) + } + cctx, cancel := context.WithTimeout(ctx, senderHTTPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(cctx, http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return "", fmt.Errorf("new req: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := senderHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("http: %s", scrubSecrets(err.Error())) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 419 || resp.StatusCode == 429 { + return "", fmt.Errorf("http %d (rate limit): %s", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) + } + if resp.StatusCode >= 500 { + return "", fmt.Errorf("http %d: %s", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) + } + + var parsed struct { + Data struct { + TransactionHash string `json:"transactionHash"` + Success bool `json:"success"` + } `json:"data"` + Error string `json:"error"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", fmt.Errorf("decode resp (status=%d body=%s): %w", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200)), err) + } + if parsed.Error != "" { + return "", fmt.Errorf("mobula: %s", scrubSecrets(parsed.Error)) + } + if parsed.Data.TransactionHash != "" { + return parsed.Data.TransactionHash, nil + } + // Mobula sometimes returns 201 without echoing the sig. Fall back to + // the locally-computed signature (known once tx.Sign ran). + if len(tx.Signatures) == 0 { + return "", fmt.Errorf("no signature in response or on tx (status=%d body=%s)", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) + } + return tx.Signatures[0].String(), nil +} diff --git a/harnesses/solana-tx-landing/cmd/script/wallets.go b/harnesses/solana-tx-landing/cmd/script/wallets.go index 55db22e8..94e1d995 100644 --- a/harnesses/solana-tx-landing/cmd/script/wallets.go +++ b/harnesses/solana-tx-landing/cmd/script/wallets.go @@ -28,6 +28,10 @@ const ( ServiceNextBlock Service = "nextblock" ServiceAstralane Service = "astralane" ServiceSolanaVibeStation Service = "solanavibestation" + // Mobula swap-send. No dedicated tip wallets — Mobula relays internally + // to Jito/Nozomi/zeroslot via multi-RPC fan-out. Not observable via + // tip-wallet attribution (bench № 016); active-probe-only (bench № 027). + ServiceMobula Service = "mobula" ) // walletToService maps every known tip address to its owning service.