Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.19.2
0.19.3
14 changes: 12 additions & 2 deletions base_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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,
Expand Down
51 changes: 47 additions & 4 deletions solana_x402.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}
}
Expand Down Expand Up @@ -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)
Expand Down
198 changes: 198 additions & 0 deletions solana_x402_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
}
Loading