diff --git a/.gitignore b/.gitignore index a170fb4f7..231eacb8c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ harness/go-client/go-client mpp-sdk-self-learning/ .build/ go/coverage.out +notes/codex-review-*.md +notes/codex-review/ diff --git a/go/x402/README.md b/go/x402/README.md new file mode 100644 index 000000000..0f46edabd --- /dev/null +++ b/go/x402/README.md @@ -0,0 +1,81 @@ +# Go x402 SDK + +Go implementation of the x402 `exact` scheme (client + server) for Solana. + +This sub-package mirrors the canonical Rust spine at `rust/crates/x402/` +and ships the interop adapters used by the cross-language harness. + +## Layout + +```text +go/x402/ +├── cmd/ +│ ├── interop-client/ interop harness client binary +│ └── interop-server/ interop harness server binary +└── README.md +``` + +The exact-scheme protocol types, verifier, and settler live inline in +the two `main.go` files. The Rust crate keeps a separate +`protocol/schemes/exact/`, `server/exact.rs`, `client/exact/payment.rs` +split; the Go port keeps them inline because both binaries are +self-contained and there is no third caller. The spine's wire format, +constants, and pipeline ordering are mirrored 1:1. + +## Test + +```bash +cd go +go test ./x402/... -cover -race +``` + +Expected coverage: server ≥ 90 %, client ≥ 90 %. + +## Format and vet + +```bash +gofmt -l go/x402/ +go vet ./x402/... +``` + +## Parity with the Rust spine + +The Go port matches `rust/crates/x402/` on: + +- CAIP-2 network identifiers (`solana:5eykt...`, `solana:EtWTR...`, + `solana:4uhc...`) — verbatim. +- Program IDs (Token, Token-2022, Associated Token, Compute Budget, + System, Memo, Lighthouse) — verbatim. +- Stablecoin mint addresses per network (USDC/USDT/USDG/PYUSD/CASH) — + verbatim. +- Constants: `EXACT_SCHEME = "exact"`, `maxMemoBytes = 256`. +- Instruction allowlist: ComputeBudget (Set CU Limit + Price), SPL + Token / Token-2022 `TransferChecked`, plus optional Lighthouse + + Memo + ATA-create. +- Lighthouse passthrough by program-ID match only (no discriminator + allowlist, no account-count cap) — spine parity. +- Fee-payer-in-instruction-accounts sweep with the legitimate + ATA-create payer slot exception. +- Destination ATA re-derived from `(payTo, mint, tokenProgram)` and + compared against the transaction's destination index. +- L8 settlement ordering: broadcast → confirm → mark. +- Cross-server credential rejection with canonical 4xx + token in body. +- Env-var contract: `X402_INTEROP_TARGET_URL`, + `X402_INTEROP_RPC_URL`, `X402_INTEROP_NETWORK`, + `X402_INTEROP_CLIENT_SECRET_KEY`, + `X402_INTEROP_FACILITATOR_SECRET_KEY`, `X402_INTEROP_PAY_TO`, + `X402_INTEROP_MINT`, `X402_INTEROP_PRICE`, + `X402_INTEROP_EXTRA_OFFERED_MINTS` (CSV), + `X402_INTEROP_PREFER_CURRENCIES` (CSV). +- Client `result` and server `ready` stdout JSON shapes. + +Intentional Go-side specifics (not divergences): + +- Mint alias resolution happens at the env-read boundary + (`X402_INTEROP_MINT` may be a symbol or base58); the rest of the + code sees canonical base58. The spine accepts the same pattern. +- Duplicate-settlement cache keys are SHA-256 of the encoded + transaction, in addition to Solana's native per-signature + uniqueness — defense-in-depth, matches the upstream reference. + +No upstream behavior changes vs the reference port (tip `e3bf746`). diff --git a/go/x402/cmd/interop-client/challenge_test.go b/go/x402/cmd/interop-client/challenge_test.go new file mode 100644 index 000000000..6d22442c6 --- /dev/null +++ b/go/x402/cmd/interop-client/challenge_test.go @@ -0,0 +1,1369 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/gagliardetto/solana-go" +) + +func TestSelectSVMRequirementFromPaymentRequiredHeader(t *testing.T) { + requirement := map[string]any{ + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + } + envelope, err := json.Marshal(map[string]any{ + "x402Version": 2, + "accepts": []map[string]any{requirement}, + }) + if err != nil { + t.Fatal(err) + } + + selected := selectSVMRequirement( + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(envelope)}, + "", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Asset != requirement["asset"] { + t.Fatalf("unexpected asset: %s", selected.Asset) + } +} + +func TestSelectSVMRequirementFromBody(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x0000000000000000000000000000000000000000", + "amount": "1000", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected := selectSVMRequirement( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Network != "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" { + t.Fatalf("unexpected network: %s", selected.Network) + } +} + +func TestSelectSVMRequirementIgnoresUnsupportedScheme(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "upto", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected := selectSVMRequirement( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + ) + + if selected != nil { + t.Fatalf("expected no selected requirement, got %+v", selected) + } +} + +func TestSelectSVMRequirementSupportsRequestedUptoScheme(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "upto", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected := selectSVMRequirement( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "upto", + ) + + if selected == nil { + t.Fatal("expected selected upto requirement") + } + if selected.Scheme != "upto" { + t.Fatalf("unexpected scheme: %s", selected.Scheme) + } +} + +func TestSelectSVMChallengeHonorsPreferredCurrencyOrder(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, _ := selectSVMChallengeWithPreferences( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + []string{"PYUSD", "USDC"}, + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected PYUSD mint, got %s", selected.Asset) + } +} + +func TestSelectSVMChallengeReturnsNilWhenPreferredCurrenciesDoNotMatch(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, _ := selectSVMChallengeWithPreferences( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + []string{"PYUSD"}, + ) + + if selected != nil { + t.Fatalf("expected no selected requirement, got %+v", selected) + } +} + +func TestSelectSVMChallengeChecksBodyWhenHeaderPreferencesDoNotMatch(t *testing.T) { + headerEnvelope, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/body"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, resource := selectSVMChallengeWithPreferences( + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + []string{"PYUSD"}, + ) + + if selected == nil { + t.Fatal("expected selected requirement from body") + } + if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected body PYUSD mint, got %s", selected.Asset) + } + if resource["uri"] != "/body" { + t.Fatalf("expected body resource, got %#v", resource) + } +} + +func TestSelectSVMChallengeWithoutPreferencesPicksCheapestAmount(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000000", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "So11111111111111111111111111111111111111112", + "amount": "5000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, _ := selectSVMChallengeWithPreferences( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + nil, + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Asset != "So11111111111111111111111111111111111111112" { + t.Fatalf("expected cheapest offer, got %s", selected.Asset) + } +} + +func TestSelectSVMChallengeSkipsIncompleteAndMalformedCandidates(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "", + "amount": "1", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "So11111111111111111111111111111111111111112", + "amount": "not-int", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, _ := selectSVMChallengeWithPreferences( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + nil, + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Asset != "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" { + t.Fatalf("expected valid cheapest candidate, got %+v", selected) + } +} + +func TestSelectSVMChallengeUsesCurrencyPreferencesFromEnv(t *testing.T) { + t.Setenv("X402_INTEROP_PREFER_CURRENCIES", " PYUSD, USDC ,,") + body, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/protected"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + { + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + "amount": "2000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, resource := selectSVMChallenge( + map[string]string{}, + string(body), + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "exact", + ) + + if selected == nil { + t.Fatal("expected selected requirement") + } + if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected PYUSD preference to win, got %s", selected.Asset) + } + if resource["uri"] != "/protected" { + t.Fatalf("expected resource to be returned, got %+v", resource) + } +} + +func TestPaymentRequiredLoadersRejectMalformedInputs(t *testing.T) { + if envelope := loadPaymentRequiredHeader(map[string]string{"payment-required": "not base64"}); envelope != nil { + t.Fatalf("expected invalid base64 header to return nil") + } + encodedInvalidJSON := base64.StdEncoding.EncodeToString([]byte("{")) + if envelope := loadPaymentRequiredHeader(map[string]string{"payment-required": encodedInvalidJSON}); envelope != nil { + t.Fatalf("expected invalid JSON header to return nil") + } + if envelope := loadPaymentRequiredBody("{"); envelope != nil { + t.Fatalf("expected invalid JSON body to return nil") + } + if envelope := loadPaymentRequiredBody(""); envelope != nil { + t.Fatalf("expected empty body to return nil") + } +} + +func TestResolveStablecoinMintCanonicalAliases(t *testing.T) { + tests := map[string]struct { + currency string + network string + want string + }{ + "devnet USD alias": { + currency: " usd ", + network: "devnet", + want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + }, + "mainnet PYUSD": { + currency: "PYUSD", + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + want: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + }, + "localnet USDG": { + currency: "USDG", + network: "localnet", + want: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", + }, + "USDT": { + currency: "USDT", + network: "devnet", + want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + }, + "CASH": { + currency: "CASH", + network: "devnet", + want: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH", + }, + "mint passthrough": { + currency: " So11111111111111111111111111111111111111112 ", + network: "devnet", + want: "So11111111111111111111111111111111111111112", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if got := resolveStablecoinMint(test.currency, test.network); got != test.want { + t.Fatalf("resolveStablecoinMint() = %q, want %q", got, test.want) + } + }) + } +} + +func TestRequirementExtraParsersValidateTypes(t *testing.T) { + requirement := paymentRequirement{ + Extra: map[string]any{ + "decimalsFloat": float64(6), + "decimalsText": "9", + "tokenProgram": solana.TokenProgramID.String(), + "badInteger": "not-int", + "badString": 12, + "emptyString": "", + }, + } + + if got, err := intFromRequirement(requirement, "decimalsFloat"); err != nil || got != 6 { + t.Fatalf("float integer = %d, %v", got, err) + } + if got, err := intFromRequirement(requirement, "decimalsText"); err != nil || got != 9 { + t.Fatalf("string integer = %d, %v", got, err) + } + if _, err := intFromRequirement(requirement, "missing"); err == nil { + t.Fatal("expected missing integer error") + } + if _, err := intFromRequirement(requirement, "badInteger"); err == nil { + t.Fatal("expected invalid integer error") + } + if _, err := intFromRequirement(paymentRequirement{Extra: map[string]any{"bad": true}}, "bad"); err == nil { + t.Fatal("expected invalid integer type error") + } + if got, err := stringFromExtra(requirement, "tokenProgram"); err != nil || got != solana.TokenProgramID.String() { + t.Fatalf("string extra = %q, %v", got, err) + } + if _, err := stringFromExtra(requirement, "missing"); err == nil { + t.Fatal("expected missing string error") + } + if _, err := stringFromExtra(requirement, "badString"); err == nil { + t.Fatal("expected invalid string type error") + } + if _, err := stringFromExtra(requirement, "emptyString"); err == nil { + t.Fatal("expected empty string error") + } +} + +func TestKeypairFromJSONSecretValidatesShape(t *testing.T) { + privateKey, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal([]byte(privateKey)) + if err != nil { + t.Fatal(err) + } + + decoded, err := keypairFromJSONSecret(string(encoded)) + if err != nil { + t.Fatal(err) + } + if !decoded.PublicKey().Equals(privateKey.PublicKey()) { + t.Fatalf("decoded key does not match original") + } + if _, err := keypairFromJSONSecret("{"); err == nil { + t.Fatal("expected JSON decode error") + } + if _, err := keypairFromJSONSecret("[1,2,3]"); err == nil { + t.Fatal("expected length validation error") + } +} + +func TestLatestBlockhashHandlesJSONRPCResponses(t *testing.T) { + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + + blockhash := solana.Hash{}.String() + httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"getLatestBlockhash"`) { + t.Fatalf("unexpected RPC body: %s", string(rawBody)) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":"` + blockhash + `"}}}`)), + }, nil + })} + + got, err := latestBlockhash("http://rpc.test") + if err != nil { + t.Fatal(err) + } + if got.String() != blockhash { + t.Fatalf("latestBlockhash = %s, want %s", got, blockhash) + } + + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`)), + }, nil + })} + if _, err := latestBlockhash("http://rpc.test"); err == nil { + t.Fatal("expected RPC error") + } + + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`bad gateway`)), + }, nil + })} + if _, err := latestBlockhash("http://rpc.test"); err == nil { + t.Fatal("expected HTTP error") + } + + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{`)), + }, nil + })} + if _, err := latestBlockhash("http://rpc.test"); err == nil { + t.Fatal("expected invalid JSON error") + } +} + +func TestLatestBlockhashReturnsTransportErrors(t *testing.T) { + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("rpc unavailable") + })} + if _, err := latestBlockhash("http://rpc.test"); err == nil { + t.Fatal("expected transport error") + } +} + +func TestTransferCheckedInstructionRejectsMalformedRequirement(t *testing.T) { + signer := solana.NewWallet().PublicKey() + base := paymentRequirement{ + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: solana.NewWallet().PublicKey().String(), + } + + tests := map[string]paymentRequirement{ + "amount": func() paymentRequirement { + requirement := base + requirement.Amount = "not-int" + return requirement + }(), + "asset": func() paymentRequirement { + requirement := base + requirement.Asset = "not-base58" + return requirement + }(), + "payTo": func() paymentRequirement { + requirement := base + requirement.PayTo = "not-base58" + return requirement + }(), + } + + for name, requirement := range tests { + t.Run(name, func(t *testing.T) { + if _, err := transferCheckedInstruction(requirement, signer, 6, solana.TokenProgramID); err == nil { + t.Fatal("expected malformed requirement to be rejected") + } + }) + } +} + +func TestReadResponseAndParseResponseBody(t *testing.T) { + response := &http.Response{ + Header: http.Header{ + "X-Test": []string{"first", "second"}, + }, + Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), + } + + headers, body, err := readResponse(response) + if err != nil { + t.Fatal(err) + } + if headers["X-Test"] != "first" { + t.Fatalf("expected first header value, got %q", headers["X-Test"]) + } + if body != `{"ok":true}` { + t.Fatalf("unexpected body: %s", body) + } + parsed, ok := parseResponseBody(body).(map[string]any) + if !ok || parsed["ok"] != true { + t.Fatalf("expected JSON body to parse, got %#v", parsed) + } + if got := parseResponseBody("not json"); got != "not json" { + t.Fatalf("expected invalid JSON body passthrough, got %#v", got) + } + t.Setenv("X402_TEST_DEFAULT", "configured") + if got := readEnvWithDefault("X402_TEST_DEFAULT", "fallback"); got != "configured" { + t.Fatalf("readEnvWithDefault configured = %q", got) + } + if got := readEnvWithDefault("X402_TEST_MISSING", "fallback"); got != "fallback" { + t.Fatalf("readEnvWithDefault fallback = %q", got) + } +} + +func TestMainReportsUnimplementedChallengeResult(t *testing.T) { + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusPaymentRequired, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"accepts":[{"scheme":"upto","network":"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1","asset":"4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU","amount":"1000"}]}`)), + }, nil + })} + + t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") + t.Setenv("X402_INTEROP_SCHEME", "upto") + + output := captureStdoutForTest(t, main) + var payload map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { + t.Fatal(err) + } + if payload["implementation"] != "go" || payload["role"] != "client" || payload["ok"] != false { + t.Fatalf("unexpected result payload: %#v", payload) + } + body := payload["responseBody"].(map[string]any) + if body["error"] != "go_upto_client_not_implemented" { + t.Fatalf("unexpected error domain: %#v", body) + } +} + +func TestMainPanicsWhenTargetURLMissing(t *testing.T) { + mustPanicClient(t, main) +} + +func TestMainPanicsWhenChallengeRequestFails(t *testing.T) { + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("network down") + })} + + t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") + + mustPanicClient(t, main) +} + +func TestMainReportsExactPaymentBuildFailure(t *testing.T) { + requirement := map[string]any{ + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + "payTo": solana.NewWallet().PublicKey().String(), + "extra": map[string]any{ + "decimals": 6, + "feePayer": solana.NewWallet().PublicKey().String(), + "tokenProgram": solana.TokenProgramID.String(), + }, + } + challenge, err := json.Marshal(paymentEnvelope{ + Accepts: []paymentRequirement{{ + Scheme: requirement["scheme"].(string), + Network: requirement["network"].(string), + Asset: requirement["asset"].(string), + Amount: requirement["amount"].(string), + PayTo: requirement["payTo"].(string), + Extra: requirement["extra"].(map[string]any), + }}, + }) + if err != nil { + t.Fatal(err) + } + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + httpClient = &http.Client{Transport: clientRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusPaymentRequired, + Header: http.Header{ + "PAYMENT-REQUIRED": []string{base64.StdEncoding.EncodeToString(challenge)}, + "content-type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"error":"payment_required"}`)), + }, nil + })} + + t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") + t.Setenv("X402_INTEROP_CLIENT_SECRET_KEY", "{") + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + + output := captureStdoutForTest(t, main) + var payload map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { + t.Fatal(err) + } + if payload["ok"] != false || payload["status"] != float64(http.StatusPaymentRequired) { + t.Fatalf("unexpected payment failure result: %#v", payload) + } + body := payload["responseBody"].(map[string]any) + if body["error"] != "go_exact_client_payment_failed" { + t.Fatalf("unexpected payment failure body: %#v", body) + } +} + +func TestMainPaysExactChallengeAndReportsSettlement(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encodedClientKey, err := json.Marshal([]byte(client)) + if err != nil { + t.Fatal(err) + } + feePayer := solana.NewWallet().PublicKey() + payTo := solana.NewWallet().PublicKey() + challenge, err := json.Marshal(paymentEnvelope{ + Accepts: []paymentRequirement{ + { + Scheme: "exact", + Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: payTo.String(), + Extra: map[string]any{ + "decimals": float64(6), + "feePayer": feePayer.String(), + "tokenProgram": solana.TokenProgramID.String(), + "recentBlockhash": solana.Hash{}.String(), + "memo": "unit-main-success", + }, + }, + }, + Resource: map[string]any{"uri": "/protected"}, + }) + if err != nil { + t.Fatal(err) + } + + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + requests := 0 + httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { + requests++ + if requests == 1 { + return &http.Response{ + StatusCode: http.StatusPaymentRequired, + Header: http.Header{ + "PAYMENT-REQUIRED": []string{base64.StdEncoding.EncodeToString(challenge)}, + "content-type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"error":"payment_required"}`)), + }, nil + } + if got := request.Header.Get("PAYMENT-SIGNATURE"); got == "" { + t.Fatal("expected PAYMENT-SIGNATURE on paid retry") + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "x-fixture-settlement": []string{"unit-settlement"}, + "content-type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"paid":true}`)), + }, nil + })} + + t.Setenv("X402_INTEROP_TARGET_URL", "http://interop.test/protected") + t.Setenv("X402_INTEROP_CLIENT_SECRET_KEY", string(encodedClientKey)) + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + + output := captureStdoutForTest(t, main) + var payload map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { + t.Fatal(err) + } + if payload["ok"] != true || payload["status"] != float64(http.StatusOK) || payload["settlement"] != "unit-settlement" { + t.Fatalf("unexpected paid result: %#v", payload) + } + if requests != 2 { + t.Fatalf("expected challenge request plus paid retry, got %d", requests) + } +} + +func TestBuildExactPaymentSignatureEnvelope(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + feePayer, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + payTo, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + requirement := paymentRequirement{ + Scheme: "exact", + Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: payTo.PublicKey().String(), + MaxTimeoutSeconds: 60, + Extra: map[string]any{ + "feePayer": feePayer.PublicKey().String(), + "decimals": float64(6), + "tokenProgram": solana.TokenProgramID.String(), + "recentBlockhash": solana.Hash{}.String(), + "memo": "unit-test", + }, + } + resource := map[string]any{ + "url": "/protected", + "description": "test", + } + + header, err := buildExactPaymentSignature(requirement, resource, client, "http://127.0.0.1:8899") + if err != nil { + t.Fatal(err) + } + + decoded, err := base64.StdEncoding.DecodeString(header) + if err != nil { + t.Fatal(err) + } + var envelope paymentSignatureEnvelope + if err := json.Unmarshal(decoded, &envelope); err != nil { + t.Fatal(err) + } + if envelope.X402Version != 2 { + t.Fatalf("unexpected x402Version: %d", envelope.X402Version) + } + if envelope.Accepted.MaxTimeoutSeconds != requirement.MaxTimeoutSeconds { + t.Fatalf("accepted did not preserve maxTimeoutSeconds") + } + if envelope.Payload["transaction"] == "" { + t.Fatalf("expected transaction payload") + } + + tx := new(solana.Transaction) + if err := tx.UnmarshalBase64(envelope.Payload["transaction"]); err != nil { + t.Fatal(err) + } + if !tx.Message.IsVersioned() { + t.Fatalf("expected v0 transaction") + } + + signerIndex := -1 + feePayerIndex := -1 + for index, key := range tx.Message.AccountKeys { + if key.Equals(client.PublicKey()) { + signerIndex = index + } + if key.Equals(feePayer.PublicKey()) { + feePayerIndex = index + } + } + if signerIndex < 0 { + t.Fatalf("client signer missing from transaction") + } + if feePayerIndex < 0 { + t.Fatalf("fee payer missing from transaction") + } + if tx.Signatures[feePayerIndex] != (solana.Signature{}) { + t.Fatalf("fee payer signature should remain default") + } + message, err := tx.Message.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if !tx.Signatures[signerIndex].Verify(client.PublicKey(), message) { + t.Fatalf("client signature did not verify") + } +} + +func TestBuildExactPaymentSignatureFetchesRecentBlockhashWhenMissing(t *testing.T) { + originalHTTPClient := httpClient + defer func() { + httpClient = originalHTTPClient + }() + blockhash := solana.Hash{}.String() + httpClient = &http.Client{Transport: clientRoundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"getLatestBlockhash"`) { + t.Fatalf("unexpected RPC body: %s", string(rawBody)) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"blockhash":"` + blockhash + `"}}}`)), + }, nil + })} + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + requirement := paymentRequirement{ + Scheme: "exact", + Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: solana.NewWallet().PublicKey().String(), + Extra: map[string]any{ + "feePayer": solana.NewWallet().PublicKey().String(), + "decimals": float64(6), + "tokenProgram": solana.TokenProgramID.String(), + "memo": "unit-fetch-blockhash", + }, + } + + header, err := buildExactPaymentSignature(requirement, nil, client, "http://rpc.test") + if err != nil { + t.Fatal(err) + } + if header == "" { + t.Fatal("expected payment signature") + } +} + +func TestBuildExactPaymentSignatureRejectsInvalidRequirements(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + requirement := paymentRequirement{ + Scheme: "exact", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: solana.NewWallet().PublicKey().String(), + Extra: map[string]any{ + "feePayer": solana.NewWallet().PublicKey().String(), + "decimals": float64(6), + "tokenProgram": solana.TokenProgramID.String(), + "recentBlockhash": solana.Hash{}.String(), + "memo": "unit-test", + }, + } + + tests := map[string]func(paymentRequirement) paymentRequirement{ + "scheme": func(value paymentRequirement) paymentRequirement { + value.Scheme = "upto" + return value + }, + "missing decimals": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneClientExtra(value.Extra) + delete(value.Extra, "decimals") + return value + }, + "invalid token program": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneClientExtra(value.Extra) + value.Extra["tokenProgram"] = "not-base58" + return value + }, + "invalid fee payer": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneClientExtra(value.Extra) + value.Extra["feePayer"] = "not-base58" + return value + }, + "invalid blockhash": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneClientExtra(value.Extra) + value.Extra["recentBlockhash"] = "not-base58" + return value + }, + "invalid amount": func(value paymentRequirement) paymentRequirement { + value.Amount = "not-int" + return value + }, + "invalid payTo": func(value paymentRequirement) paymentRequirement { + value.PayTo = "not-base58" + return value + }, + } + + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + if _, err := buildExactPaymentSignature(mutate(requirement), nil, client, "http://127.0.0.1:8899"); err == nil { + t.Fatal("expected invalid requirement to be rejected") + } + }) + } +} + +func TestBuildExactPaymentSignatureGeneratesUniqueDefaultMemos(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + feePayer, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + payTo, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + requirement := paymentRequirement{ + Scheme: "exact", + Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: payTo.PublicKey().String(), + Extra: map[string]any{ + "feePayer": feePayer.PublicKey().String(), + "decimals": float64(6), + "tokenProgram": solana.TokenProgramID.String(), + "recentBlockhash": solana.Hash{}.String(), + }, + } + + firstHeader, err := buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") + if err != nil { + t.Fatal(err) + } + secondHeader, err := buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") + if err != nil { + t.Fatal(err) + } + + firstMemo := memoFromPaymentHeaderForTest(t, firstHeader) + secondMemo := memoFromPaymentHeaderForTest(t, secondHeader) + if firstHeader == secondHeader { + t.Fatal("expected unique payment headers") + } + if firstMemo == secondMemo { + t.Fatalf("expected unique default memos, got %q", firstMemo) + } + if len(firstMemo) != 32 || len(secondMemo) != 32 { + t.Fatalf("expected 32 byte hex memos, got %d and %d", len(firstMemo), len(secondMemo)) + } + if strings.Trim(firstMemo+secondMemo, "0123456789abcdef") != "" { + t.Fatalf("expected lowercase hex memos, got %q and %q", firstMemo, secondMemo) + } +} + +func TestBuildExactPaymentSignatureRejectsMemoAboveReferenceLimit(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + feePayer, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + payTo, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + requirement := paymentRequirement{ + Scheme: "exact", + Network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + Amount: "1000", + PayTo: payTo.PublicKey().String(), + Extra: map[string]any{ + "feePayer": feePayer.PublicKey().String(), + "decimals": float64(6), + "tokenProgram": solana.TokenProgramID.String(), + "recentBlockhash": solana.Hash{}.String(), + "memo": strings.Repeat("x", maxMemoBytes+1), + }, + } + + _, err = buildExactPaymentSignature(requirement, nil, client, "http://127.0.0.1:8899") + if err == nil { + t.Fatal("expected memo length error") + } + if err.Error() != "extra.memo exceeds maximum 256 bytes" { + t.Fatalf("unexpected error: %v", err) + } +} + +func memoFromPaymentHeaderForTest(t *testing.T, header string) string { + t.Helper() + decoded, err := base64.StdEncoding.DecodeString(header) + if err != nil { + t.Fatal(err) + } + var envelope paymentSignatureEnvelope + if err := json.Unmarshal(decoded, &envelope); err != nil { + t.Fatal(err) + } + tx := new(solana.Transaction) + if err := tx.UnmarshalBase64(envelope.Payload["transaction"]); err != nil { + t.Fatal(err) + } + for _, instruction := range tx.Message.Instructions { + program, err := tx.Message.Program(instruction.ProgramIDIndex) + if err != nil { + t.Fatal(err) + } + if program.Equals(memoProgramID) { + return string(instruction.Data) + } + } + t.Fatal("memo instruction missing") + return "" +} + +type clientRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn clientRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func cloneClientExtra(extra map[string]any) map[string]any { + cloned := make(map[string]any, len(extra)) + for key, value := range extra { + cloned[key] = value + } + return cloned +} + +func captureStdoutForTest(t *testing.T, fn func()) string { + t.Helper() + original := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + defer func() { + os.Stdout = original + }() + + fn() + if err := writer.Close(); err != nil { + t.Fatal(err) + } + output, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + return string(output) +} + +func mustPanicClient(t *testing.T, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + fn() +} + +// --- Greptile PR #18 follow-up: cross-envelope preference / fallback parity --- +// +// These three tests pin the cross-envelope behavior Greptile flagged as +// "absent regression coverage". They exercise the boundary between header and +// body envelopes — both with and without a currency preference — so future +// refactors can't silently regress the fallback path. + +// TestSelectSVMChallengeFallsBackToBodyWhenHeaderPreferenceMisses verifies +// that when the PAYMENT-REQUIRED header offers only USDC but the body offers +// PYUSD and the caller prefers ["PYUSD"], the client falls through the header +// envelope and selects the PYUSD entry from the body envelope. +func TestSelectSVMChallengeFallsBackToBodyWhenHeaderPreferenceMisses(t *testing.T) { + network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + headerEnvelope, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/header"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // devnet USDC + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/body"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", // devnet PYUSD + "amount": "2000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, resource := selectSVMChallengeWithPreferences( + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, + string(body), + network, + "exact", + []string{"PYUSD"}, + ) + + if selected == nil { + t.Fatal("expected fallback selection from body envelope") + } + if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected body PYUSD mint, got %s", selected.Asset) + } + if resource["uri"] != "/body" { + t.Fatalf("expected body resource attribution, got %#v", resource) + } +} + +// TestSelectSVMChallengeReturnsNilWhenNoEnvelopeMatchesPreference verifies +// that a strict preference list with no match across any envelope returns nil +// rather than silently downgrading to "any" selection. This locks the caller's +// opt-in: if you said "I only accept BOGUS", you get nothing, not USDC. +func TestSelectSVMChallengeReturnsNilWhenNoEnvelopeMatchesPreference(t *testing.T) { + network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + headerEnvelope, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC + "amount": "1500", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, resource := selectSVMChallengeWithPreferences( + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, + string(body), + network, + "exact", + []string{"BOGUS"}, + ) + + if selected != nil { + t.Fatalf("expected nil selection for unmet preference, got %+v", selected) + } + if resource != nil { + t.Fatalf("expected nil resource for unmet preference, got %#v", resource) + } +} + +// TestSelectSVMChallengePicksCheapestAcrossEnvelopesWhenNoPreference verifies +// that, when no preference is supplied, the selector aggregates valid +// candidates across the header and body envelopes and picks the globally +// cheapest amount — not merely the cheapest within the first envelope it sees. +// Header: 2000 USDC. Body: 1000 PYUSD. Expected: 1000 PYUSD with body's +// resource block. +func TestSelectSVMChallengePicksCheapestAcrossEnvelopesWhenNoPreference(t *testing.T) { + network := "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + headerEnvelope, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/header"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", // USDC + "amount": "2000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "resource": map[string]any{"uri": "/body"}, + "accepts": []map[string]any{ + { + "scheme": "exact", + "network": network, + "asset": "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", // PYUSD + "amount": "1000", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + selected, resource := selectSVMChallengeWithPreferences( + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(headerEnvelope)}, + string(body), + network, + "exact", + nil, + ) + + if selected == nil { + t.Fatal("expected cross-envelope cheapest selection") + } + if selected.Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected body PYUSD (cheapest), got %s @ %s", selected.Asset, selected.Amount) + } + if selected.Amount != "1000" { + t.Fatalf("expected amount 1000, got %s", selected.Amount) + } + if resource["uri"] != "/body" { + t.Fatalf("expected body resource attribution, got %#v", resource) + } +} diff --git a/go/x402/cmd/interop-client/main.go b/go/x402/cmd/interop-client/main.go new file mode 100644 index 000000000..1ec54de16 --- /dev/null +++ b/go/x402/cmd/interop-client/main.go @@ -0,0 +1,624 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/gagliardetto/solana-go" +) + +type paymentEnvelope struct { + Resource map[string]any `json:"resource,omitempty"` + Accepts []paymentRequirement `json:"accepts"` +} + +type paymentRequirement struct { + Scheme string `json:"scheme"` + Network string `json:"network"` + Asset string `json:"asset"` + Amount string `json:"amount"` + PayTo string `json:"payTo"` + MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` + Extra map[string]any `json:"extra"` +} + +type paymentSignatureEnvelope struct { + X402Version int `json:"x402Version"` + Accepted paymentRequirement `json:"accepted"` + Resource map[string]any `json:"resource,omitempty"` + Payload map[string]string `json:"payload"` +} + +const ( + defaultComputeUnitLimit = 20_000 + defaultComputeUnitPriceMicrolamport = 1 + maxMemoBytes = 256 +) + +var ( + computeBudgetProgramID = solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") + memoProgramID = solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") + httpClient = &http.Client{Timeout: 10 * time.Second} +) + +func headerValue(headers map[string]string, name string) string { + for key, value := range headers { + if strings.EqualFold(key, name) { + return value + } + } + return "" +} + +func loadPaymentRequiredHeader(headers map[string]string) *paymentEnvelope { + encoded := headerValue(headers, "PAYMENT-REQUIRED") + if encoded == "" { + return nil + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil + } + + var envelope paymentEnvelope + if err := json.Unmarshal(decoded, &envelope); err != nil { + return nil + } + return &envelope +} + +func loadPaymentRequiredBody(body string) *paymentEnvelope { + if body == "" { + return nil + } + + var envelope paymentEnvelope + if err := json.Unmarshal([]byte(body), &envelope); err != nil { + return nil + } + return &envelope +} + +func selectSVMRequirement(headers map[string]string, body string, network string, scheme string) *paymentRequirement { + requirement, _ := selectSVMChallengeWithPreferences(headers, body, network, scheme, nil) + return requirement +} + +func selectSVMChallenge(headers map[string]string, body string, network string, scheme string) (*paymentRequirement, map[string]any) { + return selectSVMChallengeWithPreferences(headers, body, network, scheme, parseCSVEnv("X402_INTEROP_PREFER_CURRENCIES")) +} + +func selectSVMChallengeWithPreferences(headers map[string]string, body string, network string, scheme string, preferredCurrencies []string) (*paymentRequirement, map[string]any) { + envelopes := []*paymentEnvelope{ + loadPaymentRequiredHeader(headers), + loadPaymentRequiredBody(body), + } + + // Preference path: envelope-by-envelope fallback. Each preferred currency + // is searched against each envelope in order; the first match wins. If no + // envelope satisfies the preference list we return nil (caller's strict + // opt-in is preserved instead of silently downgrading to "any" selection). + if len(preferredCurrencies) > 0 { + for _, envelope := range envelopes { + if envelope == nil { + continue + } + candidates := filterCandidates(envelope.Accepts, scheme, network) + if len(candidates) == 0 { + continue + } + for _, preferred := range preferredCurrencies { + for _, requirement := range candidates { + if currenciesMatch(requirement.Asset, preferred, network) { + selected := requirement + return &selected, envelope.Resource + } + } + } + } + return nil, nil + } + + // No-preference path: aggregate valid candidates from ALL envelopes and + // pick the globally cheapest amount. Resource attribution follows the + // envelope that contributed the winning candidate so downstream telemetry + // and signing flows see the correct context. + type candidateEntry struct { + requirement paymentRequirement + resource map[string]any + } + var entries []candidateEntry + for _, envelope := range envelopes { + if envelope == nil { + continue + } + for _, requirement := range filterCandidates(envelope.Accepts, scheme, network) { + entries = append(entries, candidateEntry{requirement: requirement, resource: envelope.Resource}) + } + } + if len(entries) == 0 { + return nil, nil + } + winner := entries[0] + winnerAmount, err := strconv.ParseUint(winner.requirement.Amount, 10, 64) + if err != nil { + winnerAmount = ^uint64(0) + } + for _, entry := range entries[1:] { + amount, err := strconv.ParseUint(entry.requirement.Amount, 10, 64) + if err != nil { + amount = ^uint64(0) + } + if amount < winnerAmount { + winner = entry + winnerAmount = amount + } + } + selected := winner.requirement + return &selected, winner.resource +} + +func filterCandidates(accepts []paymentRequirement, scheme string, network string) []paymentRequirement { + candidates := make([]paymentRequirement, 0, len(accepts)) + for _, requirement := range accepts { + if requirement.Scheme != scheme { + continue + } + if requirement.Network != network { + continue + } + if requirement.Asset == "" || requirement.Amount == "" { + continue + } + candidates = append(candidates, requirement) + } + return candidates +} + +func parseCSVEnv(name string) []string { + raw := os.Getenv(name) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + values = append(values, trimmed) + } + } + return values +} + +func currenciesMatch(offered string, accepted string, network string) bool { + return resolveStablecoinMint(offered, network) == resolveStablecoinMint(accepted, network) +} + +func resolveStablecoinMint(currency string, network string) string { + upper := strings.ToUpper(strings.TrimSpace(currency)) + switch upper { + case "USDC", "USD": + if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { + return "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" + } + return "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + case "PYUSD": + if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { + return "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" + } + return "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" + case "USDG": + if network == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" || network == "devnet" || network == "localnet" { + return "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7" + } + return "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH" + case "USDT": + return "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" + case "CASH": + return "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH" + default: + return strings.TrimSpace(currency) + } +} + +func intFromRequirement(requirement paymentRequirement, key string) (uint64, error) { + value, ok := requirement.Extra[key] + if !ok { + return 0, fmt.Errorf("payment requirement is missing integer extra.%s", key) + } + + switch typed := value.(type) { + case float64: + return uint64(typed), nil + case string: + parsed, err := strconv.ParseUint(typed, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid integer extra.%s: %w", key, err) + } + return parsed, nil + default: + return 0, fmt.Errorf("payment requirement has invalid integer extra.%s", key) + } +} + +func stringFromExtra(requirement paymentRequirement, key string) (string, error) { + value, ok := requirement.Extra[key] + if !ok { + return "", fmt.Errorf("payment requirement is missing extra.%s", key) + } + typed, ok := value.(string) + if !ok || typed == "" { + return "", fmt.Errorf("payment requirement has invalid extra.%s", key) + } + return typed, nil +} + +func keypairFromJSONSecret(raw string) (solana.PrivateKey, error) { + var values []byte + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil, fmt.Errorf("decode Solana secret key: %w", err) + } + if len(values) != 64 { + return nil, fmt.Errorf("expected a 64-byte Solana secret key JSON array") + } + privateKey := solana.PrivateKey(values) + if _, err := solana.ValidatePrivateKey(privateKey); err != nil { + return nil, err + } + return privateKey, nil +} + +func latestBlockhash(rpcURL string) (solana.Hash, error) { + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "getLatestBlockhash", + }) + if err != nil { + return solana.Hash{}, err + } + response, err := httpClient.Post(rpcURL, "application/json", bytes.NewReader(requestBody)) + if err != nil { + return solana.Hash{}, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return solana.Hash{}, fmt.Errorf("getLatestBlockhash HTTP %d", response.StatusCode) + } + var payload struct { + Result struct { + Value struct { + Blockhash string `json:"blockhash"` + } `json:"value"` + } `json:"result"` + Error any `json:"error"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return solana.Hash{}, err + } + if payload.Error != nil { + return solana.Hash{}, fmt.Errorf("getLatestBlockhash RPC error: %v", payload.Error) + } + return solana.HashFromBase58(payload.Result.Value.Blockhash) +} + +func computeUnitLimitInstruction(units uint32) solana.Instruction { + data := []byte{2} + data = binary.LittleEndian.AppendUint32(data, units) + return solana.NewInstruction(computeBudgetProgramID, nil, data) +} + +func computeUnitPriceInstruction(microLamports uint64) solana.Instruction { + data := []byte{3} + data = binary.LittleEndian.AppendUint64(data, microLamports) + return solana.NewInstruction(computeBudgetProgramID, nil, data) +} + +func transferCheckedInstruction(requirement paymentRequirement, signer solana.PublicKey, decimals uint8, tokenProgram solana.PublicKey) (solana.Instruction, error) { + amount, err := strconv.ParseUint(requirement.Amount, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid amount: %w", err) + } + mint, err := solana.PublicKeyFromBase58(requirement.Asset) + if err != nil { + return nil, fmt.Errorf("invalid asset: %w", err) + } + payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) + if err != nil { + return nil, fmt.Errorf("invalid payTo: %w", err) + } + sourceATA, _, err := findAssociatedTokenAddress(signer, tokenProgram, mint) + if err != nil { + return nil, err + } + destinationATA, _, err := findAssociatedTokenAddress(payTo, tokenProgram, mint) + if err != nil { + return nil, err + } + + data := []byte{12} + data = binary.LittleEndian.AppendUint64(data, amount) + data = append(data, decimals) + + return solana.NewInstruction( + tokenProgram, + solana.AccountMetaSlice{ + solana.Meta(sourceATA).WRITE(), + solana.Meta(mint), + solana.Meta(destinationATA).WRITE(), + solana.Meta(signer).SIGNER(), + }, + data, + ), nil +} + +func findAssociatedTokenAddress(wallet solana.PublicKey, tokenProgram solana.PublicKey, mint solana.PublicKey) (solana.PublicKey, uint8, error) { + return solana.FindProgramAddress( + [][]byte{wallet[:], tokenProgram[:], mint[:]}, + solana.SPLAssociatedTokenAccountProgramID, + ) +} + +func memoInstruction(requirement paymentRequirement) (solana.Instruction, error) { + memo := "" + if value, ok := requirement.Extra["memo"].(string); ok && value != "" { + memo = value + } else { + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, fmt.Errorf("generate memo nonce: %w", err) + } + memo = hex.EncodeToString(nonce[:]) + } + if len([]byte(memo)) > maxMemoBytes { + return nil, fmt.Errorf("extra.memo exceeds maximum %d bytes", maxMemoBytes) + } + return solana.NewInstruction(memoProgramID, nil, []byte(memo)), nil +} + +func buildExactPaymentSignature(requirement paymentRequirement, resource map[string]any, privateKey solana.PrivateKey, rpcURL string) (string, error) { + if requirement.Scheme != "exact" { + return "", fmt.Errorf("only exact payment requirements can be signed") + } + + decimalsValue, err := intFromRequirement(requirement, "decimals") + if err != nil { + return "", err + } + tokenProgramValue, err := stringFromExtra(requirement, "tokenProgram") + if err != nil { + return "", err + } + feePayerValue, err := stringFromExtra(requirement, "feePayer") + if err != nil { + return "", err + } + tokenProgram, err := solana.PublicKeyFromBase58(tokenProgramValue) + if err != nil { + return "", fmt.Errorf("invalid tokenProgram: %w", err) + } + feePayer, err := solana.PublicKeyFromBase58(feePayerValue) + if err != nil { + return "", fmt.Errorf("invalid feePayer: %w", err) + } + + blockhashValue, _ := requirement.Extra["recentBlockhash"].(string) + var blockhash solana.Hash + if blockhashValue != "" { + blockhash, err = solana.HashFromBase58(blockhashValue) + if err != nil { + return "", fmt.Errorf("invalid recentBlockhash: %w", err) + } + } else { + blockhash, err = latestBlockhash(rpcURL) + if err != nil { + return "", err + } + } + + transferIx, err := transferCheckedInstruction(requirement, privateKey.PublicKey(), uint8(decimalsValue), tokenProgram) + if err != nil { + return "", err + } + memoIx, err := memoInstruction(requirement) + if err != nil { + return "", err + } + + tx, err := solana.NewTransaction( + []solana.Instruction{ + computeUnitLimitInstruction(defaultComputeUnitLimit), + computeUnitPriceInstruction(defaultComputeUnitPriceMicrolamport), + transferIx, + memoIx, + }, + blockhash, + solana.TransactionPayer(feePayer), + ) + if err != nil { + return "", err + } + tx.Message.SetVersion(solana.MessageVersionV0) + if _, err := tx.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(privateKey.PublicKey()) { + return &privateKey + } + return nil + }); err != nil { + return "", err + } + transaction, err := tx.ToBase64() + if err != nil { + return "", err + } + + encoded, err := json.Marshal(paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Resource: resource, + Payload: map[string]string{"transaction": transaction}, + }) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(encoded), nil +} + +func readResponse(response *http.Response) (map[string]string, string, error) { + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, "", err + } + headers := map[string]string{} + for key, values := range response.Header { + if len(values) > 0 { + headers[key] = values[0] + } + } + return headers, string(body), nil +} + +func parseResponseBody(body string) any { + var parsed any + decoder := json.NewDecoder(bytes.NewReader([]byte(body))) + if err := decoder.Decode(&parsed); err == nil { + return parsed + } + return body +} + +func main() { + targetURL := os.Getenv("X402_INTEROP_TARGET_URL") + if targetURL == "" { + panic("X402_INTEROP_TARGET_URL is required") + } + + response, err := httpClient.Get(targetURL) + if err != nil { + panic(err) + } + defer func() { _ = response.Body.Close() }() + headers, body, err := readResponse(response) + if err != nil { + panic(err) + } + + selectedRequirement, resource := selectSVMChallenge( + headers, + body, + readEnvWithDefault("X402_INTEROP_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"), + readEnvWithDefault("X402_INTEROP_SCHEME", "exact"), + ) + scheme := readEnvWithDefault("X402_INTEROP_SCHEME", "exact") + errorDomain := readEnvWithDefault("X402_INTEROP_INTENT", scheme) + + if response.StatusCode == http.StatusPaymentRequired && os.Getenv("X402_INTEROP_INTENT") == "" && scheme == "exact" && selectedRequirement != nil && os.Getenv("X402_INTEROP_CLIENT_SECRET_KEY") != "" && os.Getenv("X402_INTEROP_RPC_URL") != "" { + privateKey, err := keypairFromJSONSecret(os.Getenv("X402_INTEROP_CLIENT_SECRET_KEY")) + var paymentSignature string + if err == nil { + paymentSignature, err = buildExactPaymentSignature(*selectedRequirement, resource, privateKey, os.Getenv("X402_INTEROP_RPC_URL")) + } + if err == nil { + request, requestErr := http.NewRequest(http.MethodGet, targetURL, nil) + if requestErr != nil { + err = requestErr + } else { + request.Header.Set("PAYMENT-SIGNATURE", paymentSignature) + var paidResponse *http.Response + paidResponse, err = httpClient.Do(request) + if err == nil { + defer func() { _ = paidResponse.Body.Close() }() + paidHeaders, paidBody, readErr := readResponse(paidResponse) + if readErr != nil { + err = readErr + } else { + payload := map[string]any{ + "type": "result", + "implementation": "go", + "role": "client", + "ok": paidResponse.StatusCode >= 200 && paidResponse.StatusCode < 300, + "status": paidResponse.StatusCode, + "responseHeaders": paidHeaders, + "responseBody": parseResponseBody(paidBody), + "settlement": headerValue(paidHeaders, "x-fixture-settlement"), + } + encoded, marshalErr := json.Marshal(payload) + if marshalErr != nil { + panic(marshalErr) + } + fmt.Println(string(encoded)) + return + } + } + } + } + if err != nil { + payload := map[string]any{ + "type": "result", + "implementation": "go", + "role": "client", + "ok": false, + "status": response.StatusCode, + "responseHeaders": headers, + "responseBody": map[string]any{ + "error": "go_exact_client_payment_failed", + "message": err.Error(), + "challengeStatus": response.StatusCode, + "challengeBody": body, + "selectedRequirement": selectedRequirement, + }, + "settlement": nil, + } + encoded, marshalErr := json.Marshal(payload) + if marshalErr != nil { + panic(marshalErr) + } + fmt.Println(string(encoded)) + return + } + } + + payload := map[string]any{ + "type": "result", + "implementation": "go", + "role": "client", + "ok": false, + "status": response.StatusCode, + "responseHeaders": headers, + "responseBody": map[string]any{ + "error": fmt.Sprintf("go_%s_client_not_implemented", errorDomain), + "challengeStatus": response.StatusCode, + "challengeBody": body, + "selectedRequirement": selectedRequirement, + }, + "settlement": nil, + } + + encoded, err := json.Marshal(payload) + if err != nil { + panic(err) + } + fmt.Println(string(encoded)) +} + +func readEnvWithDefault(name string, fallback string) string { + value := os.Getenv(name) + if value == "" { + return fallback + } + return value +} diff --git a/go/x402/cmd/interop-server/main.go b/go/x402/cmd/interop-server/main.go new file mode 100644 index 000000000..96ea0c957 --- /dev/null +++ b/go/x402/cmd/interop-server/main.go @@ -0,0 +1,1196 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "reflect" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/gagliardetto/solana-go" +) + +const ( + defaultResourcePath = "/protected" + defaultPrice = "$0.001" + defaultSettlementHeader = "x-fixture-settlement" + defaultDecimals = 6 + defaultTokenProgram = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + token2022Program = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + lighthouseProgram = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" + defaultMaxTimeout = 60 + duplicateCacheTTL = 120 * time.Second + maxComputeUnitPrice = 5_000_000 + maxMemoBytes = 256 + // replayKeyNamespace MUST match the scheme-namespaced canonical key + // documented in the x402 PR-readiness reference and mirrors the MPP + // `solana-charge:consumed:` shape but scoped to x402 svm-exact so + // settled signatures across schemes (and against MPP) do not collide. + replayKeyNamespace = "x402-svm-exact:consumed:" +) + +// confirmationPollAttempts × confirmationPollInterval bounds the +// post-broadcast confirmation wait. Defaults mirror the MPP +// `server/charge.rs:769` 30×200ms = ~6s window. These are vars (not +// consts) so tests can shrink the poll budget to keep timeout coverage +// fast. +var ( + confirmationPollAttempts = 60 + confirmationPollInterval = 200 * time.Millisecond +) + +var ( + computeBudgetProgramID = solana.MustPublicKeyFromBase58("ComputeBudget111111111111111111111111111111") + memoProgramID = solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr") +) + +// Lighthouse instructions are passed through by program-ID match alone, matching +// the canonical spines: +// - rust/src/protocol/schemes/exact/verify.rs:266 — `if program == LIGHTHOUSE_PROGRAM || program == MEMO_PROGRAM { continue; }` +// - typescript/packages/x402/src/facilitator/exact/scheme.ts:300 — same shape +// No discriminator or account-count allowlist is enforced here: inventing one +// in a single language port would diverge from real-world Phantom/Solflare +// transactions that the Rust + TypeScript adapters accept. Tightening this is +// a protocol-wide decision that must land in the Rust spine first; tracked at +// /notes/lighthouse-allowlist-tracking.md. + +// CAIP-2 network identifiers shared with the TypeScript spine. +const ( + solanaMainnetCAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + solanaDevnetCAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + solanaTestnetCAIP2 = "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z" +) + +// stablecoinMintsByNetwork mirrors STABLECOIN_MINTS from the TypeScript +// reference (typescript/packages/x402/src/protocol/schemes/exact/constants.ts). +// Aliases are resolved at the env-read boundary so the rest of the server +// always sees canonical base58 mint addresses. +var stablecoinMintsByNetwork = map[string]map[string]string{ + "USDC": { + solanaMainnetCAIP2: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + solanaDevnetCAIP2: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + }, + "USDT": { + solanaMainnetCAIP2: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + }, + "USDG": { + solanaMainnetCAIP2: "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH", + solanaDevnetCAIP2: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", + solanaTestnetCAIP2: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", + }, + "PYUSD": { + solanaMainnetCAIP2: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + solanaDevnetCAIP2: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + solanaTestnetCAIP2: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + }, + "CASH": { + solanaMainnetCAIP2: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH", + }, +} + +// knownMintAliases lists the case-insensitive currency-name aliases that +// resolveMintAlias understands. Kept stable for error messages. +var knownMintAliases = []string{"USDC", "USDT", "USDG", "PYUSD", "CASH"} + +// resolveMintAlias returns the canonical base58 mint address for a given +// input on the configured CAIP-2 network. The input may already be a base58 +// mint (in which case it is returned unchanged) or a known stablecoin alias +// (USDC, USDT, USDG, PYUSD, CASH). Unknown aliases and aliases without a +// configured mint for the network return a descriptive error. +func resolveMintAlias(input string, network string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", fmt.Errorf("mint is required") + } + upper := strings.ToUpper(trimmed) + if mintsByNetwork, ok := stablecoinMintsByNetwork[upper]; ok { + if mint, ok := mintsByNetwork[network]; ok { + return mint, nil + } + return "", fmt.Errorf("alias %s has no configured mint for network %s", upper, network) + } + if _, err := solana.PublicKeyFromBase58(trimmed); err != nil { + return "", fmt.Errorf("mint %q is neither a base58 address nor a known alias (accepted aliases: %s)", input, strings.Join(knownMintAliases, ", ")) + } + return trimmed, nil +} + +type serverState struct { + rpcURL string + network string + mint string + payTo string + feePayer solana.PrivateKey + amount string + extraOfferedMints []string + memo string + httpClient *http.Client +} + +type paymentEnvelope struct { + X402Version int `json:"x402Version"` + Accepts []paymentRequirement `json:"accepts"` + Resource map[string]any `json:"resource,omitempty"` +} + +type paymentRequirement struct { + Scheme string `json:"scheme"` + Network string `json:"network"` + Asset string `json:"asset"` + Amount string `json:"amount"` + PayTo string `json:"payTo"` + MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +type paymentSignatureEnvelope struct { + X402Version int `json:"x402Version"` + Accepted paymentRequirement `json:"accepted"` + Payload map[string]string `json:"payload"` +} + +type duplicateSettlementCache struct { + mu sync.Mutex + entries map[string]time.Time + now func() time.Time +} + +var settlementCache = newDuplicateSettlementCache() + +func newDuplicateSettlementCache() *duplicateSettlementCache { + return &duplicateSettlementCache{ + entries: map[string]time.Time{}, + now: time.Now, + } +} + +// putIfAbsent reserves `key` in the replay cache. Returns true if the key +// was newly inserted, false if a prior settlement already consumed it. +// +// L8 ordering (see x402 PR-readiness reference and MPP +// `server/charge.rs:535-556`): callers MUST broadcast → await on-chain +// confirmation → `putIfAbsent(signature)`. There is no release-on-failure +// path: a crash or RPC failure before this call simply never inserts a +// key, and Solana's per-signature replay protection prevents a re-broadcast +// of the same signed transaction from settling twice within its blockhash +// window. The release path of the prior claim-first design has been +// removed to close the partial-failure race where a release after a timed- +// out confirmation would permit a double-pay if the original later landed. +func (cache *duplicateSettlementCache) putIfAbsent(key string) bool { + cache.mu.Lock() + defer cache.mu.Unlock() + + now := cache.now() + for cached, seenAt := range cache.entries { + if now.Sub(seenAt) > duplicateCacheTTL { + delete(cache.entries, cached) + } + } + if _, ok := cache.entries[key]; ok { + return false + } + cache.entries[key] = now + return true +} + +func writeJSON(response http.ResponseWriter, status int, payload map[string]any) { + encoded, err := json.Marshal(payload) + if err != nil { + panic(err) + } + response.Header().Set("content-type", "application/json") + response.WriteHeader(status) + if _, err := response.Write(encoded); err != nil { + fmt.Fprintln(os.Stderr, err) + } +} + +func writeJSONWithHeaders(response http.ResponseWriter, status int, headers map[string]string, payload map[string]any) { + encoded, err := json.Marshal(payload) + if err != nil { + panic(err) + } + response.Header().Set("content-type", "application/json") + for key, value := range headers { + response.Header().Set(key, value) + } + response.WriteHeader(status) + if _, err := response.Write(encoded); err != nil { + fmt.Fprintln(os.Stderr, err) + } +} + +func capabilityPayload(implementation string) map[string]any { + return map[string]any{ + "implementation": implementation, + "role": "server", + "capabilities": []string{"exact"}, + "plannedBoundaries": []string{"exact", "upto", "session", "batch-settlement"}, + } +} + +func exactRequirementForMint(state serverState, mint string) paymentRequirement { + requirement := paymentRequirement{ + Scheme: "exact", + Network: state.network, + Asset: mint, + Amount: state.amount, + PayTo: state.payTo, + MaxTimeoutSeconds: defaultMaxTimeout, + Extra: map[string]any{ + "decimals": defaultDecimals, + "feePayer": state.feePayer.PublicKey().String(), + "tokenProgram": defaultTokenProgramForMint(mint), + }, + } + if state.memo != "" { + requirement.Extra["memo"] = state.memo + } + return requirement +} + +func exactRequirement(state serverState) paymentRequirement { + return exactRequirementForMint(state, state.mint) +} + +func exactChallengePayload(state serverState) paymentEnvelope { + accepts := []paymentRequirement{exactRequirement(state)} + for _, mint := range state.extraOfferedMints { + if mint == "" { + continue + } + accepts = append(accepts, exactRequirementForMint(state, mint)) + } + return paymentEnvelope{ + X402Version: 2, + Accepts: accepts, + Resource: map[string]any{ + "type": "http", + "uri": defaultResourcePath, + }, + } +} + +func defaultTokenProgramForMint(mint string) string { + switch strings.ToUpper(strings.TrimSpace(mint)) { + case "USDG", "PYUSD", "CASH": + return token2022Program + } + switch strings.TrimSpace(mint) { + case "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH", + "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", + "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH": + return token2022Program + default: + return defaultTokenProgram + } +} + +func uptoChallengePayload() map[string]any { + return map[string]any{ + "x402Version": 2, + "accepts": []map[string]any{ + { + "scheme": "upto", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "amount": "1000", + }, + }, + } +} + +func writePaymentRequired(response http.ResponseWriter, challenge map[string]any) { + encoded, err := json.Marshal(challenge) + if err != nil { + panic(err) + } + response.Header().Set("PAYMENT-REQUIRED", base64.StdEncoding.EncodeToString(encoded)) + writeJSON(response, http.StatusPaymentRequired, map[string]any{"error": "payment_required"}) +} + +func writeExactPaymentRequired(response http.ResponseWriter, state serverState) { + challenge := exactChallengePayload(state) + encoded, err := json.Marshal(challenge) + if err != nil { + panic(err) + } + response.Header().Set("PAYMENT-REQUIRED", base64.StdEncoding.EncodeToString(encoded)) + writeJSON(response, http.StatusPaymentRequired, map[string]any{"error": "payment_required"}) +} + +func sessionChallengePayload() map[string]any { + return map[string]any{ + "intent": "session", + "payee": "session-payee", + "mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "suggestedDeposit": "10000", + "unitPrice": "25", + "unitType": "llm_token", + } +} + +func batchSettlementChallengePayload() map[string]any { + return map[string]any{ + "x402Version": 2, + "accepts": []map[string]any{ + { + "scheme": "batch-settlement", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "receiver": "batch-receiver", + "token": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "maximumAmount": "1000", + }, + }, + } +} + +func readRequiredEnv(name string) string { + value := os.Getenv(name) + if value == "" { + panic(fmt.Sprintf("%s is required", name)) + } + return value +} + +func readEnvWithDefault(name string, fallback string) string { + value := os.Getenv(name) + if value == "" { + return fallback + } + return value +} + +func readCSVEnv(name string) []string { + raw := os.Getenv(name) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + values = append(values, trimmed) + } + } + return values +} + +func normalizeAmount(price string) string { + trimmed := strings.TrimSpace(price) + if len(trimmed) > 0 && trimmed[0] == '$' { + trimmed = trimmed[1:] + } + amountPart := strings.Fields(trimmed)[0] + parts := strings.SplitN(amountPart, ".", 2) + whole, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + panic(fmt.Sprintf("invalid X402_INTEROP_PRICE: %s", price)) + } + fraction := "" + if len(parts) == 2 { + fraction = parts[1] + } + if len(fraction) > defaultDecimals { + panic(fmt.Sprintf("X402_INTEROP_PRICE has too many decimal places: %s", price)) + } + fraction = fraction + strings.Repeat("0", defaultDecimals-len(fraction)) + fractional, err := strconv.ParseUint(fraction, 10, 64) + if err != nil { + panic(fmt.Sprintf("invalid X402_INTEROP_PRICE: %s", price)) + } + return strconv.FormatUint((whole*1_000_000)+fractional, 10) +} + +func keypairFromJSONSecret(raw string) solana.PrivateKey { + var values []byte + if err := json.Unmarshal([]byte(raw), &values); err != nil { + panic(fmt.Sprintf("decode Solana secret key: %s", err)) + } + if len(values) != 64 { + panic("expected a 64-byte Solana secret key JSON array") + } + privateKey := solana.PrivateKey(values) + if _, err := solana.ValidatePrivateKey(privateKey); err != nil { + panic(err) + } + return privateKey +} + +func readState() serverState { + network := readEnvWithDefault("X402_INTEROP_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") + rawMint := readEnvWithDefault("X402_INTEROP_MINT", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU") + resolvedMint, err := resolveMintAlias(rawMint, network) + if err != nil { + panic(fmt.Sprintf("X402_INTEROP_MINT: %s", err)) + } + rawExtra := readCSVEnv("X402_INTEROP_EXTRA_OFFERED_MINTS") + resolvedExtra := make([]string, 0, len(rawExtra)) + for _, candidate := range rawExtra { + resolved, err := resolveMintAlias(candidate, network) + if err != nil { + panic(fmt.Sprintf("X402_INTEROP_EXTRA_OFFERED_MINTS: %s", err)) + } + resolvedExtra = append(resolvedExtra, resolved) + } + return serverState{ + rpcURL: readRequiredEnv("X402_INTEROP_RPC_URL"), + network: network, + mint: resolvedMint, + payTo: readRequiredEnv("X402_INTEROP_PAY_TO"), + feePayer: keypairFromJSONSecret(readRequiredEnv("X402_INTEROP_FACILITATOR_SECRET_KEY")), + amount: normalizeAmount(readEnvWithDefault("X402_INTEROP_PRICE", defaultPrice)), + extraOfferedMints: resolvedExtra, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +func paymentRequirementMatches(left paymentRequirement, right paymentRequirement) bool { + return reflect.DeepEqual(normalizeRequirement(left), normalizeRequirement(right)) +} + +func acceptedExactRequirement(state serverState, accepted paymentRequirement) (paymentRequirement, bool) { + for _, requirement := range exactChallengePayload(state).Accepts { + if paymentRequirementMatches(accepted, requirement) { + return requirement, true + } + } + return paymentRequirement{}, false +} + +func normalizeRequirement(requirement paymentRequirement) paymentRequirement { + normalized := requirement + normalized.Extra = map[string]any{} + for key, value := range requirement.Extra { + normalized.Extra[key] = fmt.Sprint(value) + } + return normalized +} + +func decodePaymentSignature(headerValue string) (paymentSignatureEnvelope, error) { + decoded, err := base64.StdEncoding.DecodeString(headerValue) + if err != nil { + return paymentSignatureEnvelope{}, err + } + var payload paymentSignatureEnvelope + if err := json.Unmarshal(decoded, &payload); err != nil { + return paymentSignatureEnvelope{}, err + } + return payload, nil +} + +func settleExactPayment(state serverState, headerValue string) (string, error) { + payload, err := decodePaymentSignature(headerValue) + if err != nil { + return "", err + } + if payload.X402Version != 2 { + return "", fmt.Errorf("unsupported x402Version: %d", payload.X402Version) + } + requirement, ok := acceptedExactRequirement(state, payload.Accepted) + if !ok { + return "", fmt.Errorf("accepted payment requirement does not match server challenge") + } + + encodedTransaction := payload.Payload["transaction"] + if encodedTransaction == "" { + return "", fmt.Errorf("payment payload is missing transaction") + } + + transaction, err := solana.TransactionFromBase64(encodedTransaction) + if err != nil { + return "", err + } + if err := verifyExactTransaction(transaction, requirement); err != nil { + return "", err + } + // Bind the transaction's message fee-payer (account key 0) to the + // server's configured fee-payer. Without this guard a malicious client + // could nominate a different message payer and rely on the facilitator + // being in the signer set to drain SOL via co-signing. + if len(transaction.Message.AccountKeys) == 0 { + return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_missing") + } + if !transaction.Message.AccountKeys[0].Equals(state.feePayer.PublicKey()) { + return "", fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_mismatch") + } + if err := verifyTokenAccountsExist(state, transaction, requirement); err != nil { + return "", err + } + + if _, err := transaction.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(state.feePayer.PublicKey()) { + return &state.feePayer + } + return nil + }); err != nil { + return "", err + } + if err := transaction.VerifySignatures(); err != nil { + return "", err + } + + // L8 ordering: broadcast → confirm → put_if_absent(signature). + // Mirrors MPP `server/charge.rs:535-556` (broadcast_pull, + // await_pull_confirmation, consume_signature). No claim-first, no + // release-on-failure. See x402 PR-readiness reference §"L8 + // broadcast-then-confirm-then-mark ordering (SVM-specific)". + signature, err := sendTransaction(state, transaction) + if err != nil { + return "", err + } + if err := awaitSignatureConfirmation(state, signature); err != nil { + return "", err + } + if !settlementCache.putIfAbsent(replayKeyNamespace + signature) { + // Canonical `signature_consumed` surface (see MPP + // `VerificationError::signature_consumed`, + // rust/src/server/charge.rs:589-593). The interop server's + // existing error vocabulary maps this to "duplicate_settlement"; + // keep that wire token so existing clients are not broken, but + // the semantic is now "this confirmed signature was already + // consumed by an earlier successful settlement", not "we saw + // this encoded transaction blob before broadcast". + return "", fmt.Errorf("duplicate_settlement") + } + return signature, nil +} + +type transferCheckedFields struct { + source solana.PublicKey + mint solana.PublicKey + destination solana.PublicKey + authority solana.PublicKey + amount uint64 + decimals uint8 + tokenProgram solana.PublicKey +} + +func verifyExactTransaction(transaction *solana.Transaction, requirement paymentRequirement) error { + if !transaction.Message.IsVersioned() { + return fmt.Errorf("payment transaction must be versioned") + } + instructions := transaction.Message.Instructions + if len(instructions) < 3 || len(instructions) > 6 { + return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_length") + } + if err := verifyComputeLimitInstruction(transaction, instructions[0]); err != nil { + return err + } + if err := verifyComputePriceInstruction(transaction, instructions[1]); err != nil { + return err + } + transfer, err := parseTransferCheckedInstruction(transaction, instructions[2]) + if err != nil { + return err + } + // Mirror the Rust spine binding (rust/crates/x402/src/protocol/schemes/exact/verify.rs:73-80) + // and the PHP/Ruby/Lua ports: the on-chain transfer's token program MUST match the + // program declared in requirement.Extra["tokenProgram"]. Without this check, a Token-2022 + // transfer can satisfy an SPL Token requirement (or vice versa), because the + // destination-ATA derivation below uses the parsed program rather than the required one. + requiredTokenProgramRaw, ok := requirement.Extra["tokenProgram"].(string) + if !ok || requiredTokenProgramRaw == "" { + return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") + } + requiredTokenProgram, err := solana.PublicKeyFromBase58(requiredTokenProgramRaw) + if err != nil { + return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") + } + if !transfer.tokenProgram.Equals(requiredTokenProgram) { + return fmt.Errorf("invalid_exact_svm_payload_transaction_token_program") + } + if err := verifyOptionalInstructions(transaction, instructions[3:], requirement, transfer); err != nil { + return err + } + feePayer, err := solana.PublicKeyFromBase58(fmt.Sprint(requirement.Extra["feePayer"])) + if err != nil { + return fmt.Errorf("invalid feePayer: %w", err) + } + // Codex P1.2 (May 2026): the previous unconditional "fee-payer in any + // instruction account" loop was both over-broad (false-positive on the + // legitimate destination-ATA-create flow, where the SPL Associated Token + // Account program requires the rent payer at accounts[0]) and incomplete + // (it did not distinguish *role* — fee-payer as transfer authority/source + // is the real attack the Rust spine bans at + // rust/src/protocol/schemes/exact/verify.rs:382). Tightened rule: + // * fee-payer is allowed at accounts[0] of a *validated* ATA-create ix + // (the canonical rent-payer position). + // * fee-payer is allowed inside Lighthouse instruction account lists + // (the Rust spine has NO fee-payer-in-accounts sweep at all; it only + // blocks fee-payer as transfer authority at verify.rs:382, and accepts + // any Lighthouse ix by program-id alone at verify.rs:263 — wallets such + // as Phantom/Solflare routinely add `AssertAccount*` ixs that reference + // the fee-payer's pubkey to guard against malicious facilitator rewrites). + // * fee-payer in any other (non-Lighthouse, non-ATA-create-payer-slot) + // instruction account list is rejected with a distinct typed error. + // * fee-payer as transfer authority / source is still rejected with the + // spine-aligned `_transferring_funds` error. + if transfer.authority.Equals(feePayer) || transfer.source.Equals(feePayer) { + return fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_transferring_funds") + } + for index, instruction := range instructions { + if index == 2 { + // instruction[2] is the transferChecked; its fee-payer-as-role + // abuses are already covered by the spine-aligned guard above. + continue + } + program, err := programID(transaction, instruction) + if err != nil { + return err + } + if program.String() == lighthouseProgram { + // Mirror rust/src/protocol/schemes/exact/verify.rs:263 — Lighthouse + // ixs are passed through by program-id alone; the spine never + // inspects their account lists for the managed fee-payer. + continue + } + isATACreatePayerSlot := index >= 3 && isValidatedATACreateInstruction(transaction, instruction, requirement, transfer) + for accountPosition, accountIndex := range instruction.Accounts { + account, err := accountAt(transaction, accountIndex) + if err != nil { + return err + } + if !account.Equals(feePayer) { + continue + } + if isATACreatePayerSlot && accountPosition == 0 { + continue + } + return fmt.Errorf("invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts") + } + } + mint, err := solana.PublicKeyFromBase58(requirement.Asset) + if err != nil { + return fmt.Errorf("invalid asset: %w", err) + } + if !transfer.mint.Equals(mint) { + return fmt.Errorf("invalid_exact_svm_payload_transaction_mint") + } + expectedAmount, err := strconv.ParseUint(requirement.Amount, 10, 64) + if err != nil { + return fmt.Errorf("invalid amount: %w", err) + } + if transfer.amount != expectedAmount { + return fmt.Errorf("invalid_exact_svm_payload_transaction_amount") + } + payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) + if err != nil { + return fmt.Errorf("invalid payTo: %w", err) + } + expectedDestination, _, err := solana.FindAssociatedTokenAddressWithProgram(payTo, mint, transfer.tokenProgram) + if err != nil { + return err + } + if !transfer.destination.Equals(expectedDestination) { + return fmt.Errorf("invalid_exact_svm_payload_transaction_destination") + } + if decimals, err := strconv.ParseUint(fmt.Sprint(requirement.Extra["decimals"]), 10, 8); err == nil && transfer.decimals != uint8(decimals) { + return fmt.Errorf("invalid_exact_svm_payload_transaction_decimals") + } + return nil +} + +func verifyComputeLimitInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) error { + program, err := programID(transaction, instruction) + if err != nil { + return err + } + if !program.Equals(computeBudgetProgramID) || len(instruction.Data) != 5 || instruction.Data[0] != 2 { + return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction") + } + return nil +} + +func verifyComputePriceInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) error { + program, err := programID(transaction, instruction) + if err != nil { + return err + } + if !program.Equals(computeBudgetProgramID) || len(instruction.Data) != 9 || instruction.Data[0] != 3 { + return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_price_instruction") + } + price := binary.LittleEndian.Uint64(instruction.Data[1:]) + if price > maxComputeUnitPrice { + return fmt.Errorf("invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high") + } + return nil +} + +func parseTransferCheckedInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction) (transferCheckedFields, error) { + program, err := programID(transaction, instruction) + if err != nil { + return transferCheckedFields{}, err + } + if !program.Equals(solana.TokenProgramID) && !program.Equals(solana.Token2022ProgramID) { + return transferCheckedFields{}, fmt.Errorf("invalid_exact_svm_payload_transaction_transfer_program") + } + if len(instruction.Accounts) < 4 || len(instruction.Data) != 10 || instruction.Data[0] != 12 { + return transferCheckedFields{}, fmt.Errorf("invalid_exact_svm_payload_transaction_transfer_checked") + } + source, err := accountAt(transaction, instruction.Accounts[0]) + if err != nil { + return transferCheckedFields{}, err + } + mint, err := accountAt(transaction, instruction.Accounts[1]) + if err != nil { + return transferCheckedFields{}, err + } + destination, err := accountAt(transaction, instruction.Accounts[2]) + if err != nil { + return transferCheckedFields{}, err + } + authority, err := accountAt(transaction, instruction.Accounts[3]) + if err != nil { + return transferCheckedFields{}, err + } + return transferCheckedFields{ + source: source, + mint: mint, + destination: destination, + authority: authority, + amount: binary.LittleEndian.Uint64(instruction.Data[1:9]), + decimals: instruction.Data[9], + tokenProgram: program, + }, nil +} + +func verifyOptionalInstructions(transaction *solana.Transaction, instructions []solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) error { + memoCount := 0 + expectedMemo, hasExpectedMemo := requirement.Extra["memo"].(string) + invalidReasonByIndex := []string{ + "invalid_exact_svm_payload_unknown_fourth_instruction", + "invalid_exact_svm_payload_unknown_fifth_instruction", + "invalid_exact_svm_payload_unknown_sixth_instruction", + } + for index, instruction := range instructions { + program, err := programID(transaction, instruction) + if err != nil { + return err + } + if program.Equals(memoProgramID) { + memoCount++ + memo := string(instruction.Data) + if len([]byte(memo)) > maxMemoBytes { + return fmt.Errorf("extra.memo exceeds maximum %d bytes", maxMemoBytes) + } + if hasExpectedMemo && memo != expectedMemo { + return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") + } + if !hasExpectedMemo && memo == "" { + return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") + } + continue + } + if program.String() == lighthouseProgram { + // Pass through Lighthouse instructions by program-id match only, + // mirroring rust/src/protocol/schemes/exact/verify.rs:266 and + // typescript/packages/x402/src/facilitator/exact/scheme.ts:300. + continue + } + if program.Equals(solana.SPLAssociatedTokenAccountProgramID) && validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) { + continue + } + if index < len(invalidReasonByIndex) { + return fmt.Errorf("%s", invalidReasonByIndex[index]) + } + return fmt.Errorf("invalid_exact_svm_payload_unknown_optional_instruction") + } + if hasExpectedMemo && memoCount != 1 { + return fmt.Errorf("invalid_exact_svm_payload_transaction_memo") + } + return nil +} + +// isValidatedATACreateInstruction returns true when `instruction` is an +// SPL Associated Token Account program create that targets the payment's +// destination ATA — i.e. the only optional instruction in which the facilitator +// fee-payer is permitted to appear (as the rent payer at accounts[0]). +func isValidatedATACreateInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) bool { + program, err := programID(transaction, instruction) + if err != nil { + return false + } + if !program.Equals(solana.SPLAssociatedTokenAccountProgramID) { + return false + } + return validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) +} + +func validDestinationATACreateInstruction(transaction *solana.Transaction, instruction solana.CompiledInstruction, requirement paymentRequirement, transfer transferCheckedFields) bool { + if len(instruction.Data) > 1 { + return false + } + if len(instruction.Data) == 1 && instruction.Data[0] != 0 && instruction.Data[0] != 1 { + return false + } + if len(instruction.Accounts) < 6 { + return false + } + associatedAccount, err := accountAt(transaction, instruction.Accounts[1]) + if err != nil || !associatedAccount.Equals(transfer.destination) { + return false + } + wallet, err := accountAt(transaction, instruction.Accounts[2]) + if err != nil { + return false + } + payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) + if err != nil || !wallet.Equals(payTo) { + return false + } + mint, err := accountAt(transaction, instruction.Accounts[3]) + if err != nil || !mint.Equals(transfer.mint) { + return false + } + systemProgram, err := accountAt(transaction, instruction.Accounts[4]) + if err != nil || !systemProgram.Equals(solana.SystemProgramID) { + return false + } + tokenProgram, err := accountAt(transaction, instruction.Accounts[5]) + if err != nil || !tokenProgram.Equals(transfer.tokenProgram) { + return false + } + return true +} + +func programID(transaction *solana.Transaction, instruction solana.CompiledInstruction) (solana.PublicKey, error) { + return accountAt(transaction, instruction.ProgramIDIndex) +} + +func accountAt(transaction *solana.Transaction, index uint16) (solana.PublicKey, error) { + if int(index) >= len(transaction.Message.AccountKeys) { + return solana.PublicKey{}, fmt.Errorf("invalid account index: %d", index) + } + return transaction.Message.AccountKeys[index], nil +} + +func verifyTokenAccountsExist(state serverState, transaction *solana.Transaction, requirement paymentRequirement) error { + transfer, err := parseTransferCheckedInstruction(transaction, transaction.Message.Instructions[2]) + if err != nil { + return err + } + if exists, err := accountExists(state, transfer.source); err != nil { + return err + } else if !exists { + return fmt.Errorf("source token account does not exist") + } + if hasDestinationATACreateInstruction(transaction, requirement, transfer) { + return nil + } + if exists, err := accountExists(state, transfer.destination); err != nil { + return err + } else if !exists { + return fmt.Errorf("destination token account does not exist") + } + return nil +} + +func hasDestinationATACreateInstruction(transaction *solana.Transaction, requirement paymentRequirement, transfer transferCheckedFields) bool { + for _, instruction := range transaction.Message.Instructions[3:] { + program, err := programID(transaction, instruction) + if err != nil || !program.Equals(solana.SPLAssociatedTokenAccountProgramID) { + continue + } + if validDestinationATACreateInstruction(transaction, instruction, requirement, transfer) { + return true + } + } + return false +} + +func accountExists(state serverState, account solana.PublicKey) (bool, error) { + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "getAccountInfo", + "params": []any{ + account.String(), + map[string]any{"encoding": "base64"}, + }, + }) + if err != nil { + return false, err + } + response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) + if err != nil { + return false, err + } + defer func() { _ = response.Body.Close() }() + rawBody, err := io.ReadAll(response.Body) + if err != nil { + return false, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return false, fmt.Errorf("getAccountInfo HTTP %d: %s", response.StatusCode, string(rawBody)) + } + var payload struct { + Result *struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + Error any `json:"error"` + } + if err := json.Unmarshal(rawBody, &payload); err != nil { + return false, err + } + if payload.Error != nil { + return false, fmt.Errorf("getAccountInfo RPC error: %v", payload.Error) + } + if payload.Result == nil || len(payload.Result.Value) == 0 || string(payload.Result.Value) == "null" { + return false, nil + } + return true, nil +} + +func sendTransaction(state serverState, transaction *solana.Transaction) (string, error) { + encodedTransaction, err := transaction.ToBase64() + if err != nil { + return "", err + } + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "sendTransaction", + "params": []any{ + encodedTransaction, + map[string]any{ + "encoding": "base64", + "skipPreflight": false, + "preflightCommitment": "processed", + "maxRetries": 3, + }, + }, + }) + if err != nil { + return "", err + } + + response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) + if err != nil { + return "", err + } + defer func() { _ = response.Body.Close() }() + rawBody, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", fmt.Errorf("sendTransaction HTTP %d: %s", response.StatusCode, string(rawBody)) + } + + var payload struct { + Result string `json:"result"` + Error any `json:"error"` + } + if err := json.Unmarshal(rawBody, &payload); err != nil { + return "", err + } + if payload.Error != nil { + return "", fmt.Errorf("sendTransaction RPC error: %v", payload.Error) + } + if payload.Result == "" { + return "", fmt.Errorf("sendTransaction returned empty signature") + } + return payload.Result, nil +} + +// awaitSignatureConfirmation polls `getSignatureStatuses` until the +// signature reaches `confirmed` or `finalized` commitment. It returns an +// error on explicit RPC error, an on-chain transaction failure +// (status.err non-null), or when the poll budget elapses (the bounded +// stand-in for blockhash-window expiry; a signature that has not been +// observed within this window is treated as not landed so the caller +// MUST NOT mark the signature as consumed). Mirrors the canonical loop +// in MPP `server/charge.rs:761-784`. +func awaitSignatureConfirmation(state serverState, signature string) error { + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "getSignatureStatuses", + "params": []any{ + []string{signature}, + map[string]any{"searchTransactionHistory": false}, + }, + }) + if err != nil { + return err + } + for attempt := 0; attempt < confirmationPollAttempts; attempt++ { + response, err := state.httpClient.Post(state.rpcURL, "application/json", bytes.NewReader(requestBody)) + if err != nil { + return fmt.Errorf("getSignatureStatuses transport: %w", err) + } + rawBody, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + if readErr != nil { + return readErr + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("getSignatureStatuses HTTP %d: %s", response.StatusCode, string(rawBody)) + } + var payload struct { + Result *struct { + Value []*struct { + Confirmations *uint64 `json:"confirmations"` + ConfirmationStatus string `json:"confirmationStatus"` + Err any `json:"err"` + } `json:"value"` + } `json:"result"` + Error any `json:"error"` + } + if err := json.Unmarshal(rawBody, &payload); err != nil { + return err + } + if payload.Error != nil { + return fmt.Errorf("getSignatureStatuses RPC error: %v", payload.Error) + } + if payload.Result != nil && len(payload.Result.Value) > 0 && payload.Result.Value[0] != nil { + status := payload.Result.Value[0] + if status.Err != nil { + return fmt.Errorf("transaction failed on-chain: %v", status.Err) + } + if status.ConfirmationStatus == "confirmed" || status.ConfirmationStatus == "finalized" { + return nil + } + } + if attempt < confirmationPollAttempts-1 { + time.Sleep(confirmationPollInterval) + } + } + return fmt.Errorf("transaction not confirmed within timeout") +} + +func newInteropMux(state serverState) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusOK, map[string]any{"ok": true}) + }) + mux.HandleFunc("/capabilities", func(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusOK, capabilityPayload("go")) + }) + mux.HandleFunc("/exact", func(response http.ResponseWriter, _ *http.Request) { + writeExactPaymentRequired(response, state) + }) + mux.HandleFunc("/upto", func(response http.ResponseWriter, _ *http.Request) { + writePaymentRequired(response, uptoChallengePayload()) + }) + mux.HandleFunc("/session", func(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusPaymentRequired, sessionChallengePayload()) + }) + mux.HandleFunc("/batch-settlement", func(response http.ResponseWriter, _ *http.Request) { + writePaymentRequired(response, batchSettlementChallengePayload()) + }) + mux.HandleFunc("/", func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != defaultResourcePath { + writeJSON(response, http.StatusNotFound, map[string]any{"error": "not_found"}) + return + } + + paymentSignature := request.Header.Get("PAYMENT-SIGNATURE") + if paymentSignature == "" { + writeExactPaymentRequired(response, state) + return + } + + settlement, err := settleExactPayment(state, paymentSignature) + if err != nil { + challenge := exactChallengePayload(state) + encoded, marshalErr := json.Marshal(challenge) + if marshalErr != nil { + panic(marshalErr) + } + writeJSONWithHeaders( + response, + http.StatusPaymentRequired, + map[string]string{"PAYMENT-REQUIRED": base64.StdEncoding.EncodeToString(encoded)}, + map[string]any{ + "error": "payment_invalid", + "message": err.Error(), + }, + ) + return + } + + writeJSONWithHeaders( + response, + http.StatusOK, + map[string]string{defaultSettlementHeader: settlement}, + map[string]any{ + "ok": true, + "paid": true, + "settlement": map[string]any{ + "success": true, + "transaction": settlement, + "network": state.network, + }, + }, + ) + }) + return mux +} + +func runInteropServer(state serverState, listener net.Listener, signals <-chan os.Signal, readyWriter io.Writer, errWriter io.Writer) error { + server := &http.Server{Handler: newInteropMux(state)} + serveErr := make(chan error, 1) + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + serveErr <- err + } + close(serveErr) + }() + + ready := capabilityPayload("go") + ready["type"] = "ready" + ready["port"] = listener.Addr().(*net.TCPAddr).Port + encoded, err := json.Marshal(ready) + if err != nil { + return err + } + if _, err := fmt.Fprintln(readyWriter, string(encoded)); err != nil { + return err + } + + select { + case <-signals: + if err := server.Close(); err != nil { + _, _ = fmt.Fprintln(errWriter, err) + return err + } + return nil + case err := <-serveErr: + if err != nil { + _, _ = fmt.Fprintln(errWriter, err) + } + return err + } +} + +func main() { + state := readState() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + panic(err) + } + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) + if err := runInteropServer(state, listener, signals, os.Stdout, os.Stderr); err != nil { + os.Exit(1) + } +} diff --git a/go/x402/cmd/interop-server/main_test.go b/go/x402/cmd/interop-server/main_test.go new file mode 100644 index 000000000..30c1acfe7 --- /dev/null +++ b/go/x402/cmd/interop-server/main_test.go @@ -0,0 +1,2714 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/gagliardetto/solana-go" +) + +func TestNormalizeAmountUsesSixMintDecimals(t *testing.T) { + tests := map[string]string{ + "$0.001": "1000", + "0.001 USDC": "1000", + "1": "1000000", + "1.25": "1250000", + } + + for price, expected := range tests { + if actual := normalizeAmount(price); actual != expected { + t.Fatalf("normalizeAmount(%q) = %q, want %q", price, actual, expected) + } + } +} + +func TestNormalizeAmountRejectsMalformedPrices(t *testing.T) { + tests := []string{ + "not-a-price", + "1.0000001", + "1.bad", + } + + for _, price := range tests { + t.Run(price, func(t *testing.T) { + mustPanic(t, func() { + normalizeAmount(price) + }) + }) + } +} + +func TestEnvHelpersAndReadState(t *testing.T) { + privateKey, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encodedKey, err := json.Marshal([]byte(privateKey)) + if err != nil { + t.Fatal(err) + } + payTo := solana.NewWallet().PublicKey().String() + + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + t.Setenv("X402_INTEROP_PAY_TO", payTo) + t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) + t.Setenv("X402_INTEROP_NETWORK", solanaMainnetCAIP2) + t.Setenv("X402_INTEROP_MINT", "USDG") + t.Setenv("X402_INTEROP_PRICE", "$1.25") + t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", " PYUSD, , CASH ") + + state := readState() + if state.rpcURL != "http://rpc.test" || state.network != solanaMainnetCAIP2 { + t.Fatalf("unexpected state: %+v", state) + } + if state.mint != "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH" { + t.Fatalf("expected resolved USDG mainnet mint, got %s", state.mint) + } + if state.payTo != payTo || !state.feePayer.PublicKey().Equals(privateKey.PublicKey()) { + t.Fatalf("readState did not preserve configured keys") + } + if state.amount != "1250000" { + t.Fatalf("amount = %s, want 1250000", state.amount) + } + if len(state.extraOfferedMints) != 2 || + state.extraOfferedMints[0] != "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" || + state.extraOfferedMints[1] != "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH" { + t.Fatalf("unexpected extra mints: %#v", state.extraOfferedMints) + } + if state.httpClient == nil { + t.Fatal("expected readState to configure an HTTP client") + } + if got := readEnvWithDefault("X402_INTEROP_NETWORK", "fallback"); got != solanaMainnetCAIP2 { + t.Fatalf("readEnvWithDefault configured = %q", got) + } + if got := readEnvWithDefault("X402_INTEROP_MISSING", "fallback"); got != "fallback" { + t.Fatalf("readEnvWithDefault fallback = %q", got) + } + + t.Setenv("X402_INTEROP_REQUIRED_EMPTY", "") + mustPanic(t, func() { + readRequiredEnv("X402_INTEROP_REQUIRED_EMPTY") + }) + mustPanic(t, func() { + keypairFromJSONSecret("[1,2,3]") + }) + mustPanic(t, func() { + keypairFromJSONSecret("{") + }) +} + +func TestJSONWritersAndChallengePayloads(t *testing.T) { + recorder := httptest.NewRecorder() + writeJSON(recorder, http.StatusAccepted, map[string]any{"ok": true}) + if recorder.Code != http.StatusAccepted { + t.Fatalf("status = %d", recorder.Code) + } + if recorder.Header().Get("content-type") != "application/json" { + t.Fatalf("unexpected content type: %s", recorder.Header().Get("content-type")) + } + if strings.TrimSpace(recorder.Body.String()) != `{"ok":true}` { + t.Fatalf("unexpected JSON body: %s", recorder.Body.String()) + } + + recorder = httptest.NewRecorder() + writeJSONWithHeaders(recorder, http.StatusCreated, map[string]string{"x-test": "value"}, map[string]any{"created": true}) + if recorder.Code != http.StatusCreated || recorder.Header().Get("x-test") != "value" { + t.Fatalf("headers/status not written: %d %v", recorder.Code, recorder.Header()) + } + + capabilities := capabilityPayload("go") + if capabilities["implementation"] != "go" || capabilities["role"] != "server" { + t.Fatalf("unexpected capability payload: %#v", capabilities) + } + if got := len(capabilities["capabilities"].([]string)); got != 1 { + t.Fatalf("expected one implemented capability, got %d", got) + } + + state := testServerState(t) + state.memo = "bound-memo" + exact := exactChallengePayload(state) + if exact.X402Version != 2 || exact.Resource["uri"] != defaultResourcePath { + t.Fatalf("unexpected exact challenge: %+v", exact) + } + if exact.Accepts[0].Extra["memo"] != "bound-memo" { + t.Fatalf("expected exact requirement to include memo") + } + if uptoChallengePayload()["x402Version"] != 2 { + t.Fatal("expected x402 upto challenge") + } + if sessionChallengePayload()["intent"] != "session" { + t.Fatal("expected session challenge intent") + } + if batchSettlementChallengePayload()["x402Version"] != 2 { + t.Fatal("expected batch settlement challenge") + } +} + +func TestPaymentRequiredWritersEncodeChallenges(t *testing.T) { + state := testServerState(t) + + recorder := httptest.NewRecorder() + writePaymentRequired(recorder, uptoChallengePayload()) + if recorder.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d", recorder.Code) + } + decoded, err := base64.StdEncoding.DecodeString(recorder.Header().Get("PAYMENT-REQUIRED")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(decoded), `"scheme":"upto"`) { + t.Fatalf("unexpected encoded challenge: %s", string(decoded)) + } + + recorder = httptest.NewRecorder() + writeExactPaymentRequired(recorder, state) + decoded, err = base64.StdEncoding.DecodeString(recorder.Header().Get("PAYMENT-REQUIRED")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(decoded), `"scheme":"exact"`) { + t.Fatalf("unexpected exact challenge: %s", string(decoded)) + } +} + +func TestDefaultTokenProgramForMintHandlesAliasesAndMints(t *testing.T) { + tests := map[string]string{ + " PYUSD ": token2022Program, + "USDG": token2022Program, + "CASH": token2022Program, + "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM": token2022Program, + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": defaultTokenProgram, + } + + for mint, want := range tests { + t.Run(mint, func(t *testing.T) { + if got := defaultTokenProgramForMint(mint); got != want { + t.Fatalf("defaultTokenProgramForMint(%q) = %q, want %q", mint, got, want) + } + }) + } +} + +func TestPaymentRequirementMatchesBindsSettlementFields(t *testing.T) { + feePayer := solana.NewWallet().PrivateKey + state := serverState{ + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + payTo: solana.NewWallet().PublicKey().String(), + feePayer: feePayer, + amount: "1000", + } + + requirement := exactRequirement(state) + if !paymentRequirementMatches(requirement, requirement) { + t.Fatal("expected matching requirement to pass") + } + + mutated := requirement + mutated.Extra = map[string]any{ + "decimals": defaultDecimals, + "feePayer": solana.NewWallet().PublicKey().String(), + "tokenProgram": defaultTokenProgram, + } + if paymentRequirementMatches(mutated, requirement) { + t.Fatal("expected fee payer mutation to be rejected") + } +} + +func TestPaymentRequirementMatchesRejectsExactRequirementDrift(t *testing.T) { + feePayer := solana.NewWallet().PrivateKey + state := serverState{ + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + payTo: solana.NewWallet().PublicKey().String(), + feePayer: feePayer, + amount: "1000", + } + + requirement := exactRequirement(state) + tests := map[string]func(paymentRequirement) paymentRequirement{ + "scheme": func(value paymentRequirement) paymentRequirement { + value.Scheme = "upto" + return value + }, + "network": func(value paymentRequirement) paymentRequirement { + value.Network = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + return value + }, + "asset": func(value paymentRequirement) paymentRequirement { + value.Asset = solana.NewWallet().PublicKey().String() + return value + }, + "amount": func(value paymentRequirement) paymentRequirement { + value.Amount = "2000" + return value + }, + "payTo": func(value paymentRequirement) paymentRequirement { + value.PayTo = solana.NewWallet().PublicKey().String() + return value + }, + "maxTimeoutSeconds": func(value paymentRequirement) paymentRequirement { + value.MaxTimeoutSeconds = defaultMaxTimeout + 1 + return value + }, + "extra.tokenProgram": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneExtra(value.Extra) + value.Extra["tokenProgram"] = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + return value + }, + "extra.unexpected": func(value paymentRequirement) paymentRequirement { + value.Extra = cloneExtra(value.Extra) + value.Extra["memo"] = "drift" + return value + }, + } + + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + if paymentRequirementMatches(mutate(requirement), requirement) { + t.Fatalf("expected %s drift to be rejected", name) + } + }) + } +} + +func TestExactChallengeIncludesExtraOfferedMints(t *testing.T) { + feePayer := solana.NewWallet().PrivateKey + state := serverState{ + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + payTo: solana.NewWallet().PublicKey().String(), + feePayer: feePayer, + amount: "1000", + extraOfferedMints: []string{"CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"}, + } + + challenge := exactChallengePayload(state) + + if len(challenge.Accepts) != 2 { + t.Fatalf("expected primary plus extra mint offers, got %d", len(challenge.Accepts)) + } + if challenge.Accepts[0].Asset != state.mint { + t.Fatalf("expected primary mint first, got %s", challenge.Accepts[0].Asset) + } + if challenge.Accepts[1].Asset != state.extraOfferedMints[0] { + t.Fatalf("expected extra mint second, got %s", challenge.Accepts[1].Asset) + } + if challenge.Accepts[1].Extra["tokenProgram"] != "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" { + t.Fatalf("expected PYUSD offer to use Token-2022, got %v", challenge.Accepts[1].Extra["tokenProgram"]) + } +} + +func TestSettleExactPaymentRejectsMalformedPaymentSignature(t *testing.T) { + state := testServerState(t) + state.memo = "unit-duplicate" + + tests := map[string]string{ + "base64": "not base64", + "json": base64.StdEncoding.EncodeToString([]byte("{")), + } + + for name, header := range tests { + t.Run(name, func(t *testing.T) { + if _, err := settleExactPayment(state, header); err == nil { + t.Fatal("expected malformed payment signature to be rejected") + } + }) + } +} + +func TestSettleExactPaymentRejectsMissingAndInvalidTransactionPayload(t *testing.T) { + state := testServerState(t) + requirement := exactRequirement(state) + tests := map[string]map[string]string{ + "missing": {}, + "invalid": {"transaction": "not a transaction"}, + } + + for name, payload := range tests { + t.Run(name, func(t *testing.T) { + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: payload, + }) + + if _, err := settleExactPayment(state, header); err == nil { + t.Fatal("expected transaction payload to be rejected") + } + }) + } +} + +func TestSettleExactPaymentRejectsVersionAndRequirementMismatch(t *testing.T) { + state := testServerState(t) + requirement := exactRequirement(state) + + versionHeader := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 1, + Accepted: requirement, + Payload: map[string]string{"transaction": "unused"}, + }) + if _, err := settleExactPayment(state, versionHeader); err == nil || err.Error() != "unsupported x402Version: 1" { + t.Fatalf("expected version rejection, got %v", err) + } + + drifted := requirement + drifted.Amount = "999" + driftHeader := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: drifted, + Payload: map[string]string{"transaction": "unused"}, + }) + if _, err := settleExactPayment(state, driftHeader); err == nil || err.Error() != "accepted payment requirement does not match server challenge" { + t.Fatalf("expected requirement mismatch, got %v", err) + } +} + +func successfulSettlementClient(t *testing.T, signature string) *http.Client { + t.Helper() + return &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + switch { + case strings.Contains(body, `"method":"sendTransaction"`): + responseBody = fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"result":%q}`, signature) + case strings.Contains(body, `"method":"getSignatureStatuses"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } +} + +func TestSettleExactPaymentAcceptsExtraOfferedMint(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.extraOfferedMints = []string{"CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"} + state.memo = "extra-mint" + state.httpClient = successfulSettlementClient(t, "extra-mint-settlement") + defer func() { + settlementCache = newDuplicateSettlementCache() + }() + + requirement := exactRequirementForMint(state, state.extraOfferedMints[0]) + transaction := signedTransactionForTest(t, requirement, client) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{ + "transaction": transaction, + }, + }) + + settlement, err := settleExactPayment(state, header) + if err != nil { + t.Fatalf("expected extra offered mint settlement to pass: %v", err) + } + if settlement != "extra-mint-settlement" { + t.Fatalf("settlement = %q", settlement) + } +} + +func TestSettleExactPaymentRejectsDuplicateTransactionPayload(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-duplicate" + sendCalls := 0 + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + switch { + case strings.Contains(body, `"method":"sendTransaction"`): + sendCalls++ + responseBody = `{"jsonrpc":"2.0","id":1,"result":"unit-settlement"}` + case strings.Contains(body, `"method":"getSignatureStatuses"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + defer func() { + settlementCache = newDuplicateSettlementCache() + }() + requirement := exactRequirement(state) + transaction := signedTransactionForTest(t, requirement, client) + + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{ + "transaction": transaction, + }, + }) + + if settlement, err := settleExactPayment(state, header); err != nil || settlement != "unit-settlement" { + t.Fatalf("first settlement = %q, %v", settlement, err) + } + if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { + t.Fatalf("expected duplicate_settlement, got %v", err) + } + // Under broadcast-first L8 ordering, the duplicate transaction does + // reach sendTransaction (Solana itself is the global uniqueness + // primitive: a re-broadcast of the same signed tx is idempotent + // within its blockhash window). The replay-store check only fires + // post-confirmation, so the second call broadcasts and then is + // rejected at putIfAbsent because the signature was already + // consumed by the first successful settlement. + if sendCalls != 2 { + t.Fatalf("expected two sendTransaction calls under broadcast-first ordering, got %d", sendCalls) + } +} + +// TestSettleExactPaymentDoesNotConsumeReplayKeyOnPreBroadcastFailure covers +// the L8 ordering invariant: a verification failure before broadcast (here, +// a missing source token account) MUST NOT insert anything into the +// replay-store. The proof is that an immediate retry of the same envelope +// produces the same pre-broadcast error (rather than being rejected as a +// duplicate settlement). Under broadcast-first ordering there is no +// release-on-failure path; correctness follows from "never inserted in +// the first place" instead. +func TestSettleExactPaymentDoesNotConsumeReplayKeyOnPreBroadcastFailure(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { + settlementCache = newDuplicateSettlementCache() + }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-missing-ata" + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":null}}`)), + }, nil + } + t.Fatalf("unexpected RPC body: %s", string(rawBody)) + return nil, nil + }), + } + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{ + "transaction": signedTransactionForTest(t, requirement, client), + }, + }) + + if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { + t.Fatalf("expected missing source account, got %v", err) + } + if _, err := settleExactPayment(state, header); err == nil || err.Error() != "source token account does not exist" { + t.Fatalf("expected retry to surface the same pre-broadcast error (replay key never inserted), got %v", err) + } +} + +// TestSettleExactPaymentL8OrderingObserved asserts the L8 RPC call +// sequence: getAccountInfo (token-account existence) → sendTransaction +// (broadcast) → getSignatureStatuses (await confirmation) → replay store +// insert. The replay store insert is observable through a duplicate retry +// returning duplicate_settlement on the SAME signature, without any RPC +// activity ordered after putIfAbsent. +func TestSettleExactPaymentL8OrderingObserved(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "l8-ordering" + var rpcCalls []string + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + switch { + case strings.Contains(body, `"method":"sendTransaction"`): + rpcCalls = append(rpcCalls, "sendTransaction") + responseBody = `{"jsonrpc":"2.0","id":1,"result":"l8-sig"}` + case strings.Contains(body, `"method":"getSignatureStatuses"`): + rpcCalls = append(rpcCalls, "getSignatureStatuses") + responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` + case strings.Contains(body, `"method":"getAccountInfo"`): + rpcCalls = append(rpcCalls, "getAccountInfo") + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + }) + + signature, err := settleExactPayment(state, header) + if err != nil { + t.Fatalf("expected first settlement to succeed, got %v", err) + } + if signature != "l8-sig" { + t.Fatalf("signature = %q", signature) + } + // Drop pre-broadcast getAccountInfo calls; the load-bearing assertion + // is that broadcast precedes confirmation polling, which precedes the + // replay-store insert (proven by the subsequent duplicate_settlement). + var phaseOrder []string + for _, call := range rpcCalls { + if call == "sendTransaction" || call == "getSignatureStatuses" { + phaseOrder = append(phaseOrder, call) + } + } + if len(phaseOrder) < 2 || phaseOrder[0] != "sendTransaction" || phaseOrder[1] != "getSignatureStatuses" { + t.Fatalf("expected sendTransaction before getSignatureStatuses, got %v", phaseOrder) + } + if _, ok := settlementCache.entries[replayKeyNamespace+signature]; !ok { + t.Fatalf("expected replay key %q to be present after confirmation", replayKeyNamespace+signature) + } +} + +// TestSettleExactPaymentDoesNotConsumeReplayKeyOnBroadcastFailure covers +// the L8 invariant that an RPC failure during broadcast (before +// confirmation) MUST NOT insert the replay key. Mirrors MPP +// `server/charge.rs` semantics: only a confirmed signature is consumed. +func TestSettleExactPaymentDoesNotConsumeReplayKeyOnBroadcastFailure(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "l8-broadcast-fail" + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + if strings.Contains(body, `"method":"sendTransaction"`) { + responseBody = `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"blockhash not found"}}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + }) + + if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "sendTransaction RPC error") { + t.Fatalf("expected broadcast RPC error, got %v", err) + } + if len(settlementCache.entries) != 0 { + t.Fatalf("expected empty replay cache after broadcast failure, got %d entries", len(settlementCache.entries)) + } +} + +// TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure +// covers the L8 invariant that an on-chain failure surfaced via +// getSignatureStatuses (e.g. tx landed but reverted) MUST NOT insert +// the replay key — a future re-broadcast under a fresh blockhash is the +// caller's option, not a duplicate. +func TestSettleExactPaymentDoesNotConsumeReplayKeyOnConfirmationFailure(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "l8-confirm-fail" + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + switch { + case strings.Contains(body, `"method":"sendTransaction"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":"reverted-sig"}` + case strings.Contains(body, `"method":"getSignatureStatuses"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]}}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + }) + + if _, err := settleExactPayment(state, header); err == nil || !strings.Contains(err.Error(), "transaction failed on-chain") { + t.Fatalf("expected on-chain failure, got %v", err) + } + if _, ok := settlementCache.entries[replayKeyNamespace+"reverted-sig"]; ok { + t.Fatalf("expected replay key NOT to be consumed when confirmation surfaces on-chain failure") + } +} + +// TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature covers +// the L8 invariant that a putIfAbsent collision (signature already +// consumed) surfaces the canonical duplicate_settlement error and does +// not echo a fresh PAYMENT-RESPONSE. +func TestSettleExactPaymentReturnsCanonicalErrorForConsumedSignature(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "l8-pre-consumed" + state.httpClient = successfulSettlementClient(t, "pre-consumed-sig") + // Simulate a prior successful settlement having already inserted + // the canonical replay key for this signature. + settlementCache.entries[replayKeyNamespace+"pre-consumed-sig"] = time.Now() + + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + }) + if _, err := settleExactPayment(state, header); err == nil || err.Error() != "duplicate_settlement" { + t.Fatalf("expected duplicate_settlement on already-consumed signature, got %v", err) + } +} + +// TestSettleExactPaymentConcurrentDuplicatesCollapse asserts that two +// concurrent settlements producing the same signature collapse to a +// single successful settle and one canonical duplicate_settlement. +// Solana's per-signature replay protection guarantees the on-chain +// effect is single; the putIfAbsent collision in the replay store +// guarantees the off-chain accounting is single. +func TestSettleExactPaymentConcurrentDuplicatesCollapse(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "l8-concurrent" + state.httpClient = successfulSettlementClient(t, "concurrent-sig") + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": signedTransactionForTest(t, requirement, client)}, + }) + + const concurrency = 4 + var wg sync.WaitGroup + results := make([]error, concurrency) + signatures := make([]string, concurrency) + wg.Add(concurrency) + for i := 0; i < concurrency; i++ { + go func(idx int) { + defer wg.Done() + signatures[idx], results[idx] = settleExactPayment(state, header) + }(i) + } + wg.Wait() + + successes := 0 + duplicates := 0 + for i, err := range results { + switch { + case err == nil: + successes++ + if signatures[i] != "concurrent-sig" { + t.Fatalf("unexpected signature %q", signatures[i]) + } + case err.Error() == "duplicate_settlement": + duplicates++ + default: + t.Fatalf("unexpected error %v", err) + } + } + if successes != 1 || duplicates != concurrency-1 { + t.Fatalf("expected 1 success + %d duplicates, got %d / %d", concurrency-1, successes, duplicates) + } +} + +// TestAwaitSignatureConfirmationCases drills the L8 confirmation poll +// directly against the four observable RPC outcomes: confirmed/finalized +// success, on-chain failure, transport-level RPC error, and bounded +// timeout when no status ever surfaces. +func TestAwaitSignatureConfirmationCases(t *testing.T) { + prevAttempts := confirmationPollAttempts + prevInterval := confirmationPollInterval + confirmationPollAttempts = 3 + confirmationPollInterval = time.Millisecond + defer func() { + confirmationPollAttempts = prevAttempts + confirmationPollInterval = prevInterval + }() + + tests := map[string]struct { + responseBody string + wantErr string + }{ + "confirmed": { + responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}`, + wantErr: "", + }, + "finalized": { + responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":32,"err":null,"confirmationStatus":"finalized"}]}}`, + wantErr: "", + }, + "on-chain failure": { + responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]}}`, + wantErr: "transaction failed on-chain", + }, + "rpc error": { + responseBody: `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"boom"}}`, + wantErr: "getSignatureStatuses RPC error", + }, + "timeout": { + responseBody: `{"jsonrpc":"2.0","id":1,"result":{"value":[null]}}`, + wantErr: "transaction not confirmed within timeout", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(test.responseBody)), + }, nil + }), + } + err := awaitSignatureConfirmation(state, "sig") + switch { + case test.wantErr == "" && err != nil: + t.Fatalf("expected success, got %v", err) + case test.wantErr != "" && (err == nil || !strings.Contains(err.Error(), test.wantErr)): + t.Fatalf("expected error containing %q, got %v", test.wantErr, err) + } + }) + } +} + +func TestAwaitSignatureConfirmationTransportError(t *testing.T) { + prevAttempts := confirmationPollAttempts + confirmationPollAttempts = 1 + defer func() { confirmationPollAttempts = prevAttempts }() + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, fmt.Errorf("dial timeout") + }), + } + if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses transport") { + t.Fatalf("expected transport error, got %v", err) + } +} + +func TestAwaitSignatureConfirmationNon2xx(t *testing.T) { + prevAttempts := confirmationPollAttempts + confirmationPollAttempts = 1 + defer func() { confirmationPollAttempts = prevAttempts }() + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"boom"}`)), + }, nil + }), + } + if err := awaitSignatureConfirmation(state, "sig"); err == nil || !strings.Contains(err.Error(), "getSignatureStatuses HTTP 500") { + t.Fatalf("expected HTTP 500, got %v", err) + } +} + +func TestVerifyExactTransactionRejectsSpecViolations(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-spec" + requirement := exactRequirement(state) + valid := transactionForTest(t, requirement, client) + + tests := map[string]struct { + mutate func(*solana.Transaction) + want string + }{ + "compute price too high": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[1].Data = computePriceDataForTest(maxComputeUnitPrice + 1) + }, + want: "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high", + }, + "amount mismatch": { + mutate: func(tx *solana.Transaction) { + data := []byte{12} + data = binary.LittleEndian.AppendUint64(data, 999) + data = append(data, byte(defaultDecimals)) + tx.Message.Instructions[2].Data = data + }, + want: "invalid_exact_svm_payload_transaction_amount", + }, + "missing memo": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions = tx.Message.Instructions[:3] + }, + want: "invalid_exact_svm_payload_transaction_memo", + }, + "fee payer instruction account": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[2].Accounts[0] = 0 + }, + want: "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", + }, + "mint mismatch": { + mutate: func(tx *solana.Transaction) { + tx.Message.AccountKeys = append(tx.Message.AccountKeys, solana.NewWallet().PublicKey()) + tx.Message.Instructions[2].Accounts[1] = uint16(len(tx.Message.AccountKeys) - 1) + }, + want: "invalid_exact_svm_payload_transaction_mint", + }, + "decimals mismatch": { + mutate: func(tx *solana.Transaction) { + data := []byte{12} + data = binary.LittleEndian.AppendUint64(data, 1000) + data = append(data, byte(defaultDecimals+1)) + tx.Message.Instructions[2].Data = data + }, + want: "invalid_exact_svm_payload_transaction_decimals", + }, + "memo mismatch": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[3].Data = []byte("wrong") + }, + want: "invalid_exact_svm_payload_transaction_memo", + }, + "unknown fourth instruction": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[3] = compiledInstructionForTest(t, tx, solana.SystemProgramID.String(), nil) + }, + want: "invalid_exact_svm_payload_unknown_fourth_instruction", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + tx := cloneTransactionForTest(t, valid) + test.mutate(tx) + if err := verifyExactTransaction(tx, requirement); err == nil || err.Error() != test.want { + t.Fatalf("expected %q, got %v", test.want, err) + } + }) + } +} + +func TestVerifyExactTransactionRejectsMalformedInstructionShapes(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-shapes" + requirement := exactRequirement(state) + valid := transactionForTest(t, requirement, client) + + tests := map[string]struct { + mutate func(*solana.Transaction) + want string + }{ + "legacy transaction": { + mutate: func(tx *solana.Transaction) { + tx.Message.SetVersion(solana.MessageVersionLegacy) + }, + want: "payment transaction must be versioned", + }, + "too few instructions": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions = tx.Message.Instructions[:2] + }, + want: "invalid_exact_svm_payload_transaction_instructions_length", + }, + "bad compute limit": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[0].Data = []byte{2} + }, + want: "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", + }, + "bad compute price": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[1].Data = []byte{3} + }, + want: "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", + }, + "bad transfer program": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[2] = compiledInstructionForTest(t, tx, solana.SystemProgramID.String(), []byte{12}) + }, + want: "invalid_exact_svm_payload_transaction_transfer_program", + }, + "bad transfer data": { + mutate: func(tx *solana.Transaction) { + tx.Message.Instructions[2].Data = []byte{12} + }, + want: "invalid_exact_svm_payload_transaction_transfer_checked", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + tx := cloneTransactionForTest(t, valid) + test.mutate(tx) + if err := verifyExactTransaction(tx, requirement); err == nil || err.Error() != test.want { + t.Fatalf("expected %q, got %v", test.want, err) + } + }) + } +} + +func TestParseTransferCheckedInstructionRejectsInvalidAccountIndexes(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-transfer-indexes" + tx := transactionForTest(t, exactRequirement(state), client) + instruction := tx.Message.Instructions[2] + + tests := map[string]int{ + "source": 0, + "mint": 1, + "destination": 2, + "authority": 3, + } + + for name, accountIndex := range tests { + t.Run(name, func(t *testing.T) { + mutated := instruction + mutated.Accounts = append([]uint16(nil), instruction.Accounts...) + mutated.Accounts[accountIndex] = uint16(len(tx.Message.AccountKeys)) + if _, err := parseTransferCheckedInstruction(tx, mutated); err == nil { + t.Fatal("expected invalid account index") + } + }) + } +} + +func TestVerifyExactTransactionRejectsMalformedRequirementFields(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-requirement-fields" + requirement := exactRequirement(state) + valid := transactionForTest(t, requirement, client) + + tests := map[string]struct { + mutate func(paymentRequirement) paymentRequirement + want string + }{ + "fee payer": { + mutate: func(value paymentRequirement) paymentRequirement { + value.Extra = cloneExtra(value.Extra) + value.Extra["feePayer"] = "not-base58" + return value + }, + want: "invalid feePayer:", + }, + "asset": { + mutate: func(value paymentRequirement) paymentRequirement { + value.Asset = "not-base58" + return value + }, + want: "invalid asset:", + }, + "amount": { + mutate: func(value paymentRequirement) paymentRequirement { + value.Amount = "not-int" + return value + }, + want: "invalid amount:", + }, + "payTo": { + mutate: func(value paymentRequirement) paymentRequirement { + value.PayTo = "not-base58" + return value + }, + want: "invalid payTo:", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + err := verifyExactTransaction(cloneTransactionForTest(t, valid), test.mutate(requirement)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("expected %q, got %v", test.want, err) + } + }) + } +} + +func TestVerifyExactTransactionAllowsOptionalLighthouseBeforeMemo(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-lighthouse" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append( + []solana.CompiledInstruction{compiledInstructionForTest(t, tx, lighthouseProgram, []byte{9, 0})}, + tx.Message.Instructions[3:]..., + )..., + ) + + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected lighthouse before memo to be accepted, got %v", err) + } +} + +func TestVerifyExactTransactionAllowsValidDestinationATACreateInstruction(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-create-ata" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) + if err != nil { + t.Fatal(err) + } + payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) + + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append( + []solana.CompiledInstruction{ + compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + }, []byte{1}), + }, + tx.Message.Instructions[3:]..., + )..., + ) + + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected valid destination ATA create instruction to be accepted, got %v", err) + } +} + +func TestValidDestinationATACreateInstructionRejectsMalformedCreateInstructions(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-create-ata-invalid" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) + if err != nil { + t.Fatal(err) + } + payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) + validAccounts := []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + } + + tests := map[string]solana.CompiledInstruction{ + "bad data": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts, []byte{2}), + "too many data bytes": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts, []byte{0, 0}), + "too few accounts": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, validAccounts[:5], nil), + "wrong associated account": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + solana.NewWallet().PublicKey(), + payTo, + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + }, nil), + "wrong wallet": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + solana.NewWallet().PublicKey(), + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + }, nil), + "wrong mint": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + solana.NewWallet().PublicKey(), + solana.SystemProgramID, + transfer.tokenProgram, + }, nil), + "wrong system program": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + transfer.mint, + solana.NewWallet().PublicKey(), + transfer.tokenProgram, + }, nil), + "wrong token program": compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + transfer.mint, + solana.SystemProgramID, + solana.NewWallet().PublicKey(), + }, nil), + } + + for name, instruction := range tests { + t.Run(name, func(t *testing.T) { + if validDestinationATACreateInstruction(tx, instruction, requirement, transfer) { + t.Fatal("expected malformed destination ATA create instruction to be rejected") + } + }) + } +} + +func TestVerifyTokenAccountsExistSkipsMissingDestinationWhenCreateATAIsPresent(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-create-ata-exists" + accountInfoCalls := 0 + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { + t.Fatalf("unexpected RPC body: %s", string(rawBody)) + } + accountInfoCalls++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"context":{"slot":1},"value":{"data":["","base64"]}}}`)), + }, nil + }), + } + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) + if err != nil { + t.Fatal(err) + } + payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append( + []solana.CompiledInstruction{ + compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + client.PublicKey(), + transfer.destination, + payTo, + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + }, nil), + }, + tx.Message.Instructions[3:]..., + )..., + ) + + if err := verifyTokenAccountsExist(state, tx, requirement); err != nil { + t.Fatalf("expected create ATA instruction to satisfy destination existence policy, got %v", err) + } + if accountInfoCalls != 1 { + t.Fatalf("expected only source account lookup, got %d", accountInfoCalls) + } +} + +func TestVerifyTokenAccountsExistRejectsMissingDestinationWithoutCreateATA(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-missing-destination" + accountInfoCalls := 0 + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if _, err := io.ReadAll(request.Body); err != nil { + t.Fatal(err) + } + accountInfoCalls++ + body := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + if accountInfoCalls == 2 { + body = `{"jsonrpc":"2.0","id":1,"result":{"value":null}}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + + if err := verifyTokenAccountsExist(state, tx, requirement); err == nil || err.Error() != "destination token account does not exist" { + t.Fatalf("expected missing destination account, got %v", err) + } + if accountInfoCalls != 2 { + t.Fatalf("expected source and destination lookups, got %d", accountInfoCalls) + } +} + +func TestVerifyTokenAccountsExistAcceptsExistingSourceAndDestination(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-existing-atas" + accountInfoCalls := 0 + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) { + t.Fatalf("unexpected RPC body: %s", string(rawBody)) + } + accountInfoCalls++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}`)), + }, nil + }), + } + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + + if err := verifyTokenAccountsExist(state, tx, requirement); err != nil { + t.Fatalf("expected existing source and destination accounts, got %v", err) + } + if accountInfoCalls != 2 { + t.Fatalf("expected source and destination lookups, got %d", accountInfoCalls) + } +} + +func TestVerifyExactTransactionAllowsMissingMemoWhenRequirementDoesNotBindMemo(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "" + requirement := exactRequirement(state) + builderRequirement := requirement + builderRequirement.Extra = cloneExtra(requirement.Extra) + builderRequirement.Extra["memo"] = "builder-memo" + tx := transactionForTest(t, builderRequirement, client) + tx.Message.Instructions = tx.Message.Instructions[:3] + + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected missing memo to be accepted when requirement has no memo, got %v", err) + } +} + +func TestVerifyOptionalInstructionsRejectsMemoViolations(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-memo" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) + if err != nil { + t.Fatal(err) + } + + tests := map[string]struct { + requirement paymentRequirement + instructions []solana.CompiledInstruction + want string + }{ + "empty unbound memo": { + requirement: func() paymentRequirement { + value := requirement + value.Extra = cloneExtra(value.Extra) + delete(value.Extra, "memo") + return value + }(), + instructions: []solana.CompiledInstruction{compiledInstructionForTest(t, tx, memoProgramID.String(), nil)}, + want: "invalid_exact_svm_payload_transaction_memo", + }, + "oversized memo": { + requirement: func() paymentRequirement { + value := requirement + value.Extra = cloneExtra(value.Extra) + delete(value.Extra, "memo") + return value + }(), + instructions: []solana.CompiledInstruction{compiledInstructionForTest(t, tx, memoProgramID.String(), []byte(strings.Repeat("x", maxMemoBytes+1)))}, + want: "extra.memo exceeds maximum 256 bytes", + }, + "duplicate bound memo": { + requirement: requirement, + instructions: []solana.CompiledInstruction{ + compiledInstructionForTest(t, tx, memoProgramID.String(), []byte("unit-memo")), + compiledInstructionForTest(t, tx, memoProgramID.String(), []byte("unit-memo")), + }, + want: "invalid_exact_svm_payload_transaction_memo", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if err := verifyOptionalInstructions(tx, test.instructions, test.requirement, transfer); err == nil || err.Error() != test.want { + t.Fatalf("expected %q, got %v", test.want, err) + } + }) + } +} + +func TestDuplicateSettlementCachePrunesExpiredEntries(t *testing.T) { + cache := newDuplicateSettlementCache() + now := time.Unix(1_700_000_000, 0) + cache.now = func() time.Time { + return now + } + cache.entries["expired"] = now.Add(-(duplicateCacheTTL + time.Second)) + cache.entries["fresh"] = now + + if !cache.putIfAbsent("new") { + t.Fatal("expected new key to be inserted") + } + if _, ok := cache.entries["expired"]; ok { + t.Fatal("expected expired cache entry to be pruned") + } + if _, ok := cache.entries["fresh"]; !ok { + t.Fatal("expected fresh cache entry to survive pruning") + } + if !cache.putIfAbsent("expired") { + t.Fatal("expected pruned key to be re-insertable") + } + if cache.putIfAbsent("fresh") { + t.Fatal("expected fresh duplicate to be rejected") + } +} + +func TestAccountExistsHandlesRPCResponses(t *testing.T) { + account := solana.NewWallet().PublicKey() + tests := map[string]struct { + status int + body string + exists bool + err bool + }{ + "exists": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}`, + exists: true, + }, + "missing null value": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"result":{"value":null}}`, + exists: false, + }, + "missing result": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1}`, + exists: false, + }, + "rpc error": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`, + err: true, + }, + "http error": { + status: http.StatusBadGateway, + body: `bad gateway`, + err: true, + }, + "invalid json": { + status: http.StatusOK, + body: `{`, + err: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"getAccountInfo"`) || !strings.Contains(string(rawBody), account.String()) { + t.Fatalf("unexpected accountExists RPC body: %s", string(rawBody)) + } + return &http.Response{ + StatusCode: test.status, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(test.body)), + }, nil + }), + } + + exists, err := accountExists(state, account) + if test.err { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatal(err) + } + if exists != test.exists { + t.Fatalf("exists = %v, want %v", exists, test.exists) + } + }) + } +} + +func TestAccountExistsReturnsTransportErrors(t *testing.T) { + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("rpc unavailable") + }), + } + + if _, err := accountExists(state, solana.NewWallet().PublicKey()); err == nil { + t.Fatal("expected transport error") + } +} + +func TestSendTransactionHandlesRPCResponses(t *testing.T) { + baseState := testServerState(t) + baseState.memo = "unit-send" + tx := transactionForTest(t, exactRequirement(baseState), solana.NewWallet().PrivateKey) + tests := map[string]struct { + status int + body string + want string + err bool + }{ + "success": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"result":"unit-signature"}`, + want: "unit-signature", + }, + "http error": { + status: http.StatusBadGateway, + body: `bad gateway`, + err: true, + }, + "invalid json": { + status: http.StatusOK, + body: `{`, + err: true, + }, + "rpc error": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"error":{"message":"nope"}}`, + err: true, + }, + "empty signature": { + status: http.StatusOK, + body: `{"jsonrpc":"2.0","id":1,"result":""}`, + err: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + state := testServerState(t) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rawBody), `"method":"sendTransaction"`) || !strings.Contains(string(rawBody), `"maxRetries":3`) { + t.Fatalf("unexpected sendTransaction RPC body: %s", string(rawBody)) + } + return &http.Response{ + StatusCode: test.status, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(test.body)), + }, nil + }), + } + + got, err := sendTransaction(state, tx) + if test.err { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("sendTransaction = %q, want %q", got, test.want) + } + }) + } +} + +func TestSendTransactionReturnsTransportErrors(t *testing.T) { + state := testServerState(t) + state.memo = "unit-send-transport" + tx := transactionForTest(t, exactRequirement(state), solana.NewWallet().PrivateKey) + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("rpc unavailable") + }), + } + + if _, err := sendTransaction(state, tx); err == nil { + t.Fatal("expected transport error") + } +} + +func TestAccountAtRejectsInvalidIndexes(t *testing.T) { + state := testServerState(t) + state.memo = "unit-index" + tx := transactionForTest(t, exactRequirement(state), solana.NewWallet().PrivateKey) + if _, err := accountAt(tx, uint16(len(tx.Message.AccountKeys))); err == nil { + t.Fatal("expected invalid account index") + } + if _, err := programID(tx, solana.CompiledInstruction{ProgramIDIndex: uint16(len(tx.Message.AccountKeys))}); err == nil { + t.Fatal("expected invalid program index") + } +} + +func TestInteropMuxRoutesHealthCapabilitiesAndChallenges(t *testing.T) { + state := testServerState(t) + mux := newInteropMux(state) + + tests := map[string]struct { + path string + status int + header string + bodySearch string + }{ + "health": { + path: "/health", + status: http.StatusOK, + bodySearch: `"ok":true`, + }, + "capabilities": { + path: "/capabilities", + status: http.StatusOK, + bodySearch: `"implementation":"go"`, + }, + "exact challenge": { + path: "/exact", + status: http.StatusPaymentRequired, + header: "PAYMENT-REQUIRED", + bodySearch: `"payment_required"`, + }, + "upto challenge": { + path: "/upto", + status: http.StatusPaymentRequired, + header: "PAYMENT-REQUIRED", + bodySearch: `"payment_required"`, + }, + "session challenge": { + path: "/session", + status: http.StatusPaymentRequired, + bodySearch: `"intent":"session"`, + }, + "batch settlement challenge": { + path: "/batch-settlement", + status: http.StatusPaymentRequired, + header: "PAYMENT-REQUIRED", + bodySearch: `"payment_required"`, + }, + "protected challenge": { + path: defaultResourcePath, + status: http.StatusPaymentRequired, + header: "PAYMENT-REQUIRED", + bodySearch: `"payment_required"`, + }, + "not found": { + path: "/missing", + status: http.StatusNotFound, + bodySearch: `"not_found"`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, test.path, nil) + recorder := httptest.NewRecorder() + + mux.ServeHTTP(recorder, request) + + if recorder.Code != test.status { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.status, recorder.Body.String()) + } + if test.header != "" && recorder.Header().Get(test.header) == "" { + t.Fatalf("expected %s header", test.header) + } + if test.bodySearch != "" && !strings.Contains(recorder.Body.String(), test.bodySearch) { + t.Fatalf("body %s does not contain %s", recorder.Body.String(), test.bodySearch) + } + }) + } +} + +func TestInteropMuxProtectedRouteRejectsInvalidPayment(t *testing.T) { + state := testServerState(t) + mux := newInteropMux(state) + request := httptest.NewRequest(http.MethodGet, defaultResourcePath, nil) + request.Header.Set("PAYMENT-SIGNATURE", "not base64") + recorder := httptest.NewRecorder() + + mux.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusPaymentRequired) + } + if recorder.Header().Get("PAYMENT-REQUIRED") == "" { + t.Fatal("expected refreshed payment challenge") + } + if !strings.Contains(recorder.Body.String(), `"payment_invalid"`) { + t.Fatalf("expected payment_invalid body, got %s", recorder.Body.String()) + } +} + +func TestInteropMuxProtectedRouteSettlesValidPayment(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { + settlementCache = newDuplicateSettlementCache() + }() + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-mux-settle" + state.httpClient = &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + rawBody, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + body := string(rawBody) + responseBody := `{"jsonrpc":"2.0","id":1,"result":{"value":{"data":["","base64"]}}}` + switch { + case strings.Contains(body, `"method":"sendTransaction"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":"unit-mux-settlement"}` + case strings.Contains(body, `"method":"getSignatureStatuses"`): + responseBody = `{"jsonrpc":"2.0","id":1,"result":{"value":[{"slot":1,"confirmations":1,"err":null,"confirmationStatus":"confirmed"}]}}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + requirement := exactRequirement(state) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{ + "transaction": signedTransactionForTest(t, requirement, client), + }, + }) + mux := newInteropMux(state) + request := httptest.NewRequest(http.MethodGet, defaultResourcePath, nil) + request.Header.Set("PAYMENT-SIGNATURE", header) + recorder := httptest.NewRecorder() + + mux.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if recorder.Header().Get(defaultSettlementHeader) != "unit-mux-settlement" { + t.Fatalf("settlement header = %q", recorder.Header().Get(defaultSettlementHeader)) + } + if !strings.Contains(recorder.Body.String(), `"paid":true`) { + t.Fatalf("expected paid response, got %s", recorder.Body.String()) + } +} + +func TestRunInteropServerEmitsReadyAndStopsOnSignal(t *testing.T) { + state := testServerState(t) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + signals := make(chan os.Signal, 1) + ready := newSyncBuffer() + errors := newSyncBuffer() + done := make(chan error, 1) + + go func() { + done <- runInteropServer(state, listener, signals, ready, errors) + }() + + baseURL := "http://" + listener.Addr().String() + deadline := time.Now().Add(2 * time.Second) + for { + response, err := http.Get(baseURL + "/health") + if err == nil { + _ = response.Body.Close() + if response.StatusCode == http.StatusOK { + break + } + } + if time.Now().After(deadline) { + t.Fatalf("server did not become ready; ready=%s errors=%s lastErr=%v", ready.String(), errors.String(), err) + } + time.Sleep(10 * time.Millisecond) + } + + var payload map[string]any + if err := json.Unmarshal(bytes.TrimSpace(ready.Bytes()), &payload); err != nil { + t.Fatalf("decode ready payload %q: %v", ready.String(), err) + } + if payload["type"] != "ready" || payload["implementation"] != "go" { + t.Fatalf("unexpected ready payload: %#v", payload) + } + if _, ok := payload["port"].(float64); !ok { + t.Fatalf("ready payload missing port: %#v", payload) + } + + signals <- syscall.SIGTERM + select { + case err := <-done: + if err != nil { + t.Fatalf("runInteropServer returned %v; errors=%s", err, errors.String()) + } + case <-time.After(2 * time.Second): + t.Fatal("server did not stop after signal") + } +} + +// syncBuffer wraps bytes.Buffer with a mutex so the test goroutine can read +// the buffer concurrently with the server goroutine writing the ready line and +// stderr without triggering -race warnings. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func newSyncBuffer() *syncBuffer { return &syncBuffer{} } + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) Bytes() []byte { + b.mu.Lock() + defer b.mu.Unlock() + return append([]byte(nil), b.buf.Bytes()...) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestRunInteropServerReturnsServeErrors(t *testing.T) { + state := testServerState(t) + signals := make(chan os.Signal) + var ready bytes.Buffer + var errors bytes.Buffer + + err := runInteropServer(state, failingListener{}, signals, &ready, &errors) + + if err == nil || !strings.Contains(err.Error(), "listener failed") { + t.Fatalf("expected listener failure, got %v", err) + } + if ready.String() == "" { + t.Fatal("expected ready payload before listener failure") + } + if !strings.Contains(errors.String(), "listener failed") { + t.Fatalf("expected error writer to receive listener failure, got %q", errors.String()) + } +} + +func TestMainPanicsWhenRequiredEnvMissing(t *testing.T) { + mustPanic(t, main) +} + +// TestVerifyExactTransactionAttackRegressions covers MPP §19.5 fee-payer drain +// attacks: managed fee-payer (server co-signs) must never become a token source +// or transfer authority, must not appear in any extra instruction, must not be +// reassigned via a tampered details.fee_payer, and must not be moved into a +// signer slot beyond the fee-payer (index 0) position. +func TestVerifyExactTransactionAttackRegressions(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "attack-regression" + requirement := exactRequirement(state) + feePayer := state.feePayer.PublicKey() + mint := solana.MustPublicKeyFromBase58(requirement.Asset) + feePayerATA, _, err := solana.FindAssociatedTokenAddressWithProgram(feePayer, mint, solana.MustPublicKeyFromBase58(defaultTokenProgram)) + if err != nil { + t.Fatal(err) + } + + // Positive control: an unmodified happy-path transaction must verify. + valid := transactionForTest(t, requirement, client) + if err := verifyExactTransaction(valid, requirement); err != nil { + t.Fatalf("positive control failed: %v", err) + } + + tests := map[string]struct { + mutate func(*solana.Transaction, paymentRequirement) paymentRequirement + wantErrFrag string + }{ + "DRAIN: SystemProgram.Transfer from fee-payer in optional slot": { + mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { + // Replace memo (slot 3) with a SystemProgram.Transfer touching fee-payer. + attacker := solana.NewWallet().PublicKey() + tx.Message.Instructions[3] = compiledInstructionWithAccountsForTest( + t, tx, solana.SystemProgramID, + []solana.PublicKey{feePayer, attacker}, + []byte{2, 0, 0, 0, 0xff, 0, 0, 0, 0, 0, 0, 0}, + ) + return req + }, + // Accepted rejection paths: fee-payer-touch guard OR unknown-optional-instruction guard. + wantErrFrag: "invalid_exact_svm_payload", + }, + "SPL DRAIN: transferChecked from fee-payer ATA in optional slot": { + mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { + attackerATA := solana.NewWallet().PublicKey() + data := []byte{12} + data = binary.LittleEndian.AppendUint64(data, 1) + data = append(data, byte(defaultDecimals)) + tx.Message.Instructions[3] = compiledInstructionWithAccountsForTest( + t, tx, solana.TokenProgramID, + []solana.PublicKey{feePayerATA, mint, attackerATA, feePayer}, + data, + ) + return req + }, + // Accepted rejection paths: fee-payer-touch guard OR unknown-optional-instruction guard. + wantErrFrag: "invalid_exact_svm_payload", + }, + "SLOT: fee-payer at signer slot 1 as transfer authority": { + mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { + // Replace authority account on the transferChecked with fee-payer. + accounts := append([]uint16(nil), tx.Message.Instructions[2].Accounts...) + feePayerIndex := -1 + for index, key := range tx.Message.AccountKeys { + if key.Equals(feePayer) { + feePayerIndex = index + break + } + } + if feePayerIndex == -1 { + t.Fatal("fee payer not in account keys") + } + accounts[3] = uint16(feePayerIndex) + tx.Message.Instructions[2].Accounts = accounts + return req + }, + wantErrFrag: "fee_payer_transferring_funds", + }, + "SLOT: fee-payer as transfer source ATA": { + mutate: func(tx *solana.Transaction, req paymentRequirement) paymentRequirement { + // Repoint transferChecked.source to the fee-payer's own ATA. + feePayerIndex := -1 + for index, key := range tx.Message.AccountKeys { + if key.Equals(feePayer) { + feePayerIndex = index + break + } + } + if feePayerIndex == -1 { + t.Fatal("fee payer not in account keys") + } + // Add fee-payer ATA as a new account key and use it as source. + tx.Message.AccountKeys = append(tx.Message.AccountKeys, feePayerATA) + ataIndex := uint16(len(tx.Message.AccountKeys) - 1) + accounts := append([]uint16(nil), tx.Message.Instructions[2].Accounts...) + accounts[0] = ataIndex + accounts[3] = uint16(feePayerIndex) // authority = fee-payer + tx.Message.Instructions[2].Accounts = accounts + return req + }, + wantErrFrag: "fee_payer_transferring_funds", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + tx := cloneTransactionForTest(t, valid) + req := requirement + req.Extra = cloneExtra(requirement.Extra) + mutated := test.mutate(tx, req) + err := verifyExactTransaction(tx, mutated) + if err == nil { + t.Fatalf("expected attack to be rejected") + } + if !strings.Contains(err.Error(), test.wantErrFrag) { + t.Fatalf("error %q does not contain %q", err.Error(), test.wantErrFrag) + } + }) + } +} + +// TestSettleExactPaymentRejectsForeignMessageFeePayer covers Codex finding #1: +// the transaction's message fee-payer (account key 0) must equal the server's +// configured fee-payer before the facilitator co-signs. Otherwise a malicious +// client could pick a different message payer and the facilitator's presence +// in the signer set would drain its SOL. +func TestSettleExactPaymentRejectsForeignMessageFeePayer(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "foreign-fee-payer" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + + // Swap account key 0 (message fee-payer) for a foreign pubkey. + foreign := solana.NewWallet().PublicKey() + tx.Message.AccountKeys[0] = foreign + encoded, err := tx.ToBase64() + if err != nil { + t.Fatal(err) + } + + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{"transaction": encoded}, + }) + + if _, err := settleExactPayment(state, header); err == nil || + !strings.Contains(err.Error(), "fee_payer") { + t.Fatalf("expected foreign message fee-payer rejection, got %v", err) + } +} + +// TestSettleExactPaymentRejectsTamperedFeePayer covers MPP §19.5: an attacker +// presenting an envelope where details.feePayer (Extra["feePayer"]) is rebound +// to a non-server pubkey must be rejected at the requirement-match stage so +// that the server-co-signing context pubkey cannot be substituted by the +// client envelope. +func TestSettleExactPaymentRejectsTamperedFeePayer(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { settlementCache = newDuplicateSettlementCache() }() + + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "tampered-fee-payer" + requirement := exactRequirement(state) + transaction := signedTransactionForTest(t, requirement, client) + + tampered := requirement + tampered.Extra = cloneExtra(requirement.Extra) + tampered.Extra["feePayer"] = solana.NewWallet().PublicKey().String() + + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: tampered, + Payload: map[string]string{"transaction": transaction}, + }) + + if _, err := settleExactPayment(state, header); err == nil || + !strings.Contains(err.Error(), "does not match server challenge") { + t.Fatalf("expected tampered fee-payer to be rejected, got %v", err) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +type failingListener struct{} + +func (failingListener) Accept() (net.Conn, error) { + return nil, errors.New("listener failed") +} + +func (failingListener) Close() error { + return nil +} + +func (failingListener) Addr() net.Addr { + return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0} +} + +func cloneExtra(extra map[string]any) map[string]any { + cloned := make(map[string]any, len(extra)) + for key, value := range extra { + cloned[key] = value + } + return cloned +} + +func testServerState(t *testing.T) serverState { + t.Helper() + feePayer, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + return serverState{ + rpcURL: "http://127.0.0.1:8899", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + payTo: solana.NewWallet().PublicKey().String(), + feePayer: feePayer, + amount: "1000", + httpClient: &http.Client{}, + } +} + +func encodePaymentSignatureForTest(t *testing.T, envelope paymentSignatureEnvelope) string { + t.Helper() + encoded, err := json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(encoded) +} + +func signedTransactionForTest(t *testing.T, requirement paymentRequirement, client solana.PrivateKey) string { + t.Helper() + tx := transactionForTest(t, requirement, client) + encoded, err := tx.ToBase64() + if err != nil { + t.Fatal(err) + } + return encoded +} + +func transactionForTest(t *testing.T, requirement paymentRequirement, client solana.PrivateKey) *solana.Transaction { + t.Helper() + feePayer, err := solana.PublicKeyFromBase58(requirement.Extra["feePayer"].(string)) + if err != nil { + t.Fatal(err) + } + mint, err := solana.PublicKeyFromBase58(requirement.Asset) + if err != nil { + t.Fatal(err) + } + payTo, err := solana.PublicKeyFromBase58(requirement.PayTo) + if err != nil { + t.Fatal(err) + } + tokenProgram, err := solana.PublicKeyFromBase58(requirement.Extra["tokenProgram"].(string)) + if err != nil { + t.Fatal(err) + } + source, _, err := solana.FindAssociatedTokenAddressWithProgram(client.PublicKey(), mint, tokenProgram) + if err != nil { + t.Fatal(err) + } + destination, _, err := solana.FindAssociatedTokenAddressWithProgram(payTo, mint, tokenProgram) + if err != nil { + t.Fatal(err) + } + amount, err := strconv.ParseUint(requirement.Amount, 10, 64) + if err != nil { + t.Fatal(err) + } + transferData := []byte{12} + transferData = binary.LittleEndian.AppendUint64(transferData, amount) + transferData = append(transferData, byte(defaultDecimals)) + + tx, err := solana.NewTransaction( + []solana.Instruction{ + computeLimitInstructionForTest(20_000), + computePriceInstructionForTest(1), + solana.NewInstruction( + tokenProgram, + solana.AccountMetaSlice{ + solana.Meta(source).WRITE(), + solana.Meta(mint), + solana.Meta(destination).WRITE(), + solana.Meta(client.PublicKey()).SIGNER(), + }, + transferData, + ), + solana.NewInstruction(memoProgramID, nil, []byte(requirement.Extra["memo"].(string))), + }, + solana.Hash{}, + solana.TransactionPayer(feePayer), + ) + if err != nil { + t.Fatal(err) + } + tx.Message.SetVersion(solana.MessageVersionV0) + if _, err := tx.PartialSign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(client.PublicKey()) { + return &client + } + return nil + }); err != nil { + t.Fatal(err) + } + return tx +} + +func cloneTransactionForTest(t *testing.T, tx *solana.Transaction) *solana.Transaction { + t.Helper() + encoded, err := tx.ToBase64() + if err != nil { + t.Fatal(err) + } + cloned, err := solana.TransactionFromBase64(encoded) + if err != nil { + t.Fatal(err) + } + return cloned +} + +func computeLimitInstructionForTest(units uint32) solana.Instruction { + data := []byte{2} + data = binary.LittleEndian.AppendUint32(data, units) + return solana.NewInstruction(computeBudgetProgramID, nil, data) +} + +func computePriceInstructionForTest(microLamports uint64) solana.Instruction { + return solana.NewInstruction(computeBudgetProgramID, nil, computePriceDataForTest(microLamports)) +} + +func computePriceDataForTest(microLamports uint64) []byte { + data := []byte{3} + return binary.LittleEndian.AppendUint64(data, microLamports) +} + +func compiledInstructionForTest(t *testing.T, tx *solana.Transaction, program string, data []byte) solana.CompiledInstruction { + t.Helper() + programKey := solana.MustPublicKeyFromBase58(program) + return compiledInstructionWithAccountsForTest(t, tx, programKey, nil, data) +} + +func compiledInstructionWithAccountsForTest(t *testing.T, tx *solana.Transaction, programKey solana.PublicKey, accounts []solana.PublicKey, data []byte) solana.CompiledInstruction { + t.Helper() + programIndex := -1 + for index, key := range tx.Message.AccountKeys { + if key.Equals(programKey) { + programIndex = index + break + } + } + if programIndex == -1 { + tx.Message.AccountKeys = append(tx.Message.AccountKeys, programKey) + programIndex = len(tx.Message.AccountKeys) - 1 + } + accountIndexes := make([]uint16, 0, len(accounts)) + for _, account := range accounts { + accountIndex := -1 + for index, key := range tx.Message.AccountKeys { + if key.Equals(account) { + accountIndex = index + break + } + } + if accountIndex == -1 { + tx.Message.AccountKeys = append(tx.Message.AccountKeys, account) + accountIndex = len(tx.Message.AccountKeys) - 1 + } + accountIndexes = append(accountIndexes, uint16(accountIndex)) + } + return solana.CompiledInstruction{ + ProgramIDIndex: uint16(programIndex), + Accounts: accountIndexes, + Data: data, + } +} + +func TestResolveMintAlias(t *testing.T) { + tests := []struct { + name string + input string + network string + want string + wantErr bool + }{ + {name: "USDG mainnet alias", input: "USDG", network: solanaMainnetCAIP2, want: "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH"}, + {name: "USDG devnet alias", input: "usdg", network: solanaDevnetCAIP2, want: "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7"}, + {name: "PYUSD mainnet alias", input: "PYUSD", network: solanaMainnetCAIP2, want: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo"}, + {name: "PYUSD devnet alias", input: "pyusd", network: solanaDevnetCAIP2, want: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM"}, + {name: "CASH mainnet alias", input: "CASH", network: solanaMainnetCAIP2, want: "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH"}, + {name: "USDT mainnet alias", input: "USDT", network: solanaMainnetCAIP2, want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, + {name: "USDT lowercase mainnet alias", input: " usdt ", network: solanaMainnetCAIP2, want: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, + {name: "USDT has no devnet mint", input: "USDT", network: solanaDevnetCAIP2, wantErr: true}, + {name: "USDT has no testnet mint", input: "USDT", network: solanaTestnetCAIP2, wantErr: true}, + {name: "USDC devnet alias", input: " usdc ", network: solanaDevnetCAIP2, want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"}, + {name: "passthrough base58 mint", input: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", network: solanaDevnetCAIP2, want: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"}, + {name: "CASH has no devnet mint", input: "CASH", network: solanaDevnetCAIP2, wantErr: true}, + {name: "unknown alias", input: "WEIRDO", network: solanaMainnetCAIP2, wantErr: true}, + {name: "empty input", input: " ", network: solanaMainnetCAIP2, wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := resolveMintAlias(test.input, test.network) + if test.wantErr { + if err == nil { + t.Fatalf("expected error for %q on %q, got %q", test.input, test.network, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != test.want { + t.Fatalf("resolveMintAlias(%q,%q) = %q, want %q", test.input, test.network, got, test.want) + } + }) + } +} + +func TestReadStateResolvesMintAliases(t *testing.T) { + privateKey, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encodedKey, err := json.Marshal([]byte(privateKey)) + if err != nil { + t.Fatal(err) + } + payTo := solana.NewWallet().PublicKey().String() + + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + t.Setenv("X402_INTEROP_PAY_TO", payTo) + t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) + t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) + t.Setenv("X402_INTEROP_MINT", "PYUSD") + t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", "USDG, USDC") + + state := readState() + + challenge := exactChallengePayload(state) + if len(challenge.Accepts) != 3 { + t.Fatalf("expected 3 challenge entries, got %d", len(challenge.Accepts)) + } + if challenge.Accepts[0].Asset != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("primary Asset = %q, expected resolved PYUSD devnet mint", challenge.Accepts[0].Asset) + } + if _, err := solana.PublicKeyFromBase58(challenge.Accepts[0].Asset); err != nil { + t.Fatalf("primary Asset is not valid base58: %v", err) + } + if challenge.Accepts[1].Asset != "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7" { + t.Fatalf("extra[0] Asset = %q, expected resolved USDG devnet mint", challenge.Accepts[1].Asset) + } + if challenge.Accepts[2].Asset != "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" { + t.Fatalf("extra[1] Asset = %q, expected resolved USDC devnet mint", challenge.Accepts[2].Asset) + } + for index, requirement := range challenge.Accepts { + if _, err := solana.PublicKeyFromBase58(requirement.Asset); err != nil { + t.Fatalf("Accepts[%d].Asset is not base58 after resolution: %v", index, err) + } + } +} + +func TestReadStatePanicsOnUnknownMintAlias(t *testing.T) { + privateKey, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encodedKey, err := json.Marshal([]byte(privateKey)) + if err != nil { + t.Fatal(err) + } + + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + t.Setenv("X402_INTEROP_PAY_TO", solana.NewWallet().PublicKey().String()) + t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) + t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) + t.Setenv("X402_INTEROP_MINT", "DEFINITELY_NOT_A_MINT") + + mustPanic(t, func() { readState() }) + + t.Setenv("X402_INTEROP_MINT", "USDG") + t.Setenv("X402_INTEROP_EXTRA_OFFERED_MINTS", "PYUSD, NOPE") + mustPanic(t, func() { readState() }) +} + +func TestSettleExactPaymentAcceptsAliasResolvedRequirement(t *testing.T) { + settlementCache = newDuplicateSettlementCache() + defer func() { + settlementCache = newDuplicateSettlementCache() + }() + + privateKey, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + encodedKey, err := json.Marshal([]byte(privateKey)) + if err != nil { + t.Fatal(err) + } + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + + t.Setenv("X402_INTEROP_RPC_URL", "http://rpc.test") + t.Setenv("X402_INTEROP_PAY_TO", solana.NewWallet().PublicKey().String()) + t.Setenv("X402_INTEROP_FACILITATOR_SECRET_KEY", string(encodedKey)) + t.Setenv("X402_INTEROP_NETWORK", solanaDevnetCAIP2) + t.Setenv("X402_INTEROP_MINT", "PYUSD") + t.Setenv("X402_INTEROP_PRICE", "$0.001") + + state := readState() + state.memo = "alias-resolution" + state.httpClient = successfulSettlementClient(t, "alias-resolved-settlement") + + if state.mint != "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" { + t.Fatalf("expected PYUSD devnet mint resolution, got %q", state.mint) + } + + requirement := exactRequirement(state) + transaction := signedTransactionForTest(t, requirement, client) + header := encodePaymentSignatureForTest(t, paymentSignatureEnvelope{ + X402Version: 2, + Accepted: requirement, + Payload: map[string]string{ + "transaction": transaction, + }, + }) + + settlement, err := settleExactPayment(state, header) + if err != nil { + t.Fatalf("expected alias-resolved settlement to pass, got %v", err) + } + if settlement != "alias-resolved-settlement" { + t.Fatalf("settlement = %q", settlement) + } +} + +// --- Codex P1.1: Lighthouse discriminator + account-count allowlist --- + +// TestLighthousePassthroughMatchesSpine locks parity with the Rust + TS spines, +// both of which accept any Lighthouse-program instruction by program-id match +// alone. Inventing a per-language allowlist here would diverge from real-world +// Phantom/Solflare transactions the canonical adapters accept. See the comment +// on the optional-instruction loop for the spine citations. +func TestLighthousePassthroughMatchesSpine(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + data []byte + // extra wallet count for the instruction's account list. + extraAccounts int + }{ + {name: "empty_payload", data: []byte{}, extraAccounts: 0}, + {name: "known_assert_disc_single_account", data: []byte{9, 0}, extraAccounts: 1}, + {name: "unknown_discriminator", data: []byte{200, 1, 2}, extraAccounts: 1}, + {name: "oversize_payload_many_accounts", data: bytes.Repeat([]byte{0xAB}, 256), extraAccounts: 8}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + state := testServerState(t) + state.memo = "lighthouse-parity-" + tc.name + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + extras := make([]solana.PublicKey, tc.extraAccounts) + for i := range extras { + extras[i] = solana.NewWallet().PublicKey() + } + var ix solana.CompiledInstruction + if tc.extraAccounts == 0 { + ix = compiledInstructionForTest(t, tx, lighthouseProgram, tc.data) + } else { + ix = compiledInstructionWithAccountsForTest(t, tx, solana.MustPublicKeyFromBase58(lighthouseProgram), extras, tc.data) + } + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append([]solana.CompiledInstruction{ix}, tx.Message.Instructions[3:]...)..., + ) + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected spine-parity acceptance for %s, got %v", tc.name, err) + } + }) + } +} + +// --- Codex P1.2: tightened fee-payer-in-instruction guard --- + +// TestAcceptsFeePayerInLighthouseAccountMirrorsSpine locks parity with the Rust +// spine, which intentionally has NO fee-payer-in-instruction-accounts sweep: +// - rust/src/protocol/schemes/exact/verify.rs:382 only blocks fee-payer as +// the transfer *authority*, not as a passive account in some other ix. +// - rust/src/protocol/schemes/exact/verify.rs:263 accepts any Lighthouse +// instruction by program-id match alone. +// +// Real Phantom/Solflare wallets emit `Assert*` Lighthouse ixs that reference the +// fee-payer's pubkey as a read-only account to guard the facilitator from +// rewriting the transfer post-sign. Rejecting these would break canonical +// wallet flows and diverge from the spine. This test pins the Go adapter to +// the spine semantics: fee-payer in a Lighthouse account list is ACCEPTED. +func TestAcceptsFeePayerInLighthouseAccountMirrorsSpine(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "fee-payer-lighthouse-assert" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + + feePayer := state.feePayer.PublicKey() + // Lighthouse `AssertAccountInfo` (discriminator 9) referencing the + // fee-payer's pubkey as the target account — exactly the shape Phantom + // emits when guarding the rent-payer's balance against post-sign rewrites. + ix := compiledInstructionWithAccountsForTest( + t, tx, + solana.MustPublicKeyFromBase58(lighthouseProgram), + []solana.PublicKey{feePayer}, + []byte{9, 0}, + ) + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append([]solana.CompiledInstruction{ix}, tx.Message.Instructions[3:]...)..., + ) + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected fee-payer-in-Lighthouse-account to be accepted (spine parity), got %v", err) + } +} + +func TestAcceptsFeePayerAsAtaCreatePayer(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "fee-payer-ata-create" + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + transfer, err := parseTransferCheckedInstruction(tx, tx.Message.Instructions[2]) + if err != nil { + t.Fatal(err) + } + payTo := solana.MustPublicKeyFromBase58(requirement.PayTo) + feePayer := state.feePayer.PublicKey() + + // Canonical ATA-create where fee-payer is the rent payer at accounts[0]. + // Per the Codex P1.2 fix this is the *only* place fee-payer is allowed to + // appear outside the transfer authority/source check. + ataCreate := compiledInstructionWithAccountsForTest(t, tx, solana.SPLAssociatedTokenAccountProgramID, []solana.PublicKey{ + feePayer, + transfer.destination, + payTo, + transfer.mint, + solana.SystemProgramID, + transfer.tokenProgram, + }, []byte{1}) + tx.Message.Instructions = append( + tx.Message.Instructions[:3], + append([]solana.CompiledInstruction{ataCreate}, tx.Message.Instructions[3:]...)..., + ) + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected fee-payer as ATA-create payer to be accepted, got %v", err) + } +} + +// TestVerifyExactTransactionEnforcesTokenProgramBinding mirrors the Rust spine +// binding (rust/crates/x402/src/protocol/schemes/exact/verify.rs:73-80) and the +// PHP/Ruby/Lua ports: the on-chain transferChecked instruction's program MUST +// match requirement.Extra["tokenProgram"]. Without this, a Token-2022 transfer +// could satisfy an SPL Token requirement (and vice versa) because the +// destination-ATA derivation uses the parsed program, not the required one. +func TestVerifyExactTransactionEnforcesTokenProgramBinding(t *testing.T) { + client, err := solana.NewRandomPrivateKey() + if err != nil { + t.Fatal(err) + } + state := testServerState(t) + state.memo = "unit-token-program-binding" + + t.Run("mismatch_requires_spl_token_but_tx_uses_token2022", func(t *testing.T) { + // Requirement declares SPL Token; build a transaction using Token-2022 with + // a Token-2022 ATA. Verification must reject the program mismatch even + // though the transfer otherwise looks well-formed. + splRequirement := exactRequirement(state) + token2022Requirement := exactRequirement(state) + token2022Requirement.Extra = cloneExtra(token2022Requirement.Extra) + token2022Requirement.Extra["tokenProgram"] = token2022Program + tx := transactionForTest(t, token2022Requirement, client) + + err := verifyExactTransaction(tx, splRequirement) + if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { + t.Fatalf("expected token_program rejection, got %v", err) + } + }) + + t.Run("reverse_requires_token2022_but_tx_uses_spl_token", func(t *testing.T) { + token2022Requirement := exactRequirement(state) + token2022Requirement.Extra = cloneExtra(token2022Requirement.Extra) + token2022Requirement.Extra["tokenProgram"] = token2022Program + // Build the transaction against an SPL Token requirement (default). + splRequirement := exactRequirement(state) + tx := transactionForTest(t, splRequirement, client) + + err := verifyExactTransaction(tx, token2022Requirement) + if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { + t.Fatalf("expected token_program rejection, got %v", err) + } + }) + + t.Run("positive_control_matching_pair_accepted", func(t *testing.T) { + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + if err := verifyExactTransaction(tx, requirement); err != nil { + t.Fatalf("expected matching tokenProgram pair to be accepted, got %v", err) + } + }) + + t.Run("missing_required_token_program_rejected", func(t *testing.T) { + requirement := exactRequirement(state) + tx := transactionForTest(t, requirement, client) + mutated := requirement + mutated.Extra = cloneExtra(requirement.Extra) + delete(mutated.Extra, "tokenProgram") + err := verifyExactTransaction(tx, mutated) + if err == nil || err.Error() != "invalid_exact_svm_payload_transaction_token_program" { + t.Fatalf("expected missing tokenProgram to be rejected, got %v", err) + } + }) +} + +func mustPanic(t *testing.T, fn func()) { + t.Helper() + defer func() { + if recovered := recover(); recovered == nil { + t.Fatal("expected panic") + } + }() + fn() +} diff --git a/harness/README.md b/harness/README.md index 490662fc0..8a6546533 100644 --- a/harness/README.md +++ b/harness/README.md @@ -123,6 +123,55 @@ Use these environment variables to filter the active matrix: - `MPP_INTEROP_INTENTS=charge` - `MPP_INTEROP_SCENARIOS=charge-basic,charge-split-ata,charge-network-mismatch,charge-cross-route-replay` +### x402 exact intent + +A second intent, `x402-exact`, exercises the canonical x402 `exact` scheme +against the Rust spine in `rust/crates/x402/src/bin/interop_{client,server}.rs`. +The TypeScript reference adapters live at +`src/fixtures/typescript/exact-{client,server}.ts` and share the same +harness contract as the Rust spine: identical `X402_INTEROP_*` env vars, +identical `PAYMENT-REQUIRED` / `PAYMENT-SIGNATURE` headers, identical +ready / result JSON shapes. The TS reference fixture carries a stub +credential payload (challenge id + resource) and is paired against the +TS reference server in the default matrix; the Rust spine is paired +against itself. As language adapters that carry a real Solana +PaymentProof land, they expand the matrix by registering under +`intents: ["x402-exact"]` in `implementations.ts`. + +Env vars consumed by both roles: + +- `X402_INTEROP_RPC_URL`, `X402_INTEROP_NETWORK`, `X402_INTEROP_MINT` +- `X402_INTEROP_PAY_TO`, `X402_INTEROP_PRICE` +- `X402_INTEROP_FACILITATOR_SECRET_KEY` + +Server-only: + +- `X402_INTEROP_EXTRA_OFFERED_MINTS` (CSV of additional mint addresses) + +Client-only: + +- `X402_INTEROP_TARGET_URL` +- `X402_INTEROP_CLIENT_SECRET_KEY` +- `X402_INTEROP_PREFER_CURRENCIES` (CSV of preferred currencies) + +Run the x402 matrix slice: + +```bash +X402_INTEROP_MATRIX=1 \ +X402_INTEROP_RPC_URL=http://127.0.0.1:8899 \ +X402_INTEROP_MINT=... X402_INTEROP_PAY_TO=... \ +X402_INTEROP_CLIENT_SECRET_KEY='[...]' \ +X402_INTEROP_FACILITATOR_SECRET_KEY='[...]' \ +pnpm test x402-exact.e2e.test.ts +``` + +Cross-server portability and idempotent-resubmit scenarios are gated +separately: + +```bash +X402_INTEROP_CROSS_SERVER=1 pnpm test cross-server-scenarios.test.ts +``` + The current scenario set covers only the `charge` intent. It includes a basic payment, a split payment that requires the server fee payer to create the split recipient ATA, a negative network-mismatch payment, and a cross-route replay diff --git a/harness/src/contracts.ts b/harness/src/contracts.ts index 145301551..288ed18a7 100644 --- a/harness/src/contracts.ts +++ b/harness/src/contracts.ts @@ -1,11 +1,12 @@ import type { CanonicalErrorCode } from "./canonical-codes"; import { chargeScenarios } from "./intents/charge"; +import { x402ExactScenarios } from "./intents/x402-exact"; export type { CanonicalErrorCode }; export type AdapterKind = "client" | "server"; -export type InteropIntent = "charge"; +export type InteropIntent = "charge" | "x402-exact"; export type InteropScenarioSplit = { recipientKey: string; @@ -136,8 +137,10 @@ export type AdapterMessage = ReadyMessage | ClientRunResult; export { chargeCanonicalJsonVectors } from "./intents/charge"; -export const interopScenarios: readonly InteropScenario[] = - chargeScenarios; +export const interopScenarios: readonly InteropScenario[] = [ + ...chargeScenarios, + ...x402ExactScenarios, +]; export const interopScenario: InteropScenario = { ...(interopScenarios[0] as InteropScenario), @@ -191,11 +194,18 @@ function selectScenarioIds(rawSelection: string | undefined): string[] { return selected; } +// The legacy MPP charge runner predates the x402-exact intent. To keep +// the existing CI matrix's default behaviour (charge-only) stable while +// still surfacing the new intent through `selectInteropIntents("x402-exact")`, +// the empty-selection default is restricted to "charge". Callers that +// want the full intent set should pass the explicit list. +const DEFAULT_INTENTS: readonly InteropIntent[] = ["charge"]; + export function selectInteropIntents( rawSelection: string | undefined, ): InteropIntent[] { if (!rawSelection || rawSelection.trim() === "") { - return [...supportedInteropIntents]; + return [...DEFAULT_INTENTS]; } const selected = rawSelection @@ -209,8 +219,7 @@ export function selectInteropIntents( if (unsupported.length > 0) { throw new Error( `Unsupported MPP_INTEROP_INTENTS value(s): ${unsupported.join(", ")}. ` + - `Supported intents: ${supportedInteropIntents.join(", ")}. ` + - "Session and subscription scenarios are not implemented in this harness yet.", + `Supported intents: ${supportedInteropIntents.join(", ")}.`, ); } diff --git a/harness/src/fixtures/typescript/exact-client.ts b/harness/src/fixtures/typescript/exact-client.ts new file mode 100644 index 000000000..67807f376 --- /dev/null +++ b/harness/src/fixtures/typescript/exact-client.ts @@ -0,0 +1,225 @@ +// TypeScript reference x402 `exact` interop client. +// +// Shares the same `X402_INTEROP_*` env-var contract and ready/result +// JSON protocol as the Rust spine (`rust/crates/x402/src/bin/ +// interop_client.rs`). Sends an unpaid GET, parses the base64 +// `PAYMENT-REQUIRED` envelope, selects an offer (`preferredCurrencies` +// first) and resubmits with `PAYMENT-SIGNATURE`. Prints one result +// JSON line to stdout. +// +// Scope: the fixture carries a stub credential payload (challenge id + +// resource) so the harness wiring, negative-code classification, and +// cross-server portability + idempotent-resubmit flows can run without +// a full Solana signer. Real SVM PaymentProof construction (signed +// VersionedTransaction or settled signature) lives in the Rust spine +// and the TS SDK port; this client only pairs against the TS reference +// server in the default matrix (see `test/x402-exact.e2e.test.ts`). + +import { + PAYMENT_REQUIRED_HEADER, + PAYMENT_SIGNATURE_HEADER, + readX402ClientEnvironment, +} from "./exact-shared"; + +type PaymentRequirement = { + scheme: string; + network: string; + resource?: string; + payTo: string; + asset: string; + maxAmountRequired: string; + extra?: { decimals?: number; tokenProgram?: string }; +}; + +type PaymentRequiredEnvelope = { + x402Version: number; + accepts: PaymentRequirement[]; + resource?: string; +}; + +const STABLECOIN_MINTS: Record> = { + USDC: { + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + }, + PYUSD: { + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": + "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", + }, +}; + +function resolveMint(currency: string, network: string): string { + const upper = currency.toUpperCase(); + const byNetwork = STABLECOIN_MINTS[upper]; + if (byNetwork && byNetwork[network]) { + return byNetwork[network]; + } + return currency; +} + +function pickOffer( + envelope: PaymentRequiredEnvelope, + preferred: string[], + network: string, +): PaymentRequirement | undefined { + const supported = envelope.accepts.filter( + offer => offer.scheme === "exact" && offer.network === network, + ); + if (supported.length === 0) { + return undefined; + } + if (preferred.length === 0) { + return supported[0]; + } + for (const wanted of preferred) { + const wantedMint = resolveMint(wanted, network); + const match = supported.find(offer => offer.asset === wantedMint); + if (match) return match; + } + return supported[0]; +} + +function decodePaymentRequired(headerValue: string | null): PaymentRequiredEnvelope | null { + if (!headerValue) return null; + try { + const raw = Buffer.from(headerValue, "base64").toString("utf8"); + return JSON.parse(raw) as PaymentRequiredEnvelope; + } catch { + return null; + } +} + +async function readResponseBody(response: Response): Promise { + const raw = await response.text(); + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +async function main() { + const env = readX402ClientEnvironment(); + + const firstResponse = await fetch(env.targetUrl); + const envelope = decodePaymentRequired( + firstResponse.headers.get(PAYMENT_REQUIRED_HEADER), + ); + + if (!envelope) { + console.log( + JSON.stringify({ + type: "result", + implementation: "typescript", + role: "client", + ok: false, + status: firstResponse.status, + responseHeaders: Object.fromEntries(firstResponse.headers.entries()), + responseBody: await readResponseBody(firstResponse), + settlement: null, + error: "missing or unparseable PAYMENT-REQUIRED header", + }), + ); + return; + } + + const offer = pickOffer(envelope, env.preferredCurrencies, env.network); + if (!offer) { + console.log( + JSON.stringify({ + type: "result", + implementation: "typescript", + role: "client", + ok: false, + status: firstResponse.status, + responseHeaders: Object.fromEntries(firstResponse.headers.entries()), + responseBody: await readResponseBody(firstResponse), + settlement: null, + error: `no offer matched network ${env.network}`, + }), + ); + return; + } + + // Credential payload mirrors the canonical x402 `exact` shape: an + // adapter-specific id plus the offer the client is committing to. + // A live SDK would also embed a signed Solana transaction here; the + // matrix runner uses the rust spine for the actual on-chain + // settlement assertions. The TS fixture's role is wire-level + // protocol compliance. + // Use the server-issued challenge id if present (TS reference server + // emits one in the `x-challenge-id` header on the 402). This lets the + // server verify the credential was issued against its own 402 — the + // cross-server portability scenario relies on this distinction. + const issuedChallengeId = firstResponse.headers.get("x-challenge-id"); + const credentialId = + issuedChallengeId ?? + `ts-x402-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + // Mirrors the Rust spine's PaymentPayload wire shape: + // { x402Version, accepted: { scheme, network, asset, payTo, amount, extra? }, + // payload: { ... scheme-specific blob ... }, resource?: string } + // The `payload` field is required by Rust's parser. For the wire-only + // TS adapter the payload carries the credential id plus the route the + // client is committing to; a full SDK fixture would carry a signed + // Solana transaction here. + const credential = { + x402Version: envelope.x402Version, + accepted: { + scheme: offer.scheme, + network: offer.network, + asset: offer.asset, + payTo: offer.payTo, + amount: offer.maxAmountRequired, + extra: offer.extra ?? null, + }, + payload: { + challengeId: credentialId, + resource: offer.resource ?? envelope.resource, + }, + resource: offer.resource ?? envelope.resource, + }; + const credentialHeader = Buffer.from(JSON.stringify(credential), "utf8").toString( + "base64", + ); + + const paidResponse = await fetch(env.targetUrl, { + headers: { [PAYMENT_SIGNATURE_HEADER]: credentialHeader }, + }); + + const responseHeaders = Object.fromEntries(paidResponse.headers.entries()); + // Echo the credential the client sent so the harness can replay it in + // cross-server portability + idempotent-resubmit scenarios. The credential + // is a request header so it is never reflected in the response on its own. + responseHeaders[`${PAYMENT_SIGNATURE_HEADER}-sent`] = credentialHeader; + + console.log( + JSON.stringify({ + type: "result", + implementation: "typescript", + role: "client", + ok: paidResponse.ok, + status: paidResponse.status, + responseHeaders, + responseBody: await readResponseBody(paidResponse), + settlement: paidResponse.headers.get(env.settlementHeader), + }), + ); +} + +void main().catch(error => { + console.log( + JSON.stringify({ + type: "result", + implementation: "typescript", + role: "client", + ok: false, + status: 0, + responseHeaders: {}, + responseBody: null, + settlement: null, + error: error instanceof Error ? error.message : String(error), + }), + ); +}); diff --git a/harness/src/fixtures/typescript/exact-server.ts b/harness/src/fixtures/typescript/exact-server.ts new file mode 100644 index 000000000..780c6633e --- /dev/null +++ b/harness/src/fixtures/typescript/exact-server.ts @@ -0,0 +1,368 @@ +// TypeScript reference x402 `exact` interop server. +// +// Wire-compatible with `rust/crates/x402/src/bin/interop_server.rs`: +// - 402 carries a `PAYMENT-REQUIRED` header whose value is the +// base64 of the JSON envelope `{x402Version, accepts, resource}`. +// - The credential is delivered in the `PAYMENT-SIGNATURE` header. +// - On successful settlement, the response includes +// `PAYMENT-RESPONSE` and the fixture settlement header. +// +// This fixture deliberately keeps the SDK surface area minimal so the +// adapter is portable across pay-kit checkouts. The cross-language +// matrix is the load-bearing path; this adapter exists so language +// adapters have a TS counterpart to pair against while the canonical +// SDK lands. End-to-end verification against a live Surfpool RPC is +// driven by the matrix runner. + +import http from "node:http"; +import { + PAYMENT_REQUIRED_HEADER, + PAYMENT_RESPONSE_HEADER, + PAYMENT_SIGNATURE_HEADER, + X402_VERSION_V2, + readX402ServerEnvironment, +} from "./exact-shared"; + +const TOKEN_DECIMALS = 6; +const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + +type PaymentRequirement = { + scheme: "exact"; + network: string; + resource: string; + description: string; + mimeType: string; + payTo: string; + asset: string; + maxAmountRequired: string; + maxTimeoutSeconds: number; + extra: { + decimals: number; + tokenProgram?: string; + feePayer?: string; + }; +}; + +function buildRequirements( + env: ReturnType, +): PaymentRequirement[] { + const primary: PaymentRequirement = { + scheme: "exact", + network: env.network, + resource: env.resourcePath, + description: "Surfpool-backed protected content", + mimeType: "application/json", + payTo: env.payTo, + asset: env.mint, + maxAmountRequired: env.price, + maxTimeoutSeconds: 60, + extra: { + decimals: TOKEN_DECIMALS, + tokenProgram: TOKEN_PROGRAM, + }, + }; + + const extras: PaymentRequirement[] = env.extraOfferedMints.map(mint => ({ + scheme: "exact", + network: env.network, + resource: env.resourcePath, + description: "Surfpool-backed protected content", + mimeType: "application/json", + payTo: env.payTo, + asset: mint, + maxAmountRequired: env.price, + maxTimeoutSeconds: 60, + extra: { decimals: TOKEN_DECIMALS }, + })); + + return [primary, ...extras]; +} + +function encodePaymentRequiredHeader(accepts: PaymentRequirement[]): string { + const envelope = { + x402Version: X402_VERSION_V2, + accepts, + resource: accepts[0]?.resource, + error: null, + }; + return Buffer.from(JSON.stringify(envelope), "utf8").toString("base64"); +} + +type DecodedCredential = { + x402Version?: number; + accepted?: { + scheme?: string; + network?: string; + asset?: string; + payTo?: string; + amount?: string; + }; + payload?: { + challengeId?: string; + resource?: string; + }; + resource?: string; +}; + +function decodeCredential(headerValue: string): DecodedCredential | null { + try { + const decoded = Buffer.from(headerValue, "base64").toString("utf8"); + return JSON.parse(decoded) as DecodedCredential; + } catch { + return null; + } +} + +type RejectReason = { + code: + | "payment_invalid" + | "wrong_network" + | "charge_request_mismatch" + | "challenge_verification_failed"; + message: string; +}; + +function classifyCredential( + credential: DecodedCredential | null, + accepts: PaymentRequirement[], + requestedResource: string, +): { offer: PaymentRequirement; credentialKey: string } | { reject: RejectReason } { + if (!credential || !credential.accepted || !credential.payload) { + return { + reject: { + code: "payment_invalid", + message: "credential is missing accepted/payload fields", + }, + }; + } + + const offer = accepts.find( + candidate => + candidate.asset === credential.accepted?.asset && + candidate.network === credential.accepted?.network && + candidate.scheme === credential.accepted?.scheme, + ); + + if (!offer) { + // Could be either network mismatch or no matching offer. + if ( + credential.accepted.network && + !accepts.some(c => c.network === credential.accepted?.network) + ) { + return { + reject: { + code: "wrong_network", + message: `credential network ${credential.accepted.network} does not match server`, + }, + }; + } + return { + reject: { + code: "charge_request_mismatch", + message: "no offered requirement matches the credential", + }, + }; + } + + if (offer.payTo !== credential.accepted.payTo) { + return { + reject: { + code: "charge_request_mismatch", + message: "recipient does not match", + }, + }; + } + + if (offer.maxAmountRequired !== credential.accepted.amount) { + return { + reject: { + code: "charge_request_mismatch", + message: "amount does not match", + }, + }; + } + + const credentialResource = credential.payload.resource ?? credential.resource; + if (credentialResource && credentialResource !== requestedResource) { + return { + reject: { + code: "charge_request_mismatch", + message: `credential resource ${credentialResource} does not match requested ${requestedResource}`, + }, + }; + } + + const challengeId = credential.payload.challengeId; + if (!challengeId || typeof challengeId !== "string") { + return { + reject: { + code: "challenge_verification_failed", + message: "credential payload missing challengeId", + }, + }; + } + + return { offer, credentialKey: challengeId }; +} + +async function main() { + const env = readX402ServerEnvironment(); + const accepts = buildRequirements(env); + const paymentRequiredHeader = encodePaymentRequiredHeader(accepts); + + // Track consumed credentials by challengeId to surface + // `signature_consumed` on idempotent resubmit. + const consumed = new Set(); + // Track challenge IDs this server has issued (recognised when a + // credential's payload.challengeId matches). Cross-server portability: + // server B sees a credential carrying an id only server A issued, so B + // rejects with `challenge_verification_failed`. A real x402 facilitator + // verifies HMAC over the challenge id with its own secret; this fixture + // simulates that by tracking issuance in-process. + const issued = new Set(); + + const server = http.createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + + if (url.pathname === "/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + return; + } + + if (url.pathname !== env.resourcePath) { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + return; + } + + const paymentHeader = request.headers[PAYMENT_SIGNATURE_HEADER] as + | string + | undefined; + + if (!paymentHeader) { + // Issue a fresh challenge id so the client can echo it back. The + // fixture's "verification" is presence-in-`issued`; a real + // facilitator would HMAC the id with its secret. + const challengeId = `ts-srv-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 10)}`; + issued.add(challengeId); + response.writeHead(402, { + "content-type": "application/json", + [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, + "x-challenge-id": challengeId, + }); + response.end( + JSON.stringify({ error: "payment_required", challengeId }), + ); + return; + } + + const credential = decodeCredential(paymentHeader); + const classified = classifyCredential(credential, accepts, env.resourcePath); + + if ("reject" in classified) { + response.writeHead(402, { + "content-type": "application/json", + [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, + }); + response.end( + JSON.stringify({ + error: classified.reject.code, + code: classified.reject.code, + message: classified.reject.message, + }), + ); + return; + } + + const { credentialKey } = classified; + + if (consumed.has(credentialKey)) { + response.writeHead(402, { + "content-type": "application/json", + [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, + }); + response.end( + JSON.stringify({ + error: "signature_consumed", + code: "signature_consumed", + message: "signature already consumed", + }), + ); + return; + } + + // Cross-server portability check: when the client supplies a payload + // challengeId, it must be one this server issued (or this server + // never required HMAC issuance). The first paid request that didn't + // come from this server's 402 will be missing from `issued`. + if (issued.size > 0 && !issued.has(credentialKey)) { + response.writeHead(402, { + "content-type": "application/json", + [PAYMENT_REQUIRED_HEADER]: paymentRequiredHeader, + }); + response.end( + JSON.stringify({ + error: "challenge_verification_failed", + code: "challenge_verification_failed", + message: "challenge id was not issued by this server", + }), + ); + return; + } + + consumed.add(credentialKey); + + // Settlement: a real facilitator would broadcast a signed Solana + // transaction here. The fixture returns a deterministic placeholder + // so the harness can assert presence of the settlement header. + const settlement = `ts-x402-exact-${credentialKey.slice(0, 16)}`; + const paymentResponse = JSON.stringify({ + success: true, + network: accepts[0]?.network, + transaction: settlement, + }); + + response.writeHead(200, { + "content-type": "application/json", + [env.settlementHeader]: settlement, + [PAYMENT_RESPONSE_HEADER]: paymentResponse, + }); + response.end( + JSON.stringify({ + ok: true, + paid: true, + settlement: { + success: true, + transaction: settlement, + network: accepts[0]?.network, + }, + }), + ); + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to bind TypeScript x402 interop server"); + } + + console.log( + JSON.stringify({ + type: "ready", + implementation: "typescript", + role: "server", + port: address.port, + capabilities: ["exact"], + }), + ); + }); + + const shutdown = () => server.close(() => process.exit(0)); + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} + +void main(); diff --git a/harness/src/fixtures/typescript/exact-shared.ts b/harness/src/fixtures/typescript/exact-shared.ts new file mode 100644 index 000000000..d9771bd8c --- /dev/null +++ b/harness/src/fixtures/typescript/exact-shared.ts @@ -0,0 +1,87 @@ +// Env contract for the TypeScript x402 `exact` fixture adapters. The +// wire shape mirrors the Rust spine (`rust/crates/x402/src/bin/ +// interop_{client,server}.rs`) verbatim so any language adapter that +// targets this contract can pair against either TS or Rust. + +export type X402InteropEnvironment = { + rpcUrl: string; + network: string; + mint: string; + payTo: string; + price: string; + resourcePath: string; + settlementHeader: string; + facilitatorSecretKey: Uint8Array; + // Server-only. Comma-separated mint addresses advertised alongside the + // primary currency. Read from `X402_INTEROP_EXTRA_OFFERED_MINTS`. + extraOfferedMints: string[]; +}; + +export type X402ClientEnvironment = X402InteropEnvironment & { + targetUrl: string; + clientSecretKey: Uint8Array; + // Comma-separated currency preference list (symbols or mints) read + // from `X402_INTEROP_PREFER_CURRENCIES`. Empty when unset. + preferredCurrencies: string[]; +}; + +const DEFAULT_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; +const DEFAULT_RESOURCE_PATH = "/protected"; +const DEFAULT_PRICE = "0.001"; +const DEFAULT_SETTLEMENT_HEADER = "x-fixture-settlement"; + +function readRequiredEnv(name: string): string { + const value = process.env[name]; + if (!value || value.trim() === "") { + throw new Error(`${name} is required`); + } + return value; +} + +function parseSecretKey(name: string): Uint8Array { + const raw = readRequiredEnv(name); + const parsed = JSON.parse(raw) as number[]; + return new Uint8Array(parsed); +} + +function parseCsv(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(",") + .map(value => value.trim()) + .filter(Boolean); +} + +function readBase(): X402InteropEnvironment { + return { + rpcUrl: readRequiredEnv("X402_INTEROP_RPC_URL"), + network: process.env.X402_INTEROP_NETWORK ?? DEFAULT_NETWORK, + mint: readRequiredEnv("X402_INTEROP_MINT"), + payTo: readRequiredEnv("X402_INTEROP_PAY_TO"), + price: process.env.X402_INTEROP_PRICE ?? DEFAULT_PRICE, + resourcePath: process.env.X402_INTEROP_RESOURCE_PATH ?? DEFAULT_RESOURCE_PATH, + settlementHeader: + process.env.X402_INTEROP_SETTLEMENT_HEADER ?? DEFAULT_SETTLEMENT_HEADER, + facilitatorSecretKey: parseSecretKey("X402_INTEROP_FACILITATOR_SECRET_KEY"), + extraOfferedMints: parseCsv(process.env.X402_INTEROP_EXTRA_OFFERED_MINTS), + }; +} + +export function readX402ServerEnvironment(): X402InteropEnvironment { + return readBase(); +} + +export function readX402ClientEnvironment(): X402ClientEnvironment { + const base = readBase(); + return { + ...base, + targetUrl: readRequiredEnv("X402_INTEROP_TARGET_URL"), + clientSecretKey: parseSecretKey("X402_INTEROP_CLIENT_SECRET_KEY"), + preferredCurrencies: parseCsv(process.env.X402_INTEROP_PREFER_CURRENCIES), + }; +} + +export const PAYMENT_REQUIRED_HEADER = "payment-required"; +export const PAYMENT_SIGNATURE_HEADER = "payment-signature"; +export const PAYMENT_RESPONSE_HEADER = "payment-response"; +export const X402_VERSION_V2 = 2; diff --git a/harness/src/implementations.ts b/harness/src/implementations.ts index 71d0ca997..503848223 100644 --- a/harness/src/implementations.ts +++ b/harness/src/implementations.ts @@ -4,6 +4,10 @@ export type ImplementationDefinition = { role: "client" | "server"; command: string[]; enabled: boolean; + // Optional. When set, this adapter only participates in scenarios whose + // `intent` is in this list. Defaults to "charge" only for back-compat + // with the existing MPP charge matrix. + intents?: string[]; }; function isEnabled(id: string, envName: string, defaultEnabled: boolean): boolean { @@ -80,6 +84,51 @@ export const clientImplementations: ImplementationDefinition[] = [ ], enabled: isEnabled("kotlin", "MPP_INTEROP_CLIENTS", true), }, + { + id: "ts-x402", + label: "TypeScript x402 exact client", + role: "client", + command: [ + "pnpm", + "exec", + "node", + "--import", + "tsx", + "src/fixtures/typescript/exact-client.ts", + ], + enabled: isEnabled("ts-x402", "X402_INTEROP_CLIENTS", true), + intents: ["x402-exact"], + }, + { + id: "rust-x402", + label: "Rust x402 exact client", + role: "client", + command: [ + "cargo", + "run", + "--quiet", + "--manifest-path", + "../../rust/Cargo.toml", + "-p", + "solana-x402", + "--bin", + "interop_client", + ], + enabled: isEnabled("rust-x402", "X402_INTEROP_CLIENTS", true), + intents: ["x402-exact"], + }, + { + id: "go-x402-client", + label: "Go x402 exact client", + role: "client", + command: [ + "sh", + "-c", + "cd ../go/x402/cmd/interop-client && go run .", + ], + enabled: isEnabled("go-x402-client", "MPP_INTEROP_CLIENTS", false), + intents: ["x402-exact"], + }, ]; export const serverImplementations: ImplementationDefinition[] = [ @@ -172,4 +221,49 @@ export const serverImplementations: ImplementationDefinition[] = [ command: ["sh", "-c", "cd go-server && go run ."], enabled: isEnabled("go", "MPP_INTEROP_SERVERS", true), }, + { + id: "ts-x402", + label: "TypeScript x402 exact server", + role: "server", + command: [ + "pnpm", + "exec", + "node", + "--import", + "tsx", + "src/fixtures/typescript/exact-server.ts", + ], + enabled: isEnabled("ts-x402", "X402_INTEROP_SERVERS", true), + intents: ["x402-exact"], + }, + { + id: "rust-x402", + label: "Rust x402 exact server", + role: "server", + command: [ + "cargo", + "run", + "--quiet", + "--manifest-path", + "../../rust/Cargo.toml", + "-p", + "solana-x402", + "--bin", + "interop_server", + ], + enabled: isEnabled("rust-x402", "X402_INTEROP_SERVERS", true), + intents: ["x402-exact"], + }, + { + id: "go-x402-server", + label: "Go x402 exact server", + role: "server", + command: [ + "sh", + "-c", + "cd ../go/x402/cmd/interop-server && go run .", + ], + enabled: isEnabled("go-x402-server", "MPP_INTEROP_SERVERS", false), + intents: ["x402-exact"], + }, ]; diff --git a/harness/src/intents/x402-exact.ts b/harness/src/intents/x402-exact.ts new file mode 100644 index 000000000..85f1afe93 --- /dev/null +++ b/harness/src/intents/x402-exact.ts @@ -0,0 +1,119 @@ +import type { InteropScenario } from "../contracts"; + +// Canonical x402 `exact` intent scenarios. The harness contract (env +// vars, ready/result JSON shapes, capabilities) mirrors the Rust spine +// (`rust/crates/x402/src/bin/interop_{client,server}.rs`). The matrix +// pairs each x402 client against each x402 server registered in +// `implementations.ts`; the default-matrix pair set is restricted in +// `test/x402-exact.e2e.test.ts` while the TS reference adapter ships +// without a full Solana signing path. Adding language adapters that +// carry a real PaymentProof expands the matrix. +// +// Reject codes (cross-server portability / replay / network mismatch) +// reuse the canonical L6 set declared in `canonical-codes.ts`; the +// matrix asserts each x402 server adapter classifies the failure +// to the same canonical snake_case code as every other adapter. +export const x402ExactScenarios: readonly InteropScenario[] = [ + { + id: "x402-exact-basic", + intent: "x402-exact", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected", + settlementHeader: "x-fixture-settlement", + expectedStatus: 200, + }, + { + // Network mismatch: client signs against localnet but the challenge + // requires devnet (or vice versa). Server must reject the credential + // with canonical `wrong_network`. + id: "x402-exact-network-mismatch", + intent: "x402-exact", + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected/network-mismatch", + settlementHeader: "x-fixture-settlement", + expectedStatus: 402, + expectedCode: "wrong_network", + clientIds: ["ts-x402", "rust-x402"], + serverIds: ["ts-x402", "rust-x402"], + }, + { + // Cross-route replay: credential issued for /protected/cheap is + // re-submitted against /protected/expensive. Server must reject with + // `charge_request_mismatch` because the credential's pinned route / + // amount does not match the served route. + id: "x402-exact-cross-route-replay", + intent: "x402-exact", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected/expensive", + settlementHeader: "x-fixture-settlement", + replaySource: { + resourcePath: "/protected/cheap", + price: "0.0005", + amount: "500", + }, + expectedStatus: 402, + expectedCode: "charge_request_mismatch", + clientIds: ["ts-x402"], + serverIds: ["ts-x402", "rust-x402"], + }, + { + // Cross-server credential portability. Client pays server A and + // re-submits the same payment header to server B. B must reject with + // canonical `challenge_verification_failed` because B's verifier + // does not accept A's challenge issuance. + id: "x402-exact-cross-server-portability", + intent: "x402-exact", + kind: "cross-server-portability", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected", + settlementHeader: "x-fixture-settlement", + expectedStatus: 402, + expectedCode: "challenge_verification_failed", + clientIds: ["ts-x402"], + serverIds: ["ts-x402", "rust-x402"], + // Cross-server portability requires the client adapter to expose the + // credential it sent so the runner can replay it. The TS reference + // client echoes `payment-signature-sent`; the Rust spine adapter does + // not (and is preserved as the canonical settlement-signing path + // rather than a credential-capturing one). Pairs that use the TS + // client cover the asymmetric direction too: TS pays server A, then + // replays the captured credential against server B. + crossServerPairs: [["ts-x402", "rust-x402"]], + }, + { + // Same-server idempotent resubmit. Client pays server A, then + // re-submits the same payment header. Server must reject with + // `signature_consumed`. + id: "x402-exact-idempotent-resubmit", + intent: "x402-exact", + kind: "idempotent-resubmit", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected", + settlementHeader: "x-fixture-settlement", + expectedStatus: 402, + expectedCode: "signature_consumed", + // Driven by the TS client (the only one that echoes the sent + // credential back to the harness). The first paid request must + // reach 200, which constrains us to the TS reference server in + // the default matrix because that server is what speaks the TS + // client's stub payload. Rust server coverage of `signature_consumed` + // lives in the Rust crate's own integration tests. + clientIds: ["ts-x402"], + serverIds: ["ts-x402"], + }, +] as const; diff --git a/harness/test/cross-server-scenarios.test.ts b/harness/test/cross-server-scenarios.test.ts new file mode 100644 index 000000000..4dad52861 --- /dev/null +++ b/harness/test/cross-server-scenarios.test.ts @@ -0,0 +1,210 @@ +// Cross-server portability + idempotent-resubmit scenarios for the x402 +// `exact` intent. Mirrors MPP §19.6: +// +// - Cross-server portability: the client pays server A and re-submits the +// same payment-signature header to server B. B must reject with the +// canonical `challenge_verification_failed` code because B's verifier +// does not accept A's challenge. +// +// - Idempotent resubmit: the client pays server A, then re-submits the +// same payment-signature header to server A. A must reject with +// `signature_consumed`. +// +// Gated behind `X402_INTEROP_CROSS_SERVER=1` because the matrix needs +// two long-lived servers and live RPC credentials, neither of which the +// default `pnpm test` run wires up. + +import { afterAll, describe, expect, it } from "vitest"; +import { interopScenarios } from "../src/contracts"; +import { classifyMessageToCanonicalCode } from "../src/canonical-codes"; +import { + clientImplementations, + serverImplementations, +} from "../src/implementations"; +import { runClient, startServer, stopServer } from "../src/process"; + +const CROSS_SERVER_ENABLED = process.env.X402_INTEROP_CROSS_SERVER === "1"; + +const requiredEnvs = [ + "X402_INTEROP_RPC_URL", + "X402_INTEROP_MINT", + "X402_INTEROP_PAY_TO", + "X402_INTEROP_CLIENT_SECRET_KEY", + "X402_INTEROP_FACILITATOR_SECRET_KEY", +]; + +function missingEnvs(): string[] { + return requiredEnvs.filter( + name => !process.env[name] || process.env[name]?.trim() === "", + ); +} + +const portabilityScenario = interopScenarios.find( + scenario => scenario.id === "x402-exact-cross-server-portability", +); +const resubmitScenario = interopScenarios.find( + scenario => scenario.id === "x402-exact-idempotent-resubmit", +); + +const serversById = new Map(serverImplementations.map(s => [s.id, s])); +const clientsById = new Map(clientImplementations.map(c => [c.id, c])); + +type RunningServer = Awaited>; +const runningServers: RunningServer[] = []; + +afterAll(async () => { + for (const server of runningServers.splice(0)) { + await stopServer(server); + } +}); + +function extractCanonicalCode(body: unknown): string | undefined { + if (body && typeof body === "object" && !Array.isArray(body)) { + const record = body as Record; + if (typeof record.code === "string") return record.code; + const source = + (typeof record.error === "string" && record.error) || + (typeof record.message === "string" && record.message) || + undefined; + if (source) return classifyMessageToCanonicalCode(source); + } + if (typeof body === "string") { + return classifyMessageToCanonicalCode(body); + } + return undefined; +} + +describe("x402 exact — cross-server portability + idempotent resubmit", () => { + if (!CROSS_SERVER_ENABLED) { + it.skip("cross-server suite is gated behind X402_INTEROP_CROSS_SERVER=1", () => {}); + return; + } + + const missing = missingEnvs(); + if (missing.length > 0) { + it.skip(`missing required env vars: ${missing.join(", ")}`, () => {}); + return; + } + + if (portabilityScenario && portabilityScenario.crossServerPairs) { + for (const [serverAId, serverBId] of portabilityScenario.crossServerPairs) { + const serverA = serversById.get(serverAId); + const serverB = serversById.get(serverBId); + // Use the TS reference client to drive the pay-then-replay flow + // because it echoes the sent credential under `payment-signature-sent`. + // The Rust spine client does not surface the captured credential to + // the harness; its portability coverage is exercised by the Rust + // crate's own integration tests. + const client = clientsById.get("ts-x402"); + if (!serverA?.enabled || !serverB?.enabled || !client?.enabled) { + it.skip(`portability ${serverAId} -> ${serverBId}: adapter not enabled`, () => {}); + continue; + } + + it(`portability: pay ${serverAId} then resubmit credential to ${serverBId}`, async () => { + const env = { + X402_INTEROP_NETWORK: portabilityScenario.network, + X402_INTEROP_PRICE: portabilityScenario.price, + X402_INTEROP_RESOURCE_PATH: portabilityScenario.resourcePath, + X402_INTEROP_SETTLEMENT_HEADER: portabilityScenario.settlementHeader, + }; + + const runningA = await startServer(serverA, env); + runningServers.push(runningA); + const runningB = await startServer(serverB, env); + runningServers.push(runningB); + + try { + const urlA = `http://127.0.0.1:${runningA.ready.port}${portabilityScenario.resourcePath}`; + const payA = await runClient(client, urlA, { + X402_INTEROP_TARGET_URL: urlA, + ...env, + }); + expect(payA.status).toBe(200); + + // Re-submit the captured payment-signature header to server B. + // Adapters echo the credential they sent under `*-sent` so the + // harness can replay it. Falls back to the live payment-signature + // header for adapters that don't echo (rust spine). + const headers = payA.responseHeaders as Record; + const credential = + headers["payment-signature-sent"] ?? headers["payment-signature"]; + const urlB = `http://127.0.0.1:${runningB.ready.port}${portabilityScenario.resourcePath}`; + const replay = await fetch(urlB, { + headers: credential + ? { "payment-signature": String(credential) } + : {}, + }); + const body = await replay.json().catch(() => null); + + expect(replay.status).toBe(portabilityScenario.expectedStatus); + if (portabilityScenario.expectedCode) { + expect(extractCanonicalCode(body)).toBe(portabilityScenario.expectedCode); + } + } finally { + await stopServer(runningA); + await stopServer(runningB); + runningServers.splice(runningServers.indexOf(runningA), 1); + runningServers.splice(runningServers.indexOf(runningB), 1); + } + }, 180_000); + } + } else { + it.skip("portability scenario missing crossServerPairs", () => {}); + } + + if (resubmitScenario) { + const serverIds = resubmitScenario.serverIds ?? ["ts-x402"]; + for (const sid of serverIds) { + const server = serversById.get(sid); + // Same rationale as portability above: drive with the TS client so + // the harness can replay the captured credential. + const client = clientsById.get("ts-x402"); + if (!server?.enabled || !client?.enabled) { + it.skip(`idempotent-resubmit on ${sid}: adapter not enabled`, () => {}); + continue; + } + + it(`idempotent resubmit against ${sid}`, async () => { + const env = { + X402_INTEROP_NETWORK: resubmitScenario.network, + X402_INTEROP_PRICE: resubmitScenario.price, + X402_INTEROP_RESOURCE_PATH: resubmitScenario.resourcePath, + X402_INTEROP_SETTLEMENT_HEADER: resubmitScenario.settlementHeader, + }; + + const running = await startServer(server, env); + runningServers.push(running); + + try { + const url = `http://127.0.0.1:${running.ready.port}${resubmitScenario.resourcePath}`; + const first = await runClient(client, url, { + X402_INTEROP_TARGET_URL: url, + ...env, + }); + expect(first.status).toBe(200); + + const headers = first.responseHeaders as Record; + const credential = + headers["payment-signature-sent"] ?? headers["payment-signature"]; + const replay = await fetch(url, { + headers: credential + ? { "payment-signature": String(credential) } + : {}, + }); + const body = await replay.json().catch(() => null); + + expect(replay.status).toBe(resubmitScenario.expectedStatus); + if (resubmitScenario.expectedCode) { + expect(extractCanonicalCode(body)).toBe(resubmitScenario.expectedCode); + } + } finally { + await stopServer(running); + runningServers.splice(runningServers.indexOf(running), 1); + } + }, 180_000); + } + } else { + it.skip("idempotent-resubmit scenario missing", () => {}); + } +}); diff --git a/harness/test/e2e.test.ts b/harness/test/e2e.test.ts index 2c0d76d91..4e72e847c 100644 --- a/harness/test/e2e.test.ts +++ b/harness/test/e2e.test.ts @@ -320,13 +320,23 @@ describe("mpp interop", () => { ) { continue; } + // The x402-exact intent has its own runner in + // `test/x402-exact.e2e.test.ts` that emits `X402_INTEROP_*` env vars. + // The legacy MPP runner builds `MPP_INTEROP_*` env, which the x402 + // adapters do not consume, so we hard-skip the new intent here even + // when MPP_INTEROP_INTENTS explicitly selects it. + if (scenario.intent === "x402-exact") { + continue; + } const scenarioServers = activeServers.filter( (implementation) => - !scenario.serverIds || scenario.serverIds.includes(implementation.id), + (!implementation.intents || implementation.intents.includes(scenario.intent)) && + (!scenario.serverIds || scenario.serverIds.includes(implementation.id)), ); const scenarioClients = activeClients.filter( (implementation) => - !scenario.clientIds || scenario.clientIds.includes(implementation.id), + (!implementation.intents || implementation.intents.includes(scenario.intent)) && + (!scenario.clientIds || scenario.clientIds.includes(implementation.id)), ); for (const serverImplementation of scenarioServers) { diff --git a/harness/test/intent-selection.test.ts b/harness/test/intent-selection.test.ts index 6e8660278..1dcef686f 100644 --- a/harness/test/intent-selection.test.ts +++ b/harness/test/intent-selection.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { selectInteropIntents, selectInteropScenarios } from "../src/contracts"; describe("interop intent selection", () => { - it("defaults to the implemented charge scenario", () => { + it("defaults to the legacy charge intent for CI stability", () => { + // x402-exact is opt-in via MPP_INTEROP_INTENTS=x402-exact (or + // comma-list) so the canonical MPP charge matrix in the existing + // runner is not perturbed by the new intent's enabled-by-default + // adapters. expect(selectInteropIntents(undefined)).toEqual(["charge"]); }); @@ -10,6 +14,17 @@ describe("interop intent selection", () => { expect(selectInteropIntents(" charge ")).toEqual(["charge"]); }); + it("accepts the implemented x402-exact intent", () => { + expect(selectInteropIntents("x402-exact")).toEqual(["x402-exact"]); + }); + + it("accepts both intents at once", () => { + expect(selectInteropIntents("charge,x402-exact")).toEqual([ + "charge", + "x402-exact", + ]); + }); + it("rejects scenarios that are not implemented yet", () => { expect(() => selectInteropIntents("session")).toThrow( /Unsupported MPP_INTEROP_INTENTS/, @@ -42,6 +57,20 @@ describe("interop scenario selection", () => { ]); }); + it("returns x402-exact scenarios when explicitly requested", () => { + expect( + selectInteropScenarios("x402-exact", undefined).map( + (scenario) => scenario.id, + ), + ).toEqual([ + "x402-exact-basic", + "x402-exact-network-mismatch", + "x402-exact-cross-route-replay", + "x402-exact-cross-server-portability", + "x402-exact-idempotent-resubmit", + ]); + }); + it("runs one requested scenario", () => { expect( selectInteropScenarios("charge", "charge-split-ata").map( diff --git a/harness/test/x402-exact.e2e.test.ts b/harness/test/x402-exact.e2e.test.ts new file mode 100644 index 000000000..03aeb262e --- /dev/null +++ b/harness/test/x402-exact.e2e.test.ts @@ -0,0 +1,128 @@ +// Cross-language matrix for the x402 `exact` intent. Iterates every +// active x402 client × every active x402 server registered in +// `src/implementations.ts` and asserts the happy-path scenario reaches +// HTTP 200 with the fixture settlement header populated. +// +// Gated behind `X402_INTEROP_MATRIX=1` so the default `pnpm test` run +// in pay-kit does not require cargo or a live Surfpool RPC. The +// canonical CI invocation is: +// +// X402_INTEROP_MATRIX=1 \ +// X402_INTEROP_RPC_URL=... \ +// X402_INTEROP_PAY_TO=... \ +// X402_INTEROP_CLIENT_SECRET_KEY=[...] \ +// X402_INTEROP_FACILITATOR_SECRET_KEY=[...] \ +// pnpm test x402-exact.e2e.test.ts + +import { afterAll, describe, expect, it } from "vitest"; +import { interopScenarios } from "../src/contracts"; +import { + clientImplementations, + serverImplementations, +} from "../src/implementations"; +import { runClient, startServer, stopServer } from "../src/process"; + +const MATRIX_ENABLED = process.env.X402_INTEROP_MATRIX === "1"; + +const requiredEnvs = [ + "X402_INTEROP_RPC_URL", + "X402_INTEROP_MINT", + "X402_INTEROP_PAY_TO", + "X402_INTEROP_CLIENT_SECRET_KEY", + "X402_INTEROP_FACILITATOR_SECRET_KEY", +]; + +function missingEnvs(): string[] { + return requiredEnvs.filter( + name => !process.env[name] || process.env[name]?.trim() === "", + ); +} + +const happyPath = interopScenarios.find( + scenario => scenario.id === "x402-exact-basic", +); + +const x402Clients = clientImplementations.filter( + impl => impl.enabled && (impl.intents ?? ["charge"]).includes("x402-exact"), +); +const x402Servers = serverImplementations.filter( + impl => impl.enabled && (impl.intents ?? ["charge"]).includes("x402-exact"), +); + +type RunningServer = Awaited>; +const runningServers: RunningServer[] = []; + +afterAll(async () => { + for (const server of runningServers.splice(0)) { + await stopServer(server); + } +}); + +describe("x402 exact intent — cross-language matrix", () => { + if (!MATRIX_ENABLED) { + it.skip("matrix is gated behind X402_INTEROP_MATRIX=1", () => {}); + return; + } + + const missing = missingEnvs(); + if (missing.length > 0) { + it.skip(`missing required env vars: ${missing.join(", ")}`, () => {}); + return; + } + + if (!happyPath) { + it.fails("happy-path scenario x402-exact-basic missing from registry", () => { + throw new Error("x402-exact-basic scenario not found in interopScenarios"); + }); + return; + } + + // Pair restriction: the TS reference adapters speak a stub payload + // (no real signed Solana transaction in the fixture) so they only + // interoperate with each other. The Rust spine adapters carry the + // canonical PaymentProof and are exercised end-to-end by the rust + // crate's own integration tests (`cargo test -p solana-x402`). + // The cross-language matrix asserts the harness wiring and the + // ready/result protocol; full TS<->Rust on-chain settlement parity + // arrives with the TS SDK port (tracked separately). + const allowedPair = (clientId: string, serverId: string): boolean => { + if (clientId === "ts-x402" && serverId === "ts-x402") return true; + if (clientId === "rust-x402" && serverId === "rust-x402") return true; + return false; + }; + + for (const server of x402Servers) { + for (const client of x402Clients) { + if (!allowedPair(client.id, server.id)) { + it.skip(`${client.id} client ↔ ${server.id} server: pair not in default matrix`, () => {}); + continue; + } + it(`${client.id} client ↔ ${server.id} server: happy path`, async () => { + const env = { + X402_INTEROP_NETWORK: happyPath.network, + X402_INTEROP_PRICE: happyPath.price, + X402_INTEROP_RESOURCE_PATH: happyPath.resourcePath, + X402_INTEROP_SETTLEMENT_HEADER: happyPath.settlementHeader, + } satisfies Record; + + const running = await startServer(server, env); + runningServers.push(running); + + try { + const targetUrl = `http://127.0.0.1:${running.ready.port}${happyPath.resourcePath}`; + const result = await runClient(client, targetUrl, { + X402_INTEROP_TARGET_URL: targetUrl, + ...env, + }); + + expect(result.status).toBe(happyPath.expectedStatus); + expect(result.ok).toBe(true); + expect(result.settlement).toBeTruthy(); + } finally { + await stopServer(running); + runningServers.splice(runningServers.indexOf(running), 1); + } + }, 120_000); + } + } +});