From 201113687e24bfd2ff46f5ba930b503d162248fa Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 3 Aug 2026 22:44:49 -0500 Subject: [PATCH 1/2] fix(solana): allow paid GET endpoints for Solana clients doGetWithPayment guarded the 402 branch on `bc.privateKey == nil`, but Solana clients leave privateKey nil by design and sign with solanaKey (as the baseClient doc comment states). Every paid GET surface therefore failed for Solana users with a misleading "no wallet is configured" before signing was ever attempted: dex, market (3 call sites), prediction market, defi and surf. POST paths use a different code path, which is why this went unnoticed. Replace the check with hasWallet(), which resolves the expected key per chain. Closes #8 --- base_client.go | 17 ++++++- paid_get_test.go | 115 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 paid_get_test.go diff --git a/base_client.go b/base_client.go index 4bb09ba..bbc5f15 100644 --- a/base_client.go +++ b/base_client.go @@ -47,6 +47,17 @@ const chainSolana = "solana" // isSolana reports whether this client pays on Solana. func (bc *baseClient) isSolana() bool { return bc.chain == chainSolana } +// hasWallet reports whether a signing key is configured for the resolved chain. +// Base signs with privateKey (secp256k1); Solana signs with solanaKey (ed25519) +// and leaves privateKey nil, so a privateKey-only check silently locks Solana +// clients out of every paid endpoint. +func (bc *baseClient) hasWallet() bool { + if bc.isSolana() { + return bc.solanaKey != "" + } + return bc.privateKey != nil +} + // newBaseClient creates a new baseClient with the given private key, API URL, and timeout. // // If privateKey is empty, it checks BLOCKRUN_WALLET_KEY then BASE_CHAIN_WALLET_KEY env vars. @@ -356,7 +367,11 @@ func (bc *baseClient) doGetWithPayment(ctx context.Context, endpoint string, que defer resp.Body.Close() if resp.StatusCode == http.StatusPaymentRequired { - if bc.privateKey == nil { + // Check for ANY signing key, not just the Base one: Solana clients + // leave privateKey nil by design and sign with solanaKey instead + // (see the baseClient doc comment), so guarding on privateKey alone + // rejected every paid GET for Solana before signing was attempted. + if !bc.hasWallet() { return nil, &PaymentError{Message: "endpoint returned 402 but no wallet is configured"} } return bc.handleGetPaymentAndRetry(ctx, url, resp) diff --git a/paid_get_test.go b/paid_get_test.go new file mode 100644 index 0000000..2b02eca --- /dev/null +++ b/paid_get_test.go @@ -0,0 +1,115 @@ +package blockrun + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gagliardetto/solana-go" +) + +// paidGetServer serves a 402 on the first GET and 200 once a PAYMENT-SIGNATURE +// is presented, recording the signature it saw. +func paidGetServer(t *testing.T, opt PaymentOption) (*httptest.Server, *string) { + t.Helper() + var sawSignature string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if sig := r.Header.Get("PAYMENT-SIGNATURE"); sig != "" { + sawSignature = sig + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + return + } + req := PaymentRequirement{ + X402Version: 2, + Accepts: []PaymentOption{opt}, + Resource: ResourceInfo{URL: "https://example.test/resource"}, + } + body, err := json.Marshal(req) + if err != nil { + t.Errorf("marshal payment requirement: %v", err) + } + w.Header().Set("payment-required", base64.StdEncoding.EncodeToString(body)) + w.WriteHeader(http.StatusPaymentRequired) + })) + t.Cleanup(srv.Close) + return srv, &sawSignature +} + +// solanaPaidGetOption is a 402 requirement that needs zero RPC to sign: USDC +// (mint info hardcoded) plus a server-provided blockhash. +func solanaPaidGetOption(t *testing.T) PaymentOption { + t.Helper() + return PaymentOption{ + Scheme: "exact", + Network: "solana", + Amount: "1000", + Asset: USDCSolanaMainnet, + PayTo: solana.NewWallet().PublicKey().String(), + MaxTimeoutSeconds: 60, + Extra: map[string]any{ + "feePayer": solana.NewWallet().PublicKey().String(), + "recentBlockhash": makeBlockhash(t).String(), + }, + } +} + +// TestPaidGetSolanaSignsInsteadOfRejecting pins the regression that a Solana +// client can pay for GET endpoints. +// +// doGetWithPayment used to guard the 402 branch on `bc.privateKey == nil`, but +// Solana clients leave privateKey nil by design and sign with solanaKey. That +// rejected every paid GET (dex, market, prediction market, defi, surf) with a +// misleading "no wallet is configured" before signing was ever attempted. +func TestPaidGetSolanaSignsInsteadOfRejecting(t *testing.T) { + srv, sawSignature := paidGetServer(t, solanaPaidGetOption(t)) + + bc := &baseClient{ + chain: chainSolana, + solanaKey: testSolanaKey(t), + apiURL: srv.URL, + httpClient: srv.Client(), + } + + body, err := bc.doGetWithPayment(context.Background(), "/v1/paid", nil) + if err != nil { + t.Fatalf("paid GET failed for a Solana client with a configured wallet: %v", err) + } + if string(body) != `{"ok":true}` { + t.Errorf("body = %s, want the paid response", body) + } + if *sawSignature == "" { + t.Fatal("server never saw a PAYMENT-SIGNATURE, so the client did not sign") + } + // The signature must be a real SVM exact-scheme envelope, not a stub. + if got := decodePaymentTx(t, *sawSignature).Message.RecentBlockhash; got.IsZero() { + t.Error("signed transaction carries a zero blockhash") + } +} + +// TestPaidGetWithoutWalletStillRejected pins that the guard still fires when no +// signing key is configured on either chain. +func TestPaidGetWithoutWalletStillRejected(t *testing.T) { + for name, bc := range map[string]*baseClient{ + "solana without solanaKey": {chain: chainSolana}, + "base without privateKey": {}, + } { + t.Run(name, func(t *testing.T) { + srv, _ := paidGetServer(t, solanaPaidGetOption(t)) + bc.apiURL = srv.URL + bc.httpClient = srv.Client() + + _, err := bc.doGetWithPayment(context.Background(), "/v1/paid", nil) + if err == nil { + t.Fatal("expected a 402 rejection when no wallet is configured") + } + if !strings.Contains(err.Error(), "no wallet is configured") { + t.Errorf("error = %v, want it to mention no wallet configured", err) + } + }) + } +} From aeb4fe410c0eff670944184f6633e8aaa4e4ff50 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 3 Aug 2026 23:01:55 -0500 Subject: [PATCH 2/2] test(solana): make the paid-GET test hermetic and cover the Base branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new test signed against production Solana RPC on every run. The baseClient literal left solanaRPCURL empty, so CreateSolanaPaymentPayload fell back to DefaultSolanaRPCURL and cachedSolanaBlockhash issued a live request to sol.blockrun.ai. Proven by running with egress blocked: failed to fetch blockhash: Post "https://sol.blockrun.ai/api/v1/solana/rpc" The recentBlockhash in Extra was dead data: server-provided blockhash is d73f4a7, which is not an ancestor of this branch, so nothing here reads option.Extra["recentBlockhash"]. The helper comment claiming "zero RPC to sign" was false, and that claim is what let the network call through. Pin solanaRPCURL to newRPCCounterServer instead, and assert the signed transaction carries the blockhash that fake served rather than merely being non-zero, so the check fails on a stub as well as on a zero hash. Add TestPaidGetBaseSignsInsteadOfRejecting. hasWallet is the gate on the money path and only three of its four quadrants were covered; mutating the Base branch to `return false` — which breaks every paid GET on the SDK's default chain — left the whole suite green. Both branches are now mutation-covered: each mutation fails its own test and nothing else. Also reset the package-level blockhash cache, matching every other Solana test, and reuse testPaymentOption instead of duplicating it. Full suite passes with network fully blocked, -race -count=2 -shuffle=on. --- paid_get_test.go | 87 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/paid_get_test.go b/paid_get_test.go index 2b02eca..cae0e50 100644 --- a/paid_get_test.go +++ b/paid_get_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/ethereum/go-ethereum/crypto" "github.com/gagliardetto/solana-go" ) @@ -40,39 +41,27 @@ func paidGetServer(t *testing.T, opt PaymentOption) (*httptest.Server, *string) return srv, &sawSignature } -// solanaPaidGetOption is a 402 requirement that needs zero RPC to sign: USDC -// (mint info hardcoded) plus a server-provided blockhash. -func solanaPaidGetOption(t *testing.T) PaymentOption { - t.Helper() - return PaymentOption{ - Scheme: "exact", - Network: "solana", - Amount: "1000", - Asset: USDCSolanaMainnet, - PayTo: solana.NewWallet().PublicKey().String(), - MaxTimeoutSeconds: 60, - Extra: map[string]any{ - "feePayer": solana.NewWallet().PublicKey().String(), - "recentBlockhash": makeBlockhash(t).String(), - }, - } -} - // TestPaidGetSolanaSignsInsteadOfRejecting pins the regression that a Solana // client can pay for GET endpoints. // // doGetWithPayment used to guard the 402 branch on `bc.privateKey == nil`, but // Solana clients leave privateKey nil by design and sign with solanaKey. That -// rejected every paid GET (dex, market, prediction market, defi, surf) with a +// rejected every paid GET (dex, market, prediction market, defi) with a // misleading "no wallet is configured" before signing was ever attempted. +// +// solanaRPCURL is pinned to a local fake so signing touches no network: USDC +// mint info is hardcoded and the blockhash comes from the fake. func TestPaidGetSolanaSignsInsteadOfRejecting(t *testing.T) { - srv, sawSignature := paidGetServer(t, solanaPaidGetOption(t)) + resetSolanaBlockhashCacheForTest(t) + counter, rpcSrv := newRPCCounterServer(t, usdcSolanaDecimals) + srv, sawSignature := paidGetServer(t, *testPaymentOption(USDCSolanaMainnet)) bc := &baseClient{ - chain: chainSolana, - solanaKey: testSolanaKey(t), - apiURL: srv.URL, - httpClient: srv.Client(), + chain: chainSolana, + solanaKey: testSolanaKey(t), + solanaRPCURL: rpcSrv.URL, + apiURL: srv.URL, + httpClient: srv.Client(), } body, err := bc.doGetWithPayment(context.Background(), "/v1/paid", nil) @@ -85,9 +74,51 @@ func TestPaidGetSolanaSignsInsteadOfRejecting(t *testing.T) { if *sawSignature == "" { t.Fatal("server never saw a PAYMENT-SIGNATURE, so the client did not sign") } - // The signature must be a real SVM exact-scheme envelope, not a stub. - if got := decodePaymentTx(t, *sawSignature).Message.RecentBlockhash; got.IsZero() { - t.Error("signed transaction carries a zero blockhash") + // A real SVM exact-scheme envelope carries the blockhash the RPC served, + // so this fails on a stub as well as on a zero hash. + want := solana.MustHashFromBase58(counter.blockhash) + if got := decodePaymentTx(t, *sawSignature).Message.RecentBlockhash; !got.Equals(want) { + t.Errorf("blockhash = %s, want %s from the RPC", got, want) + } + if got := bc.GetSpending(); got.Calls != 1 || got.TotalUSD != 0.001 { + t.Errorf("spending = %+v, want 1 call totalling $0.001", got) + } +} + +// TestPaidGetBaseSignsInsteadOfRejecting pins the other half of hasWallet: a +// Base client with a privateKey must still pay for a GET. Without it, breaking +// the Base branch of hasWallet leaves the suite green even though every paid +// GET on the SDK's default chain would fail. +func TestPaidGetBaseSignsInsteadOfRejecting(t *testing.T) { + key, err := crypto.HexToECDSA(strings.TrimPrefix(testPrivateKey, "0x")) + if err != nil { + t.Fatalf("parse test key: %v", err) + } + srv, sawSignature := paidGetServer(t, PaymentOption{ + Scheme: "exact", + Network: "base", + Amount: "1000", + Asset: USDCBase, + PayTo: "0x1234567890123456789012345678901234567890", + MaxTimeoutSeconds: 300, + }) + + bc := &baseClient{ + privateKey: key, + address: crypto.PubkeyToAddress(key.PublicKey).Hex(), + apiURL: srv.URL, + httpClient: srv.Client(), + } + + body, err := bc.doGetWithPayment(context.Background(), "/v1/paid", nil) + if err != nil { + t.Fatalf("paid GET failed for a Base client with a configured wallet: %v", err) + } + if string(body) != `{"ok":true}` { + t.Errorf("body = %s, want the paid response", body) + } + if *sawSignature == "" { + t.Fatal("server never saw a PAYMENT-SIGNATURE, so the client did not sign") } } @@ -99,7 +130,7 @@ func TestPaidGetWithoutWalletStillRejected(t *testing.T) { "base without privateKey": {}, } { t.Run(name, func(t *testing.T) { - srv, _ := paidGetServer(t, solanaPaidGetOption(t)) + srv, _ := paidGetServer(t, *testPaymentOption(USDCSolanaMainnet)) bc.apiURL = srv.URL bc.httpClient = srv.Client()