diff --git a/CHANGELOG.md b/CHANGELOG.md index 51be1e3..92ef1d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to blockrun-llm-go will be documented in this file. +## 0.19.3 + +- **perf(solana): zero client-side RPC on the payment path.** Implements the + client half of the x402 SVM spec fix (x402-foundation/x402#2693): when the 402 + requirement carries `extra.recentBlockhash`, the signer uses it instead of + calling `getLatestBlockhash`. Combined with 0.19.1's hardcoded USDC mint info, + a USDC payment against a spec-aware gateway (sol.blockrun.ai serves the field + as of 2026-08-04) now makes **no RPC calls at all** — and the transaction is + pinned to a blockhash the settling RPC has already observed, which is fresher + than the 10s client cache and shrinks the blockhash-expiry settle retry tail. + Servers that omit the field fall back to the cached RPC fetch unchanged, as do + malformed and all-zero values. +- **fix(solana): async poll re-signs always fetch a fresh blockhash.** + `pollPaymentPayload` re-signs long image/video jobs every 30s precisely to + escape blockhash expiry, but it reuses one `PaymentOption` — so honoring + `extra.recentBlockhash` there would have re-signed with the same expiring hash + and failed at settlement. The fast path is now gated to the submit path only. + ## 0.19.2 - **fix(solana): paid GET endpoints work for Solana clients.** `doGetWithPayment` diff --git a/README.md b/README.md index 4524d24..ccefafe 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,8 @@ img, _ := blockrun.NewImageClientSolana("", "") Signatures are `func NewXClientSolana(privateKey, rpcURL string, opts ...XClientOption)`. `privateKey` is a bs58 Solana key (empty → `SOLANA_WALLET_KEY` → `~/.*/solana-wallet.json` -→ `~/.blockrun/.solana-session`); `rpcURL` fetches the blockhash + mint info (empty → +→ `~/.blockrun/.solana-session`); `rpcURL` fetches mint info for non-USDC assets, plus the +blockhash when the 402 requirement does not carry one (empty → `SOLANA_RPC_URL` → BlockRun's free proxy). Payment is the x402 **SVM "exact" scheme**: a locally ed25519-signed `TransferChecked` USDC transaction that BlockRun's facilitator co-signs (gasless) and settles. Constructors: `NewLLMClientSolana`, diff --git a/VERSION b/VERSION index 61e6e92..b72b05e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.2 +0.19.3 diff --git a/base_client.go b/base_client.go index bbc5f15..ad6bbdb 100644 --- a/base_client.go +++ b/base_client.go @@ -189,7 +189,10 @@ func (bc *baseClient) pollPaymentPayload(current string, lastSigned time.Time, o if !bc.isSolana() || time.Since(lastSigned) < solanaPollResignInterval { return current, lastSigned } - fresh, err := bc.createPaymentPayload(option, resourceURL, description, extensions) + // allowServerBlockhash=false: the 402's extra.recentBlockhash is pinned to + // submit time, so reusing it here would re-sign with the same expiring hash + // and defeat the re-sign. Force a fresh one. + fresh, err := bc.signPayment(option, resourceURL, description, extensions, false) if err != nil { return current, lastSigned } @@ -200,8 +203,15 @@ func (bc *baseClient) pollPaymentPayload(current string, lastSigned time.Time, o // EIP-712 (secp256k1); Solana uses the SVM exact scheme (ed25519). This is the // single signing entry point shared by every payment retry path. func (bc *baseClient) createPaymentPayload(option *PaymentOption, resourceURL, description string, extensions map[string]any) (string, error) { + return bc.signPayment(option, resourceURL, description, extensions, true) +} + +// signPayment is createPaymentPayload with control over the Solana +// server-provided-blockhash fast path (see createSolanaPaymentPayload). +// allowServerBlockhash is ignored on Base. +func (bc *baseClient) signPayment(option *PaymentOption, resourceURL, description string, extensions map[string]any, allowServerBlockhash bool) (string, error) { if bc.isSolana() { - return CreateSolanaPaymentPayload(bc.solanaKey, option, resourceURL, description, extensions, bc.solanaRPCURL) + return createSolanaPaymentPayload(bc.solanaKey, option, resourceURL, description, extensions, bc.solanaRPCURL, allowServerBlockhash) } return CreatePaymentPayload( bc.privateKey, diff --git a/solana_x402.go b/solana_x402.go index b592af9..00215f8 100644 --- a/solana_x402.go +++ b/solana_x402.go @@ -37,7 +37,9 @@ import ( // 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. +// getLatestBlockhash is skipped entirely when the 402 requirement carries +// extra.recentBlockhash (x402-foundation/x402#2693); the per-endpoint cache +// below is the fallback for servers that do not send one. // // 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 @@ -130,12 +132,40 @@ type solanaPaymentEnvelope struct { Accepted PaymentOption `json:"accepted"` } +// serverProvidedBlockhash extracts extra.recentBlockhash from a 402 payment +// requirement. Returns ok=false when the field is absent, not a valid +// base58-encoded 32-byte hash, or the all-zero hash — base58 decodes +// "111...1" successfully but it is never a real blockhash, and signing with +// it would produce a transaction that can never settle. +func serverProvidedBlockhash(extra map[string]any) (solana.Hash, bool) { + s, _ := extra["recentBlockhash"].(string) + if s == "" { + return solana.Hash{}, false + } + hash, err := solana.HashFromBase58(s) + if err != nil || hash.IsZero() { + return solana.Hash{}, false + } + return hash, true +} + // CreateSolanaPaymentPayload builds a signed x402 SVM exact-scheme payment // payload (base64) for the given 402 payment option. rpcURL (blockhash + mint // info) defaults to DefaultSolanaRPCURL when empty. resourceURL/description/ // extensions are accepted for signature parity with CreatePaymentPayload; the // SVM envelope does not carry them (matching the Python client). func CreateSolanaPaymentPayload(bs58Key string, option *PaymentOption, resourceURL, description string, extensions map[string]any, rpcURL string) (string, error) { + return createSolanaPaymentPayload(bs58Key, option, resourceURL, description, extensions, rpcURL, true) +} + +// createSolanaPaymentPayload implements CreateSolanaPaymentPayload with control +// over the server-provided-blockhash fast path. +// +// allowServerBlockhash MUST be false for async poll re-signs: extra.recentBlockhash +// is pinned to the moment the 402 was issued and expires within ~1-2 minutes, so +// honoring it on every re-sign would hand back the same expiring hash and defeat +// the whole point of re-signing (see baseClient.pollPaymentPayload). +func createSolanaPaymentPayload(bs58Key string, option *PaymentOption, resourceURL, description string, extensions map[string]any, rpcURL string, allowServerBlockhash bool) (string, error) { if option == nil { return "", &PaymentError{Message: "nil payment option"} } @@ -178,9 +208,22 @@ func CreateSolanaPaymentPayload(bs58Key string, option *PaymentOption, resourceU return "", &PaymentError{Message: fmt.Sprintf("failed to fetch mint info: %v", err)} } - blockhash, err := cachedSolanaBlockhash(rpcURL) - if err != nil { - return "", &PaymentError{Message: fmt.Sprintf("failed to fetch blockhash: %v", err)} + // Server-provided blockhash (x402-foundation/x402#2693): when the 402 + // requirement carries extra.recentBlockhash, sign with it — it is fresher + // than anything the client can cache AND pinned to an RPC view the settler + // has observed, and it removes the last client RPC from the payment path. + // Malformed values fall back to the RPC fetch rather than failing. + var blockhash solana.Hash + var ok bool + if allowServerBlockhash { + blockhash, ok = serverProvidedBlockhash(option.Extra) + } + if !ok { + var err error + blockhash, err = cachedSolanaBlockhash(rpcURL) + if err != nil { + return "", &PaymentError{Message: fmt.Sprintf("failed to fetch blockhash: %v", err)} + } } txBytes, err := buildSignedSolanaExactTx(priv, feePayer, payer, payTo, mint, tokenProgram, amount, decimals, blockhash) diff --git a/solana_x402_test.go b/solana_x402_test.go index 1195c92..d1a9468 100644 --- a/solana_x402_test.go +++ b/solana_x402_test.go @@ -3,8 +3,15 @@ package blockrun import ( "crypto/ed25519" "crypto/rand" + "encoding/base64" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" "testing" + "time" "github.com/gagliardetto/solana-go" "github.com/mr-tron/base58" @@ -220,3 +227,194 @@ func TestSolanaKeypairAndPublicKey(t *testing.T) { t.Fatalf("solanaKeypair(full): key len %d err %v", len(ed), err) } } + +// TestCreateSolanaPaymentPayloadUsesServerBlockhash pins the x402 spec change +// (x402-foundation/x402#2693): when the 402 requirement carries +// extra.recentBlockhash, the client MUST sign with it and make ZERO RPC calls. +func TestCreateSolanaPaymentPayloadUsesServerBlockhash(t *testing.T) { + priv, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatalf("keygen: %v", err) + } + serverHash := makeBlockhash(t) + + rpcCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rpcCalls++ + http.Error(w, "client must not call RPC when server provides blockhash", http.StatusTeapot) + })) + defer srv.Close() + resetSolanaBlockhashCacheForTest(t) + + opt := &PaymentOption{ + Scheme: "exact", + Network: "solana", + Amount: "1000", + Asset: USDCSolanaMainnet, // mint info hardcoded — no RPC for it + PayTo: solana.NewWallet().PublicKey().String(), + MaxTimeoutSeconds: 60, + Extra: map[string]any{ + "feePayer": solana.NewWallet().PublicKey().String(), + "recentBlockhash": serverHash.String(), + "lastValidBlockHeight": "123456789", + }, + } + + payload, err := CreateSolanaPaymentPayload(base58.Encode(priv), opt, "https://x/r", "", nil, srv.URL) + if err != nil { + t.Fatalf("CreateSolanaPaymentPayload: %v", err) + } + if rpcCalls != 0 { + t.Errorf("RPC calls = %d, want 0 (server-provided blockhash must be used)", rpcCalls) + } + + // The signed transaction must carry the server-provided blockhash. + raw, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + var env solanaPaymentEnvelope + 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) + } + if tx.Message.RecentBlockhash != serverHash { + t.Errorf("tx blockhash = %s, want server-provided %s", tx.Message.RecentBlockhash, serverHash) + } +} + +// TestCreateSolanaPaymentPayloadFallsBackWithoutServerBlockhash pins graceful +// degradation: an absent or malformed extra.recentBlockhash falls back to the +// RPC fetch instead of failing. +func TestCreateSolanaPaymentPayloadFallsBackWithoutServerBlockhash(t *testing.T) { + priv, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatalf("keygen: %v", err) + } + rpcHash := makeBlockhash(t) + + rpcCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rpcCalls++ + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":{"blockhash":%q,"lastValidBlockHeight":1}}}`, rpcHash.String()) + })) + defer srv.Close() + + for name, extra := range map[string]map[string]any{ + "absent": {"feePayer": solana.NewWallet().PublicKey().String()}, + "malformed": {"feePayer": solana.NewWallet().PublicKey().String(), "recentBlockhash": "not-base58!!"}, + // base58 decodes the all-zero hash fine, but it is never a real + // blockhash — signing with it would produce an unsettleable tx. + "zero hash": {"feePayer": solana.NewWallet().PublicKey().String(), "recentBlockhash": solana.Hash{}.String()}, + // Non-string JSON types must not panic or be coerced. + "wrong type": {"feePayer": solana.NewWallet().PublicKey().String(), "recentBlockhash": 12345}, + } { + t.Run(name, func(t *testing.T) { + resetSolanaBlockhashCacheForTest(t) + rpcCalls = 0 + opt := &PaymentOption{ + Scheme: "exact", + Network: "solana", + Amount: "1000", + Asset: USDCSolanaMainnet, + PayTo: solana.NewWallet().PublicKey().String(), + Extra: extra, + } + payload, err := CreateSolanaPaymentPayload(base58.Encode(priv), opt, "https://x/r", "", nil, srv.URL) + if err != nil { + t.Fatalf("CreateSolanaPaymentPayload: %v", err) + } + if rpcCalls == 0 { + t.Errorf("expected RPC fallback fetch, got 0 calls") + } + // The RPC-fetched hash must actually reach the signed transaction — + // asserting only "payload != \"\"" would still pass if the fetched + // value were dropped and a zero blockhash signed instead. + if got := decodePaymentTx(t, payload).Message.RecentBlockhash; got != rpcHash { + t.Errorf("tx blockhash = %s, want RPC-fetched %s", got, rpcHash) + } + }) + } +} + +// TestCreateSolanaPaymentPayloadBlockhashRPCFailure pins the error path this +// change re-wired: no server blockhash AND the RPC fetch fails. +func TestCreateSolanaPaymentPayloadBlockhashRPCFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "rpc down", http.StatusInternalServerError) + })) + defer srv.Close() + resetSolanaBlockhashCacheForTest(t) + + opt := testPaymentOption(USDCSolanaMainnet) // no recentBlockhash in Extra + _, err := CreateSolanaPaymentPayload(testSolanaKey(t), opt, "https://x/r", "", nil, srv.URL) + if err == nil { + t.Fatal("expected an error when the RPC fails and no server blockhash is provided") + } + if !strings.Contains(err.Error(), "failed to fetch blockhash") { + t.Errorf("error = %v, want it to mention the failed blockhash fetch", err) + } +} + +// TestPollPaymentPayloadForcesFreshBlockhash pins the regression that the +// server-provided-blockhash fast path must NOT leak into async poll re-signs. +// +// extra.recentBlockhash is pinned to the moment the 402 was issued and expires +// within ~1-2 minutes. pollPaymentPayload exists to re-sign long image/video +// jobs with a FRESH blockhash; if it honored the server value it would hand +// back the same expiring hash forever and the settling tx would be expired. +func TestPollPaymentPayloadForcesFreshBlockhash(t *testing.T) { + serverHash := makeBlockhash(t) + rpcHash := makeBlockhash(t) + + var rpcCalls atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rpcCalls.Add(1) + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":%q}}}`, rpcHash.String()) + })) + defer srv.Close() + resetSolanaBlockhashCacheForTest(t) + + bc := &baseClient{chain: chainSolana, solanaKey: testSolanaKey(t), solanaRPCURL: srv.URL} + opt := testPaymentOption(USDCSolanaMainnet) + opt.Extra["recentBlockhash"] = serverHash.String() + + // Submit-time signing keeps the zero-RPC fast path. + submitted, err := bc.createPaymentPayload(opt, "https://x/r", "", nil) + if err != nil { + t.Fatalf("createPaymentPayload: %v", err) + } + if got := decodePaymentTx(t, submitted).Message.RecentBlockhash; got != serverHash { + t.Errorf("submit-time blockhash = %s, want server-provided %s", got, serverHash) + } + if n := rpcCalls.Load(); n != 0 { + t.Errorf("submit-time RPC calls = %d, want 0", n) + } + + // A re-sign past the interval must fetch a fresh blockhash instead. + stale := time.Now().Add(-2 * solanaPollResignInterval) + resigned, lastSigned := bc.pollPaymentPayload(submitted, stale, opt, "https://x/r", "", nil) + if resigned == submitted { + t.Fatal("poll did not re-sign past the resign interval") + } + if lastSigned.Equal(stale) { + t.Error("lastSigned was not advanced after a re-sign") + } + got := decodePaymentTx(t, resigned).Message.RecentBlockhash + if got == serverHash { + t.Error("re-sign reused the expiring server-provided blockhash; it must fetch a fresh one") + } + if got != rpcHash { + t.Errorf("re-signed blockhash = %s, want freshly fetched %s", got, rpcHash) + } + if n := rpcCalls.Load(); n == 0 { + t.Error("re-sign made no RPC call, so it cannot have gotten a fresh blockhash") + } +}