From 163e317bd44dd8c1ba8f7c0ef4f4a9f9fbc0f3c2 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 27 Jul 2026 16:01:34 -0700 Subject: [PATCH 1/2] feat: consume the published brand-numbers artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marketing numbers here were hand-maintained and had drifted from the catalog. They now regenerate from blockrun.ai/brand/numbers.json via markers, and CI fails offline against a committed snapshot when they disagree. scripts/sync-brand-numbers.mjs is vendored byte-for-byte from BlockRunAI/blockrun:brand/ — a package would mean a dependency bump in every repo, and several consumers have no package manifest at all. blockrun CI compares the copies, so this one cannot quietly fall behind. Frontmatter takes literals rather than markers: an HTML comment there is part of the YAML string, not invisible. --- .github/workflows/brand-numbers.yml | 24 +++ README.md | 10 +- brand-numbers.json | 34 ++++ scripts/sync-brand-numbers.mjs | 270 ++++++++++++++++++++++++++++ 4 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/brand-numbers.yml create mode 100644 brand-numbers.json create mode 100644 scripts/sync-brand-numbers.mjs 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/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/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)`, +); From 67faeeb4b1e088f4bf88eb45d81f4df1fe1a3b75 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 27 Jul 2026 23:45:04 -0700 Subject: [PATCH 2/2] perf(solana): drop both RPC round-trips from the x402 payment critical path (0.19.1) USDC mint info (token program + decimals=6) is immutable, so getAccountInfo is skipped for USDC; getLatestBlockhash is cached per RPC endpoint with a 10s TTL, bounded to 8 endpoints. No duplicate-transaction guard is needed on blockhash reuse (unlike the TS SDK): every transaction carries a random 16-byte memo nonce, so payments are never byte-identical. Paid benchmark (gpt-4o-mini via sol.blockrun.ai, n=6): median 2.16s -> 1.72s, mean 3.57s -> 1.74s, max 9.68s -> 2.31s. --- CHANGELOG.md | 14 +++ VERSION | 2 +- solana_fastpath_test.go | 229 ++++++++++++++++++++++++++++++++++++++++ solana_x402.go | 100 +++++++++++++++++- 4 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 solana_fastpath_test.go 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/VERSION b/VERSION index 1cf0537..41915c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.0 +0.19.1 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)} }