diff --git a/.github/workflows/brand-numbers.yml b/.github/workflows/brand-numbers.yml new file mode 100644 index 0000000..c1f63f9 --- /dev/null +++ b/.github/workflows/brand-numbers.yml @@ -0,0 +1,24 @@ +name: Brand numbers + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Fails when a marketing number in this repo disagrees with brand-numbers.json. +# +# --check is deliberately OFFLINE. It compares against the committed snapshot +# and never fetches, so a blockrun.ai deploy in progress cannot fail this repo's +# CI. Pulling a newer artifact is a separate, deliberate act: +# +# node scripts/sync-brand-numbers.mjs --refresh +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: node scripts/sync-brand-numbers.mjs --check diff --git a/CHANGELOG.md b/CHANGELOG.md index ef106f4..406d638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to blockrun-llm-go will be documented in this file. +## 0.19.1 + +- **perf(solana): drop both RPC round-trips from the x402 payment critical + path.** Mirrors the TS SDK fast path. USDC mint info (token program + + decimals) is hardcoded — SPL Token fixes `decimals` in `InitializeMint` and + ships no instruction to change it — so `getAccountInfo` is gone outright for + USDC (non-USDC mints still resolve over RPC). `getLatestBlockhash` is cached + per RPC endpoint with a 10s TTL (bounded to 8 endpoints). Unlike the TS SDK, + no duplicate-transaction guard is needed when a blockhash is reused: every + transaction carries a random 16-byte memo nonce, so two payments can never + be byte-identical. Measured on paid gpt-4o-mini calls via `sol.blockrun.ai`: + median 2.16s → 1.72s, mean 3.57s → 1.74s, worst case 9.68s → 2.31s (the old + tail was slow serial RPC). + ## 0.19.0 - **Solana (SVM) x402 payments.** Every client can now pay USDC on Solana via diff --git a/README.md b/README.md index bc80d39..4524d24 100644 --- a/README.md +++ b/README.md @@ -147,12 +147,12 @@ co-signs (gasless) and settles. Constructors: `NewLLMClientSolana`, | Feature | Description | |---------|-------------| -| **Chat & Completion** | OpenAI-compatible chat with 40+ models | +| **Chat & Completion** | OpenAI-compatible chat with 66 models | | **Anthropic Client** | Native Anthropic Messages API with automatic x402 payments | | **Smart Routing** | Auto-selects the best model for your prompt | | **Streaming** | SSE streaming for real-time responses | | **Tool Calling** | OpenAI-compatible function/tool calling | -| **Multi-chain RPC** | JSON-RPC 2.0 to 40+ chains, $0.002/call | +| **Multi-chain RPC** | JSON-RPC 2.0 to 40 chains, $0.002/call | | **Web Search** | Search web, X/Twitter, and news | | **Prediction Markets** | Polymarket, Kalshi data access | | **Image Generation** | DALL-E 3, GPT Image 1/2, Nano Banana, Flux, CogView-4, Grok Imagine | @@ -423,7 +423,7 @@ ie, lu, cn, ca`. ## Multi-chain RPC `RPCClient` wraps `POST /v1/rpc/{network}` — standard JSON-RPC 2.0 access to -40+ chains through one endpoint (Ethereum, Base, Solana, Polygon, BSC, +40 chains through one endpoint (Ethereum, Base, Solana, Polygon, BSC, Arbitrum, Optimism, Avalanche, Bitcoin, Sui, and more; powered by Tatum's RPC gateway). No API key, no per-chain endpoints: flat **$0.002 per call** in USDC; a JSON-RPC batch charges per element. @@ -919,7 +919,7 @@ for _, w := range wallets { | **ElevenLabs** | Flash v2.5, Turbo v2.5, Multilingual v2, v3 (TTS $0.05–0.10/1k chars), Sound Effects ($0.05/gen) | — | — | | **Moonshot** | Kimi K2.6 (256K, vision + reasoning) | $0.95 | $4.00 | | **Moonshot** | Kimi K2.5 (262K context, legacy) | $0.60 | $3.00 | -| **NVIDIA** | DeepSeek V4 Pro/Flash, Nemotron Nano Omni (vision), Qwen3, Llama 4, GLM-4.7, Mistral (9 models) | **FREE** | **FREE** | +| **NVIDIA** | DeepSeek V4 Pro/Flash, Nemotron Nano Omni (vision), Qwen3, Llama 4, GLM-4.7, Mistral (8 models) | **FREE** | **FREE** | Use `client.ListModels(ctx)` for the full list with current pricing. @@ -963,7 +963,7 @@ if err != nil { ## FAQ **What is blockrun-llm-go?** -A Go SDK for pay-per-request access to 40+ LLMs, multi-chain RPC, web search, prediction markets, and image generation. Uses x402 micropayments — no API keys, no subscriptions. +A Go SDK for pay-per-request access to 66 LLMs, multi-chain RPC, web search, prediction markets, and image generation. Uses x402 micropayments — no API keys, no subscriptions. **How much does it cost?** Pay only for what you use. 9 NVIDIA-hosted models are completely free (DeepSeek V4 Pro/Flash, Nemotron Nano Omni vision, Qwen3, Llama 4, GLM-4.7, Mistral). $5 USDC gets you thousands of paid-model requests. diff --git a/VERSION b/VERSION index 1cf0537..41915c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.0 +0.19.1 diff --git a/brand-numbers.json b/brand-numbers.json new file mode 100644 index 0000000..312505d --- /dev/null +++ b/brand-numbers.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://blockrun.ai/brand/numbers.schema.json", + "version": 1, + "models": { + "chatVisible": 66, + "totalVisible": 86, + "free": 8, + "freeWithheld": 17, + "image": 8, + "video": 5, + "music": 1, + "speech": 5, + "soundfx": 1, + "withFallback": 44, + "withFallbackAllEntries": 75 + }, + "clawrouter": { + "dimensions": 15, + "tiers": 4, + "profiles": 4, + "aliases": 202 + }, + "mcp": { + "tools": 19 + }, + "chains": { + "rpc": 40 + }, + "savings": { + "baselineModel": "anthropic/claude-opus-5", + "ecoVsBaselinePct": 98, + "autoVsBaselinePct": 87 + } +} diff --git a/scripts/sync-brand-numbers.mjs b/scripts/sync-brand-numbers.mjs new file mode 100644 index 0000000..c3717f4 --- /dev/null +++ b/scripts/sync-brand-numbers.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node +/** + * Sync marketing numbers from BlockRun's canonical brand artifact. + * + * This file is copied byte-for-byte into every public repo as + * scripts/sync-brand-numbers.mjs. It is a copy rather than an npm package on + * purpose: a package would mean 37 dependency bumps, and several consuming + * repos have no package.json at all. Zero dependencies, plain Node. + * + * node scripts/sync-brand-numbers.mjs rewrite markers in place + * node scripts/sync-brand-numbers.mjs --check exit 1 on drift, write nothing + * node scripts/sync-brand-numbers.mjs --refresh re-fetch the artifact first + * + * --check NEVER touches the network. PR CI must be deterministic and offline: + * if it fetched, a deploy in progress would fail every repo in the org at once. + * Freshness is the fan-out job's problem, not the pull request's. + * + * Markers look like: 66 + * and wrap the WHOLE token, so a badge URL, its alt text and the prose number + * can all regenerate from one key. + */ +import { existsSync, lstatSync, readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { join, relative, extname } from "node:path"; + +const ROOT = process.cwd(); +const SNAPSHOT = join(ROOT, "brand-numbers.json"); +// ORIGIN is tried first because it IS the truth — the mirror can only ever be +// as fresh as the last time someone refreshed it. The mirror exists so a repo +// can still sync while blockrun.ai is down, not to front the origin. +// +// The mirror is awesome-blockrun's own brand-numbers.json: that repo consumes +// the artifact like every other, and its snapshot doubles as the org's copy. +// One file, one role per repo, nothing to keep in step by hand. +const ORIGIN = "https://blockrun.ai/brand/numbers.json"; +const MIRROR = + "https://raw.githubusercontent.com/BlockRunAI/awesome-blockrun/main/brand-numbers.json"; + +const argv = new Set(process.argv.slice(2)); +const check = argv.has("--check"); +const refresh = argv.has("--refresh"); + +const SKIP_DIRS = new Set([ + "node_modules", ".git", "dist", "build", "out", ".next", "coverage", + "vendor", "target", "__pycache__", ".venv", "venv", +]); +// .txt is here for llms.txt, which is a first-class marketing surface: it is +// what agents read to find out what BlockRun serves. Scanning other .txt files +// costs a read and changes nothing — only files with markers are ever written. +const TEXT_EXT = new Set([".md", ".mdx", ".txt"]); + +/* ── 1. numbers ──────────────────────────────────────────────────────────── */ + +async function loadNumbers() { + if (!refresh) { + try { + return JSON.parse(readFileSync(SNAPSHOT, "utf8")); + } catch { + fail( + `no brand-numbers.json in ${ROOT}\n` + + ` run with --refresh once to seed it from ${ORIGIN}`, + ); + } + } + for (const url of [ORIGIN, MIRROR]) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) continue; + const json = await res.json(); + writeFileSync(SNAPSHOT, `${JSON.stringify(json, null, 2)}\n`); + return json; + } catch { + /* try the next source */ + } + } + fail(`could not refresh from ${MIRROR} or ${ORIGIN}`); +} + +/** Flatten nested numbers into dotted keys, ignoring $comment / rationale prose. */ +function flatten(obj, prefix = "") { + return Object.entries(obj).flatMap(([k, v]) => { + if (k.startsWith("$")) return []; + const key = `${prefix}${k}`; + if (v && typeof v === "object" && !Array.isArray(v)) return flatten(v, `${key}.`); + if (v === null) return []; + return [[key, v]]; + }); +} + +/* ── 2. renderers ────────────────────────────────────────────────────────── */ + +/** + * How a key becomes text. Default is the bare value. + * + * A marker may carry an `@modifier` — `` — which + * selects a renderer without changing which number is looked up. The modifier + * is what makes a key reusable: the same mcp.tools appears as a shields badge + * at the top of a README and as a bare "19 tools" in a table two screens down, + * and one marker still keeps the badge URL, its alt text and the label in step. + * + * Renderers are registered under the FULL marker name so a badge's label is + * written out rather than guessed from the key. + */ +const badge = (label) => (n) => + `${n} ${label}`; + +const RENDER = { + "mcp.tools@badge": badge("tools"), + "models.totalVisible@badge": badge("models"), + "models.chatVisible@badge": badge("models"), +}; +const render = (marker, value) => (RENDER[marker] ?? String)(value); + +/** `mcp.tools@badge` looks up `mcp.tools`. Unmodified markers are unaffected. */ +const keyOf = (marker) => marker.split("@")[0]; + +/* ── 3. marker rewriting ─────────────────────────────────────────────────── */ + +const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const OPEN_ANY = //g; +const CLOSE_ANY = //g; + +/** Byte ranges of fenced code blocks — markers inside them are documentation. */ +function fencedRanges(text) { + const ranges = []; + const fence = /^(\s*)(`{3,}|~{3,})[^\n]*$/gm; + let open = null; + for (let m; (m = fence.exec(text)); ) { + if (open === null) open = m.index; + else { + ranges.push([open, m.index + m[0].length]); + open = null; + } + } + return ranges; +} + +function syncFile(file, numbers, problems) { + const before = readFileSync(file, "utf8"); + const rel = relative(ROOT, file); + const fenced = fencedRanges(before); + const inFence = (i) => fenced.some(([a, b]) => i >= a && i < b); + const known = new Map(numbers); + const used = new Set(); + + // Markers actually present, so a file is only ever rewritten for what it uses + // and an @modifier is carried through to the renderer verbatim. + const markers = new Set(); + // A marker naming a key that does not exist is an error, never a silent + // no-op: a typo'd marker would otherwise sit there looking synced forever. + for (const [re, shown] of [ + [OPEN_ANY, (n) => ``], + [CLOSE_ANY, (n) => ``], + ]) { + for (const m of before.matchAll(re)) { + if (inFence(m.index)) continue; + markers.add(m[1]); + if (!known.has(keyOf(m[1]))) problems.push(`${rel}: unknown key ${shown(m[1])}`); + } + } + + let after = before; + for (const marker of markers) { + const key = keyOf(marker); + if (!known.has(key)) continue; + const value = known.get(key); + const pair = new RegExp( + `()([\\s\\S]*?)()`, + "g", + ); + after = after.replace(pair, (whole, open, inner, close, offset) => { + if (inFence(offset)) return whole; + // Nesting means the closing tag of an inner marker would be consumed by + // the outer one. Refuse rather than produce mangled output. + if (/`, "g"))] + .filter((m) => !inFence(m.index)).length; + const closes = [...before.matchAll(new RegExp(``, "g"))] + .filter((m) => !inFence(m.index)).length; + if (opens !== closes) problems.push(`${rel}: unbalanced marker br:${marker} (${opens} open, ${closes} close)`); + } + + return { before, after, changed: before !== after, used }; +} + +/* ── 4. walk ─────────────────────────────────────────────────────────────── */ + +function* walk(dir) { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + // lstat, not stat: a symlinked directory is reached by its real path or not + // at all. blockrun's docs/ -> awesome-blockrun/docs is exactly the case that + // matters — following it would edit a submodule's files behind the skip + // below, and a link pointing at an ancestor would recurse forever. + const s = lstatSync(p); + if (s.isSymbolicLink()) continue; + if (s.isDirectory()) { + // A nested repo is a submodule or vendored checkout: it carries its own + // brand-numbers.json and syncs itself. Rewriting its markers from THIS + // repo's snapshot would dirty a submodule nobody asked us to touch, and + // would report drift that belongs to another repo's CI. + if (existsSync(join(p, ".git"))) continue; + yield* walk(p); + } else if (TEXT_EXT.has(extname(name))) yield p; + } +} + +function fail(msg) { + console.error(`brand-numbers: ${msg}`); + process.exit(1); +} + +/* ── 5. run ──────────────────────────────────────────────────────────────── */ + +const raw = await loadNumbers(); +const numbers = flatten(raw); +const problems = []; +const drifted = []; +const everUsed = new Set(); + +for (const file of walk(ROOT)) { + const { before, after, changed, used } = syncFile(file, numbers, problems); + used.forEach((k) => everUsed.add(k)); + if (!changed) continue; + drifted.push({ file: relative(ROOT, file), before, after }); + if (!check) writeFileSync(file, after); +} + +if (problems.length) { + for (const p of problems) console.error(` ${p}`); + fail(`${problems.length} marker problem(s)`); +} + +if (check) { + if (drifted.length === 0) { + console.log(`brand-numbers: up to date (${everUsed.size} keys in use)`); + process.exit(0); + } + console.error("brand-numbers: these files disagree with brand-numbers.json\n"); + for (const { file, before, after } of drifted) { + const b = before.split("\n"); + const a = after.split("\n"); + for (let i = 0; i < Math.max(b.length, a.length); i++) { + if (b[i] !== a[i]) { + console.error(` ${file}:${i + 1}`); + console.error(` - ${(b[i] ?? "").trim()}`); + console.error(` + ${(a[i] ?? "").trim()}`); + } + } + } + console.error( + "\n fix with: node scripts/sync-brand-numbers.mjs && git commit -am 'chore: sync brand numbers'", + ); + process.exit(1); +} + +console.log( + drifted.length + ? `brand-numbers: updated ${drifted.length} file(s)` + : `brand-numbers: already up to date (${everUsed.size} keys in use)`, +); diff --git a/solana_fastpath_test.go b/solana_fastpath_test.go new file mode 100644 index 0000000..7466db5 --- /dev/null +++ b/solana_fastpath_test.go @@ -0,0 +1,229 @@ +package blockrun + +// Tests for the Solana payment fast path: hardcoded USDC mint info (no +// getAccountInfo round trip) and the per-endpoint blockhash cache (no +// getLatestBlockhash on the hot path). Mirrors the TS SDK optimization; the +// duplicate-transaction guard the TS SDK needs is deliberately absent here +// because every Go transaction carries a random 16-byte memo nonce, so two +// payments can never be byte-identical. + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/gagliardetto/solana-go" + "github.com/mr-tron/base58" +) + +// solanaRPCCounter serves getLatestBlockhash and getAccountInfo, counting calls. +type solanaRPCCounter struct { + blockhashCalls atomic.Int64 + mintCalls atomic.Int64 + blockhash string + mintDecimals uint8 +} + +func (c *solanaRPCCounter) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch req.Method { + case "getLatestBlockhash": + c.blockhashCalls.Add(1) + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":%q}}}`, c.blockhash) + case "getAccountInfo": + c.mintCalls.Add(1) + raw := make([]byte, 82) + raw[44] = c.mintDecimals + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":1,"result":{"value":{"owner":%q,"data":[%q,"base64"]}}}`, + tokenProgramAddress, base64.StdEncoding.EncodeToString(raw)) + default: + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"unexpected method %s"}}`, req.Method) + } + } +} + +func newRPCCounterServer(t *testing.T, decimals uint8) (*solanaRPCCounter, *httptest.Server) { + t.Helper() + counter := &solanaRPCCounter{blockhash: makeBlockhash(t).String(), mintDecimals: decimals} + srv := httptest.NewServer(counter.handler()) + t.Cleanup(srv.Close) + return counter, srv +} + +// resetSolanaBlockhashCacheForTest empties the package-level blockhash cache so +// each test starts cold. +func resetSolanaBlockhashCacheForTest(t *testing.T) { + t.Helper() + solanaBlockhashCache.mu.Lock() + solanaBlockhashCache.entries = map[string]solanaBlockhashEntry{} + solanaBlockhashCache.mu.Unlock() +} + +func testPaymentOption(asset string) *PaymentOption { + return &PaymentOption{ + Scheme: "exact", + Network: "solana", + Amount: "1000", + Asset: asset, + PayTo: solana.NewWallet().PublicKey().String(), + MaxTimeoutSeconds: 60, + Extra: map[string]any{"feePayer": solana.NewWallet().PublicKey().String()}, + } +} + +func testSolanaKey(t *testing.T) string { + t.Helper() + priv, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatalf("keygen: %v", err) + } + return base58.Encode(priv) +} + +// decodePaymentTx unwraps the base64 envelope down to the signed transaction. +func decodePaymentTx(t *testing.T, payload string) *solana.Transaction { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + t.Fatalf("decode envelope: %v", err) + } + var env struct { + Payload map[string]string `json:"payload"` + } + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + txBytes, err := base64.StdEncoding.DecodeString(env.Payload["transaction"]) + if err != nil { + t.Fatalf("decode tx: %v", err) + } + tx, err := solana.TransactionFromBytes(txBytes) + if err != nil { + t.Fatalf("parse tx: %v", err) + } + return tx +} + +// transferDecimals extracts the decimals byte from the TransferChecked +// instruction (index 2, last data byte). +func transferDecimals(t *testing.T, tx *solana.Transaction) uint8 { + t.Helper() + if len(tx.Message.Instructions) != 4 { + t.Fatalf("instructions = %d, want 4", len(tx.Message.Instructions)) + } + data := []byte(tx.Message.Instructions[2].Data) + if len(data) != 10 { + t.Fatalf("transfer data len = %d, want 10", len(data)) + } + return data[9] +} + +// TestUSDCPaymentSkipsMintRPC: paying in USDC must not fetch mint info over +// RPC — the mint's token program and decimals (6) are immutable and known. +func TestUSDCPaymentSkipsMintRPC(t *testing.T) { + resetSolanaBlockhashCacheForTest(t) + counter, srv := newRPCCounterServer(t, 99) // 99 would be visible if RPC were consulted + + payload, err := CreateSolanaPaymentPayload(testSolanaKey(t), testPaymentOption(USDCSolanaMainnet), "https://x", "", nil, srv.URL) + if err != nil { + t.Fatalf("CreateSolanaPaymentPayload: %v", err) + } + if got := counter.mintCalls.Load(); got != 0 { + t.Errorf("getAccountInfo calls = %d, want 0 (USDC mint info is hardcoded)", got) + } + if got := transferDecimals(t, decodePaymentTx(t, payload)); got != 6 { + t.Errorf("decimals = %d, want hardcoded 6", got) + } +} + +// TestNonUSDCPaymentFetchesMintInfo: any other mint still resolves its token +// program and decimals over RPC. +func TestNonUSDCPaymentFetchesMintInfo(t *testing.T) { + resetSolanaBlockhashCacheForTest(t) + counter, srv := newRPCCounterServer(t, 9) + otherMint := solana.NewWallet().PublicKey().String() + + payload, err := CreateSolanaPaymentPayload(testSolanaKey(t), testPaymentOption(otherMint), "https://x", "", nil, srv.URL) + if err != nil { + t.Fatalf("CreateSolanaPaymentPayload: %v", err) + } + if got := counter.mintCalls.Load(); got != 1 { + t.Errorf("getAccountInfo calls = %d, want 1", got) + } + if got := transferDecimals(t, decodePaymentTx(t, payload)); got != 9 { + t.Errorf("decimals = %d, want 9 from RPC", got) + } +} + +// TestBlockhashCachedWithinTTL: two payments inside the TTL share one +// getLatestBlockhash fetch. Safe despite identical blockhashes because the +// random memo nonce keeps the two transactions distinct. +func TestBlockhashCachedWithinTTL(t *testing.T) { + resetSolanaBlockhashCacheForTest(t) + counter, srv := newRPCCounterServer(t, 6) + key := testSolanaKey(t) + + for i := 0; i < 2; i++ { + if _, err := CreateSolanaPaymentPayload(key, testPaymentOption(USDCSolanaMainnet), "https://x", "", nil, srv.URL); err != nil { + t.Fatalf("payment %d: %v", i+1, err) + } + } + if got := counter.blockhashCalls.Load(); got != 1 { + t.Errorf("getLatestBlockhash calls = %d, want 1 (second payment served from cache)", got) + } +} + +// TestBlockhashRefetchedAfterTTL: an entry older than the TTL is refreshed. +func TestBlockhashRefetchedAfterTTL(t *testing.T) { + resetSolanaBlockhashCacheForTest(t) + counter, srv := newRPCCounterServer(t, 6) + key := testSolanaKey(t) + + base := time.Now() + solanaTimeNow = func() time.Time { return base } + defer func() { solanaTimeNow = time.Now }() + + if _, err := CreateSolanaPaymentPayload(key, testPaymentOption(USDCSolanaMainnet), "https://x", "", nil, srv.URL); err != nil { + t.Fatalf("payment 1: %v", err) + } + solanaTimeNow = func() time.Time { return base.Add(solanaBlockhashTTL + time.Second) } + if _, err := CreateSolanaPaymentPayload(key, testPaymentOption(USDCSolanaMainnet), "https://x", "", nil, srv.URL); err != nil { + t.Fatalf("payment 2: %v", err) + } + if got := counter.blockhashCalls.Load(); got != 2 { + t.Errorf("getLatestBlockhash calls = %d, want 2 (TTL expired)", got) + } +} + +// TestBlockhashCacheBounded: the cache never tracks more endpoints than its +// limit, so URL-churning callers cannot leak memory. +func TestBlockhashCacheBounded(t *testing.T) { + resetSolanaBlockhashCacheForTest(t) + _, srv := newRPCCounterServer(t, 6) + key := testSolanaKey(t) + + for i := 0; i < maxTrackedSolanaEndpoints+2; i++ { + url := fmt.Sprintf("%s/?endpoint=%d", srv.URL, i) + if _, err := CreateSolanaPaymentPayload(key, testPaymentOption(USDCSolanaMainnet), "https://x", "", nil, url); err != nil { + t.Fatalf("payment %d: %v", i+1, err) + } + } + solanaBlockhashCache.mu.Lock() + size := len(solanaBlockhashCache.entries) + solanaBlockhashCache.mu.Unlock() + if size > maxTrackedSolanaEndpoints { + t.Errorf("cache size = %d, want <= %d", size, maxTrackedSolanaEndpoints) + } +} diff --git a/solana_x402.go b/solana_x402.go index 4de11c4..b592af9 100644 --- a/solana_x402.go +++ b/solana_x402.go @@ -22,11 +22,105 @@ import ( "io" "net/http" "strconv" + "sync" "time" "github.com/gagliardetto/solana-go" ) +// --- Payment fast path ------------------------------------------------------- +// Two RPC round-trips used to sit serially on the critical path of EVERY +// Solana payment (each ~300ms typical, multi-second tail against the default +// proxy): getAccountInfo, to read the mint's token program and decimals, and +// getLatestBlockhash. Mirrors the TS SDK optimization (src/x402.ts). +// +// getAccountInfo is skipped for USDC: SPL Token fixes `decimals` in +// InitializeMint and ships no instruction to change it, and USDC's owner is +// the classic token program, so both values are immutable and known. +// getLatestBlockhash is cached per endpoint. +// +// Unlike the TS SDK, no duplicate-transaction guard is needed when a blockhash +// is reused: every transaction built here carries a random 16-byte memo nonce +// (see buildSignedSolanaExactTx), so two payments can never compile to the +// same bytes or the same ed25519 signature. + +const ( + // solanaBlockhashTTL bounds how stale a cached blockhash may be. A + // blockhash is valid for ~150 slots (~60s) and the default RPC proxy + // already caches getLatestBlockhash for 30s server-side, so a value can be + // 30s old on arrival; a 10s client TTL keeps the worst case at ~40s and + // leaves ~20s of settlement margin. + solanaBlockhashTTL = 10 * time.Second + + // maxTrackedSolanaEndpoints bounds the blockhash cache so a caller that + // builds a fresh RPC URL per request cannot grow it without limit. + maxTrackedSolanaEndpoints = 8 + + // usdcSolanaDecimals is USDC's immutable mint decimals. + usdcSolanaDecimals = 6 +) + +// solanaTimeNow is a test seam for the cache TTL. +var solanaTimeNow = time.Now + +type solanaBlockhashEntry struct { + blockhash solana.Hash + fetchedAt time.Time +} + +// solanaBlockhashCache holds the latest blockhash per RPC endpoint. Keyed by +// URL because endpoints genuinely differ on what "latest" is, and a client +// alternating between a primary and a fallback would otherwise evict the +// entry on every call. +var solanaBlockhashCache = struct { + mu sync.Mutex + entries map[string]solanaBlockhashEntry +}{entries: map[string]solanaBlockhashEntry{}} + +// cachedSolanaBlockhash returns a recent blockhash for rpcURL, fetching over +// RPC only when the cached entry is missing or older than the TTL. The lock is +// released during the fetch so concurrent payments on other endpoints are not +// serialized; concurrent misses on one endpoint may fetch twice, which is +// harmless (last write wins). +func cachedSolanaBlockhash(rpcURL string) (solana.Hash, error) { + now := solanaTimeNow() + solanaBlockhashCache.mu.Lock() + if e, ok := solanaBlockhashCache.entries[rpcURL]; ok && now.Sub(e.fetchedAt) < solanaBlockhashTTL { + solanaBlockhashCache.mu.Unlock() + return e.blockhash, nil + } + solanaBlockhashCache.mu.Unlock() + + hash, err := solanaLatestBlockhash(rpcURL) + if err != nil { + return solana.Hash{}, err + } + + solanaBlockhashCache.mu.Lock() + defer solanaBlockhashCache.mu.Unlock() + solanaBlockhashCache.entries[rpcURL] = solanaBlockhashEntry{blockhash: hash, fetchedAt: solanaTimeNow()} + for len(solanaBlockhashCache.entries) > maxTrackedSolanaEndpoints { + oldestKey := "" + var oldest time.Time + for k, e := range solanaBlockhashCache.entries { + if oldestKey == "" || e.fetchedAt.Before(oldest) { + oldestKey, oldest = k, e.fetchedAt + } + } + delete(solanaBlockhashCache.entries, oldestKey) + } + return hash, nil +} + +// resolveSolanaMintInfo returns the mint's token program and decimals, +// hardcoded for USDC and fetched over RPC for any other mint. +func resolveSolanaMintInfo(rpcURL, mint string) (solana.PublicKey, uint8, error) { + if mint == USDCSolanaMainnet { + return solana.MustPublicKeyFromBase58(tokenProgramAddress), usdcSolanaDecimals, nil + } + return solanaMintInfo(rpcURL, mint) +} + // solanaPaymentEnvelope is the x402 v2 PaymentPayload for the SVM exact scheme, // serialized with camelCase keys and base64-encoded into the payment header. // "accepted" echoes the fulfilled 402 requirement verbatim. @@ -78,13 +172,13 @@ func CreateSolanaPaymentPayload(bs58Key string, option *PaymentOption, resourceU return "", &PaymentError{Message: fmt.Sprintf("invalid amount %q: %v", option.Amount, err)} } - // Token program + decimals from the mint account (Token vs Token-2022). - tokenProgram, decimals, err := solanaMintInfo(rpcURL, option.Asset) + // Token program + decimals (Token vs Token-2022); hardcoded for USDC. + tokenProgram, decimals, err := resolveSolanaMintInfo(rpcURL, option.Asset) if err != nil { return "", &PaymentError{Message: fmt.Sprintf("failed to fetch mint info: %v", err)} } - blockhash, err := solanaLatestBlockhash(rpcURL) + blockhash, err := cachedSolanaBlockhash(rpcURL) if err != nil { return "", &PaymentError{Message: fmt.Sprintf("failed to fetch blockhash: %v", err)} }