Skip to content

fix: hold the no-wallet guard at the signing choke point - #19

Merged
VickyXAI merged 1 commit into
mainfrom
fix/wallet-guard-all-paths
Aug 4, 2026
Merged

fix: hold the no-wallet guard at the signing choke point#19
VickyXAI merged 1 commit into
mainfrom
fix/wallet-guard-all-paths

Conversation

@VickyXAI

@VickyXAI VickyXAI commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closes #15.

What

#9 introduced hasWallet() to stop a keyless client reaching the signer, then applied it to exactly one of six 402 entry points. doRequestHeaders, handleStreamPaymentAndRetry, image.go, video.go and rpc.go all call the signer directly.

On Base that means CreatePaymentPayload dereferences a nil *ecdsa.PrivateKey at crypto.PubkeyToAddress(privateKey.PublicKey) and panics the caller's goroutine instead of returning the PaymentError the GET path returns.

This is not theoretical. Removing the guard added here reproduces it:

panic: runtime error: invalid memory address or nil pointer dereference

Severity

Latent, not live. The exported constructors always set a key — newBaseClient errors without one, newSolanaBaseClient errors without one — so the invariant currently holds by accident. Moving it to signPayment, which every payment path funnels through, makes it hold by construction. One line covers all six entry points instead of one.

Why the call-site check stays

doGetWithPayment keeps its own check. Bailing there skips parsing the 402 body, so a keyless client gets no wallet is configured rather than whatever parse error a malformed 402 produces first. Deliberate, and commented as such: signPayment holds the invariant, the call site holds the message.

Testing

  • TestSignPaymentRejectsKeylessClient — both chains, asserts a *PaymentError with the right message and an empty payload. A panic fails the test outright, so it pins "returns an error" and "does not panic" together.
  • TestCreatePaymentPayloadRejectsKeylessClient — the exported wrapper inherits the guard.
  • Mutation-checked: removing the guard panics with the nil deref above.

Full suite green with -race -count=2 -shuffle=on, no network. go vet clean.

VERSION/CHANGELOG untouched — left for the release.

hasWallet() was introduced in #9 to stop a keyless client reaching the
signer, then applied to exactly one of six 402 entry points.
doRequestHeaders, handleStreamPaymentAndRetry, image.go, video.go and
rpc.go all called the signer directly.

On Base that means CreatePaymentPayload dereferences a nil
*ecdsa.PrivateKey at x402.go's crypto.PubkeyToAddress and panics the
caller's goroutine instead of returning the PaymentError the GET path
returns. Removing the new guard reproduces it:

  panic: runtime error: invalid memory address or nil pointer dereference

Latent, not live: the exported constructors always set a key, so the
invariant held by accident. Moving it to signPayment — which every
payment path funnels through — makes it hold by construction, and one
line covers all six entry points.

The doGetWithPayment call-site check stays. Bailing there skips parsing
the 402 body, so a keyless client gets "no wallet is configured" rather
than whatever parse error a malformed 402 produces first. signPayment
holds the invariant; the call site holds the message.

Closes #15.
@VickyXAI
VickyXAI merged commit 182175f into main Aug 4, 2026
1 check passed
@VickyXAI
VickyXAI deleted the fix/wallet-guard-all-paths branch August 4, 2026 04:55
VickyXAI pushed a commit that referenced this pull request Aug 4, 2026
Ships the no-wallet guard hoist from #19 (closes #15). Also on main since
0.19.4, neither shipping library code: #20 corrects the README FAQ that
told Solana users the Go SDK was Base-only, and #21 adds the release gate
that flagged this very commit as missing.

First release caught by that gate rather than by a human noticing the tag
had fallen behind.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hasWallet() guards 1 of 6 payment entry points — the others nil-deref on Base

1 participant