diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 39e300bc9..36e4d69f3 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -58,6 +58,9 @@ jobs: - name: Install PHP harness dependencies working-directory: php run: composer install --no-interaction --no-progress + - name: Verify PHP x402 replay-store startup + working-directory: harness + run: pnpm exec vitest run test/php-x402-startup.test.ts # Cross-SDK conformance vectors for PHP: the PHP exact verifier must execute # the x402 exact-reject fund-safety vectors, mirroring the python leg. The # conformance runner (harness/runners/php.json) launches diff --git a/go/paykit/adapters/x402/binding_test.go b/go/paykit/adapters/x402/binding_test.go index 2da3e482c..a7ef98368 100644 --- a/go/paykit/adapters/x402/binding_test.go +++ b/go/paykit/adapters/x402/binding_test.go @@ -1,6 +1,8 @@ package x402 import ( + "bytes" + "context" "encoding/base64" "encoding/json" "errors" @@ -9,6 +11,7 @@ import ( "github.com/solana-foundation/pay-kit/go/paycore/signer" "github.com/solana-foundation/pay-kit/go/paykit" proto "github.com/solana-foundation/pay-kit/go/protocols/x402" + solana "github.com/solana-foundation/solana-go/v2" ) func bindingAdapter(t *testing.T) *Adapter { @@ -35,11 +38,75 @@ func encodeCredential(t *testing.T, cred proto.Credential) string { return base64.StdEncoding.EncodeToString(raw) } +func TestTotalUnitsRejectsPositiveSubBaseUnitPrice(t *testing.T) { + a := bindingAdapter(t) + gate := &paykit.Gate{Amount: paykit.MustParseUSD("0.0000009")} + + if _, err := a.totalUnits(gate, "USDC"); err == nil { + t.Fatal("expected a positive price below one base unit to be rejected") + } + if entry, err := a.AcceptsEntry(gate); err == nil || entry != nil { + t.Fatalf("sub-base-unit price must fail accepts entry construction, got entry=%v err=%v", entry, err) + } + if headers, err := a.ChallengeHeaders(gate); err == nil || headers != nil { + t.Fatalf("sub-base-unit price must fail challenge construction, got headers=%v err=%v", headers, err) + } + + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{ + Gate: gate, + PaymentSig: encodeCredential(t, proto.Credential{}), + }) + var perr *paykit.PaymentError + if !errors.As(err, &perr) || perr.Code != "invalid_gate" { + t.Fatalf("expected invalid_gate for sub-base-unit price, got %v", err) + } +} + +func TestTotalUnitsRejectsFractionalBaseUnitPrice(t *testing.T) { + a := bindingAdapter(t) + gate := &paykit.Gate{Amount: paykit.MustParseUSD("0.0000019")} + + if _, err := a.totalUnits(gate, "USDC"); err == nil { + t.Fatal("expected a fractional base-unit price to be rejected") + } +} + +func TestTotalUnitsBindsExactlyOneBaseUnit(t *testing.T) { + a := bindingAdapter(t) + gate := &paykit.Gate{Amount: paykit.MustParseUSD("0.000001")} + + units, err := a.totalUnits(gate, "USDC") + if err != nil { + t.Fatalf("one base unit should be accepted: %v", err) + } + if units != "1" { + t.Fatalf("totalUnits = %q, want %q", units, "1") + } + + route, err := a.routeAccepts(gate) + if err != nil { + t.Fatalf("routeAccepts: %v", err) + } + if route.Amount != "1" || route.MaxAmountRequired != "1" { + t.Fatalf("route amounts = (%q, %q), want (1, 1)", route.Amount, route.MaxAmountRequired) + } + reqs, err := a.transferRequirements(gate) + if err != nil { + t.Fatalf("transferRequirements: %v", err) + } + if reqs.Amount != 1 { + t.Fatalf("transfer amount = %d, want 1", reqs.Amount) + } +} + func TestVerifyRejectsLyingAcceptedAmount(t *testing.T) { a := bindingAdapter(t) gate := paykit.Gate{Amount: paykit.MustParseUSD("0.10")} - route := a.routeAccepts(&gate) + route, err := a.routeAccepts(&gate) + if err != nil { + t.Fatal(err) + } tampered := route tampered.Amount = "999999999" tampered.MaxAmountRequired = "999999999" @@ -49,7 +116,7 @@ func TestVerifyRejectsLyingAcceptedAmount(t *testing.T) { Payload: proto.CredentialPayload{Transaction: base64.StdEncoding.EncodeToString([]byte("ignored"))}, Accepted: &tampered, } - _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) var perr *paykit.PaymentError if !errors.As(err, &perr) || perr.Code != "charge_request_mismatch" { t.Fatalf("expected charge_request_mismatch for lying amount, got %v", err) @@ -60,7 +127,10 @@ func TestVerifyRejectsLyingAcceptedRecipient(t *testing.T) { a := bindingAdapter(t) gate := paykit.Gate{Amount: paykit.MustParseUSD("0.10")} - route := a.routeAccepts(&gate) + route, err := a.routeAccepts(&gate) + if err != nil { + t.Fatal(err) + } tampered := route tampered.PayTo = string(signer.Generate().Pubkey()) @@ -69,24 +139,236 @@ func TestVerifyRejectsLyingAcceptedRecipient(t *testing.T) { Payload: proto.CredentialPayload{Transaction: base64.StdEncoding.EncodeToString([]byte("ignored"))}, Accepted: &tampered, } - _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) var perr *paykit.PaymentError if !errors.As(err, &perr) || perr.Code != "charge_request_mismatch" { t.Fatalf("expected charge_request_mismatch for lying recipient, got %v", err) } } +// TestCosignRefusesOperatorOutsideFeePayerSlot is the fee-payer-slot-pin +// regression: the +// operator must only ever co-sign the fee-payer slot (account key index 0). A +// transaction that places the operator key at any other index with a zero +// signature slot must NOT be co-signed, even though the operator's key appears +// in the account list, matching the Rust/TS index-0 pin. Signing an arbitrary +// slot would let a crafted transaction spend the operator's signature on an +// instruction it never intended to authorize. +// +// Before the fix cosign scanned for the first zero-signature slot matching the +// operator anywhere in AccountKeys and signed it, so a slot-2 operator was +// co-signed. +func TestCosignRefusesOperatorOutsideFeePayerSlot(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{ + Network: paykit.SolanaMainnet, + Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, + X402: paykit.X402Config{Scheme: "exact"}, + }, + signer: op, + } + + // Operator key sits at index 2, not the fee-payer index 0. + feePayer := solana.NewWallet().PublicKey() + other := solana.NewWallet().PublicKey() + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: solana.PublicKeySlice{feePayer, other, opPub}, + Header: solana.MessageHeader{NumRequiredSignatures: 3}, + }, + // Three signature slots, all zero: slot 0 (fee payer), slot 1, slot 2 + // (the operator). A pre-fix cosign would fill slot 2. + Signatures: []solana.Signature{{}, {}, {}}, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + + wire, err := a.cosign(context.Background(), tx, rawTx) + if err != nil { + t.Fatalf("cosign should not error when the operator is not the fee payer, got %v", err) + } + // The operator must not have signed any slot: the wire bytes are unchanged. + if !bytes.Equal(wire, rawTx) { + t.Fatal("cosign signed a non-fee-payer slot; operator must only co-sign account key index 0") + } +} + +// TestCosignSignsFeePayerSlotZero is the fee-payer-slot-pin positive: when the operator IS the +// fee payer (account key index 0) and its signature slot is zero, cosign fills +// exactly slot 0 and leaves every other slot untouched. +func TestCosignSignsFeePayerSlotZero(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{ + Network: paykit.SolanaMainnet, + Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, + X402: paykit.X402Config{Scheme: "exact"}, + }, + signer: op, + } + + clientSig := solana.MustSignatureFromBase58(sampleClientSig) + other := solana.NewWallet().PublicKey() + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: solana.PublicKeySlice{opPub, other}, + Header: solana.MessageHeader{NumRequiredSignatures: 2}, + }, + // Slot 0 (operator/fee payer) zero; slot 1 already carries a client + // signature that must be preserved. + Signatures: []solana.Signature{{}, clientSig}, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + + wire, err := a.cosign(context.Background(), tx, rawTx) + if err != nil { + t.Fatalf("cosign: %v", err) + } + if bytes.Equal(wire, rawTx) { + t.Fatal("cosign left slot 0 unsigned; the operator fee-payer slot must be filled") + } + // Slot 1 (offset 1+64=65 .. 129) must be byte-identical to the client's + // signature: only slot 0 changed. + if !bytes.Equal(wire[1+64:1+128], rawTx[1+64:1+128]) { + t.Fatal("cosign mutated a non-fee-payer signature slot") + } +} + +// TestCosignSkipsAlreadyFilledFeePayerSlot covers the no-op path: the operator +// is the fee payer at index 0 but slot 0 already carries a signature, so cosign +// leaves the wire untouched. +func TestCosignSkipsAlreadyFilledFeePayerSlot(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{Network: paykit.SolanaMainnet, Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, X402: paykit.X402Config{Scheme: "exact"}}, + signer: op, + } + filled := solana.MustSignatureFromBase58(sampleClientSig) + tx := &solana.Transaction{ + Message: solana.Message{AccountKeys: solana.PublicKeySlice{opPub}, Header: solana.MessageHeader{NumRequiredSignatures: 1}}, + Signatures: []solana.Signature{filled}, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + wire, err := a.cosign(context.Background(), tx, rawTx) + if err != nil { + t.Fatalf("cosign: %v", err) + } + if !bytes.Equal(wire, rawTx) { + t.Fatal("cosign overwrote an already-filled fee-payer slot") + } +} + +// TestCosignSupportsWideSignerVector proves co-signing uses the transaction +// encoder instead of relying on a one-byte short-vector prefix offset. +func TestCosignSupportsWideSignerVector(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{Network: paykit.SolanaMainnet, Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, X402: paykit.X402Config{Scheme: "exact"}}, + signer: op, + } + keys := make(solana.PublicKeySlice, 128) + keys[0] = opPub + for i := 1; i < 128; i++ { + keys[i] = solana.NewWallet().PublicKey() + } + sigs := make([]solana.Signature, 128) // slot 0 zero, operator is fee payer. + tx := &solana.Transaction{ + Message: solana.Message{AccountKeys: keys, Header: solana.MessageHeader{NumRequiredSignatures: 128}}, + Signatures: sigs, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + wire, err := a.cosign(context.Background(), tx, rawTx) + if err != nil { + t.Fatalf("cosign wide signer vector: %v", err) + } + if len(wire) == 0 || tx.Signatures[0].IsZero() { + t.Fatal("cosign must fill the fee-payer signature in a wide signer vector") + } + for i := 1; i < len(tx.Signatures); i++ { + if !tx.Signatures[i].IsZero() { + t.Fatalf("cosign mutated non-fee-payer signature slot %d", i) + } + } +} + +func TestCosignRejectsSignerVectorMismatchedWithMessage(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{Network: paykit.SolanaMainnet, Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, X402: paykit.X402Config{Scheme: "exact"}}, + signer: op, + } + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: solana.PublicKeySlice{opPub}, + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + }, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if _, err := a.cosign(context.Background(), tx, rawTx); err == nil { + t.Fatal("cosign must reject an in-memory transaction whose signature vector does not match the message") + } +} + +func TestCosignRejectsReadOnlyFeePayer(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{Network: paykit.SolanaMainnet, Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, X402: paykit.X402Config{Scheme: "exact"}}, + signer: op, + } + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: solana.PublicKeySlice{opPub}, + Header: solana.MessageHeader{ + NumRequiredSignatures: 1, + NumReadonlySignedAccounts: 1, + }, + }, + Signatures: []solana.Signature{{}}, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if _, err := a.cosign(context.Background(), tx, rawTx); err == nil { + t.Fatal("cosign must reject a read-only fee payer") + } +} + func TestVerifyAcceptsHonestAcceptedThenProceeds(t *testing.T) { a := bindingAdapter(t) gate := paykit.Gate{Amount: paykit.MustParseUSD("0.10")} - honest := a.routeAccepts(&gate) + honest, err := a.routeAccepts(&gate) + if err != nil { + t.Fatal(err) + } cred := proto.Credential{ X402Version: proto.X402Version, Payload: proto.CredentialPayload{Transaction: base64.StdEncoding.EncodeToString([]byte("not-a-tx"))}, Accepted: &honest, } - _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) var perr *paykit.PaymentError if !errors.As(err, &perr) { t.Fatalf("expected a PaymentError, got %v", err) diff --git a/go/paykit/adapters/x402/exact.go b/go/paykit/adapters/x402/exact.go index de0aa94bb..ee649cb02 100644 --- a/go/paykit/adapters/x402/exact.go +++ b/go/paykit/adapters/x402/exact.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "strconv" "sync" "time" @@ -14,17 +15,43 @@ import ( bin "github.com/gagliardetto/binary" "github.com/solana-foundation/pay-kit/go/paycore" "github.com/solana-foundation/pay-kit/go/paykit" + mppcore "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" proto "github.com/solana-foundation/pay-kit/go/protocols/x402" solana "github.com/solana-foundation/solana-go/v2" "github.com/solana-foundation/solana-go/v2/rpc" + "github.com/solana-foundation/solana-go/v2/rpc/jsonrpc" ) +// consumedPrefix namespaces the replay markers this adapter writes into the +// shared store so they never collide with other schemes' keys. Mirrors the +// MPP charge server's consumed-signature namespace. +const consumedPrefix = "x402-svm-exact:consumed:" + type Adapter struct { cfg paykit.Config signer paykit.Signer rpc proto.RPCClient - replay sync.Map + replay mppcore.Store + replayOnce sync.Once blockhashProvider func() (string, error) + // confirmAttempts and confirmDelay bound the settlement confirmation + // poll. Zero values fall back to the package defaults; tests set them + // small to exercise the confirmation-timeout path deterministically. + confirmAttempts int + confirmDelay time.Duration +} + +// replayStore returns the adapter's consumed-signature store, lazily +// defaulting to a process-local in-memory store when the adapter was built +// without one (New always injects one; a bare literal, e.g. in tests, may +// not). Cached so repeated settlements share the same replay set. +func (a *Adapter) replayStore() mppcore.Store { + a.replayOnce.Do(func() { + if a.replay == nil { + a.replay = mppcore.NewMemoryStore() + } + }) + return a.replay } func New(cfg paykit.Config) (paykit.Adapter, error) { @@ -39,10 +66,33 @@ func New(cfg paykit.Config) (paykit.Adapter, error) { if sgn == nil { sgn = cfg.Operator.Signer } + allowUnsafeMemoryStore := os.Getenv("PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE") == "1" + if cfg.Network == paykit.SolanaMainnet && allowUnsafeMemoryStore { + return nil, errors.New("protocols/x402: PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE is forbidden on mainnet; inject a durable shared replay store") + } + store := cfg.X402.ReplayStore + if store == nil { + if cfg.Network == paykit.SolanaMainnet { + return nil, errors.New("protocols/x402: mainnet exact settlement requires X402.ReplayStore with durable shared replay capability") + } + if cfg.Network != paykit.SolanaLocalnet && !allowUnsafeMemoryStore { + return nil, errors.New("protocols/x402: non-localnet exact settlement requires X402.ReplayStore with durable shared replay capability; inject a shared atomic store or set PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 to acknowledge process-local replay protection") + } + store = mppcore.NewMemoryStore() + } else if cfg.Network != paykit.SolanaLocalnet && !allowUnsafeMemoryStore { + capability, ok := store.(paykit.ReplayStoreCapability) + if !ok || !capability.ProvidesDurableSharedReplayProtection() { + if cfg.Network == paykit.SolanaMainnet { + return nil, errors.New("protocols/x402: mainnet X402.ReplayStore must implement ReplayStoreCapability and report durable shared replay protection") + } + return nil, errors.New("protocols/x402: non-localnet X402.ReplayStore must implement ReplayStoreCapability and report durable shared replay protection; set PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 only for an explicit process-local override") + } + } a := &Adapter{ cfg: cfg, signer: sgn, rpc: rpc.New(rpcURL), + replay: store, blockhashProvider: cfg.RecentBlockhashProvider, } return a, nil @@ -60,7 +110,10 @@ func (a *Adapter) AcceptsEntry(gate *paykit.Gate) (paykit.AcceptsEntry, error) { coin := a.settlementCoin(gate) label := a.cfg.Network.MintsLabel() mint := paycore.ResolveMint(coin, label) - amount := a.totalUnits(gate, coin) + amount, err := a.totalUnits(gate, coin) + if err != nil { + return nil, err + } payTo := a.payTo(gate) extra := proto.Extra{ FeePayer: true, @@ -132,6 +185,14 @@ func (a *Adapter) advertisedExtensions() json.RawMessage { func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, error) { ctx := context.Background() + gate := *req.Gate + if req.Path != "" { + gate.Desc = req.Path + } + req.Gate = &gate + if _, err := a.totalUnits(req.Gate, a.settlementCoin(req.Gate)); err != nil { + return nil, &paykit.PaymentError{Code: "invalid_gate", Err: err, Gate: req.Gate} + } sig := req.PaymentSig if sig == "" { sig = req.PaymentSigLegacy @@ -217,13 +278,38 @@ func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, if err != nil { return nil, &paykit.PaymentError{Code: "invalid_payload", Err: err, Gate: req.Gate} } - if _, loaded := a.replay.LoadOrStore(replayKey, struct{}{}); loaded { + // Reserve the credential before broadcast so a concurrent duplicate is + // rejected during the verify + cosign window. The reservation is released + // ONLY on a definitive pre-broadcast failure (cosign error, send error), + // cases where the transaction provably never reached the chain. Once the + // broadcast succeeds the marker becomes permanent and is never deleted on + // a confirmation timeout, because the transaction may still land on-chain; + // deleting it would reopen a double-serve window on this replica (and, with + // a shared store, across replicas) while the original settlement lands. + // Mirrors the MPP charge server's consumed-signature invariant + // (protocols/mpp/server/server.go: cleanupConsumed pinned to false right + // after SendTransaction succeeds, never released on a confirmation timeout). + consumedKey := consumedPrefix + replayKey + store := a.replayStore() + inserted, err := store.PutIfAbsent(ctx, consumedKey, true) + if err != nil { + // A store I/O failure (e.g. a shared Redis outage) is NOT a replay: the + // credential's consumption state is simply unknown. Surface a distinct + // code so operators debugging an outage don't see honest clients told + // their credential was already spent (which could cause a client to + // discard a valid, unsettled credential). "signature_consumed" is + // reserved for the provably-already-reserved (!inserted) branch below. + return nil, &paykit.PaymentError{Code: "replay_store_error", Err: err, Gate: req.Gate} + } + if !inserted { return nil, &paykit.PaymentError{Code: "signature_consumed", Err: errors.New("replay rejected"), Gate: req.Gate} } - settled := false + cleanupConsumed := true defer func() { - if !settled { - a.replay.Delete(replayKey) + if cleanupConsumed { + // Detach cancellation but keep values so the rollback still runs + // when the caller's context is already canceled. + _ = store.Delete(context.WithoutCancel(ctx), consumedKey) } }() wire, err := a.cosign(ctx, tx, rawTx) @@ -238,12 +324,30 @@ func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, }, ) if err != nil { + // A transport timeout or generic RPC error does not prove the node + // rejected the transaction before broadcast: it may have accepted the + // bytes and lost the response. Keep the reservation on every ambiguous + // send failure. Solana's explicit simulation failure (-32002) is the one + // definitive preflight rejection and is safe to release for a corrected + // retry. + if !definitivePreflightFailure(err) { + cleanupConsumed = false + } return nil, &paykit.PaymentError{Code: "send_failed", Err: err, Gate: req.Gate} } + // The RPC accepted the broadcast. From here the transaction may land even + // if confirmation polling times out, so pin the replay marker now: a + // confirmation/verify timeout must not delete it and reopen the credential + // for a second submission while the original lands and double-pays. + cleanupConsumed = false if err := a.awaitConfirmation(ctx, signature); err != nil { + // Release only when this transaction's exact recent blockhash is + // finalized-invalid and its signature is absent from full history. + if a.settlementNotLanded(ctx, signature, tx.Message.RecentBlockhash) { + _ = store.Delete(context.WithoutCancel(ctx), consumedKey) + } return nil, &paykit.PaymentError{Code: "settlement_failed", Err: err, Gate: req.Gate} } - settled = true respEnvelope := proto.SettlementResponse{ Success: true, Transaction: signature.String(), @@ -268,6 +372,11 @@ func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, }, nil } +func definitivePreflightFailure(err error) bool { + var rpcErr *jsonrpc.RPCError + return errors.As(err, &rpcErr) && rpcErr.Code == -32002 +} + func transactionReplayKey(tx *solana.Transaction) (string, error) { for _, sig := range tx.Signatures { if !sig.IsZero() { @@ -281,7 +390,10 @@ func (a *Adapter) verifyLegacyBinding(gate *paykit.Gate, credential *proto.Crede if credential.Scheme != proto.ExactScheme { return fmt.Errorf("scheme mismatch: expected %s, got %q", proto.ExactScheme, credential.Scheme) } - route := a.routeAccepts(gate) + route, err := a.routeAccepts(gate) + if err != nil { + return err + } got := normalizeNetwork(credential.Network) if got != route.Network { return fmt.Errorf("network mismatch: expected %s, got %s", route.Network, credential.Network) @@ -290,7 +402,10 @@ func (a *Adapter) verifyLegacyBinding(gate *paykit.Gate, credential *proto.Crede } func (a *Adapter) verifyAcceptedBinding(gate *paykit.Gate, accepted *proto.AcceptsEntry) error { - route := a.routeAccepts(gate) + route, err := a.routeAccepts(gate) + if err != nil { + return err + } if accepted.Network != route.Network { return fmt.Errorf("network mismatch: expected %s, got %s", route.Network, accepted.Network) } @@ -317,16 +432,20 @@ func (a *Adapter) verifyAcceptedBinding(gate *paykit.Gate, accepted *proto.Accep return nil } -func (a *Adapter) routeAccepts(gate *paykit.Gate) proto.AcceptsEntry { +func (a *Adapter) routeAccepts(gate *paykit.Gate) (proto.AcceptsEntry, error) { coin := a.settlementCoin(gate) label := a.cfg.Network.MintsLabel() + amount, err := a.totalUnits(gate, coin) + if err != nil { + return proto.AcceptsEntry{}, err + } return proto.AcceptsEntry{ Protocol: "x402", Scheme: a.cfg.X402.Scheme, Network: a.cfg.Network.CAIP2(), Asset: paycore.ResolveMint(coin, label), - Amount: a.totalUnits(gate, coin), - MaxAmountRequired: a.totalUnits(gate, coin), + Amount: amount, + MaxAmountRequired: amount, PayTo: string(a.payTo(gate)), MaxTimeoutSeconds: proto.DefaultMaxTimeoutSeconds, Extra: proto.Extra{ @@ -338,7 +457,7 @@ func (a *Adapter) routeAccepts(gate *paykit.Gate) proto.AcceptsEntry { TokenProgram: paycore.DefaultTokenProgramForCurrency(coin, label), Memo: gate.Desc, }, - } + }, nil } func canonicalAccepted(e *proto.AcceptsEntry) ([]byte, error) { @@ -369,7 +488,11 @@ func (a *Adapter) transferRequirements(gate *paykit.Gate) (proto.TransferRequire if err != nil { return proto.TransferRequirements{}, fmt.Errorf("operator pubkey: %w", err) } - amount, err := strconv.ParseUint(a.totalUnits(gate, coin), 10, 64) + amountText, err := a.totalUnits(gate, coin) + if err != nil { + return proto.TransferRequirements{}, fmt.Errorf("amount: %w", err) + } + amount, err := strconv.ParseUint(amountText, 10, 64) if err != nil { return proto.TransferRequirements{}, fmt.Errorf("amount: %w", err) } @@ -386,14 +509,41 @@ func (a *Adapter) transferRequirements(gate *paykit.Gate) (proto.TransferRequire } func (a *Adapter) cosign(ctx context.Context, tx *solana.Transaction, rawTx []byte) ([]byte, error) { + if tx == nil { + return nil, errors.New("transaction is nil") + } operator, err := solana.PublicKeyFromBase58(string(a.signer.Pubkey())) if err != nil { return nil, fmt.Errorf("operator pubkey: %w", err) } - // The fee payer is the canonical first required signer and therefore owns - // signature slot 0. Never sign a matching operator key in a later slot. - if len(tx.Message.AccountKeys) == 0 || len(tx.Signatures) == 0 || - !tx.Message.AccountKeys[0].Equals(operator) || !tx.Signatures[0].IsZero() { + // The operator co-signs ONLY the fee-payer slot, which on Solana is always + // account key index 0. Pinning to slot 0 (rather than scanning for the + // operator key anywhere in the account list) closes the operator-signs- + // attacker-instruction path: a crafted transaction could place a different + // signer at index 0 and the operator's key at some later signer index, and + // a scan would then spend the operator's signature on a slot it never + // intended to authorize while leaving the real fee payer unsigned. Mirrors + // the Rust/TS index-0 pin. + signers := tx.Message.Signers() + requiredSigners := int(tx.Message.Header.NumRequiredSignatures) + if requiredSigners == 0 || len(signers) != requiredSigners || len(tx.Signatures) != requiredSigners { + return nil, fmt.Errorf("invalid signer vector: message requires %d signatures, transaction carries %d", requiredSigners, len(tx.Signatures)) + } + // Solana debits the fee payer, so the first required signer must be + // writable. A message marking every required signer read-only is invalid; + // refuse to sign it instead of producing an operator signature for a + // transaction that cannot be a valid fee-paying transaction. + if int(tx.Message.Header.NumReadonlySignedAccounts) >= requiredSigners { + return nil, errors.New("invalid fee payer: first required signer is read-only") + } + if !signers[0].Equals(operator) { + // The operator is not this transaction's fee payer, so it has nothing to + // co-sign. Return the wire unchanged rather than signing an off-slot key. + return rawTx, nil + } + if !tx.Signatures[0].IsZero() { + // Slot 0 is either absent or already filled (the operator or client + // already signed): nothing to do. return rawTx, nil } msgBytes, err := tx.Message.MarshalBinary() @@ -469,8 +619,14 @@ func (a *Adapter) rejectManagedSourceOwner(ctx context.Context, tx *solana.Trans } func (a *Adapter) awaitConfirmation(ctx context.Context, signature solana.Signature) error { - const attempts = 40 - const delay = 250 * time.Millisecond + attempts := a.confirmAttempts + if attempts <= 0 { + attempts = 40 + } + delay := a.confirmDelay + if delay <= 0 { + delay = 250 * time.Millisecond + } for range attempts { statuses, err := a.rpc.GetSignatureStatuses(ctx, true, signature) if err == nil && statuses != nil && len(statuses.Value) > 0 { @@ -494,6 +650,26 @@ func (a *Adapter) awaitConfirmation(ctx context.Context, signature solana.Signat return fmt.Errorf("timed out confirming %s", signature) } +type blockhashValidator interface { + IsBlockhashValid(context.Context, solana.Hash, rpc.CommitmentType) (*rpc.IsValidBlockhashResult, error) +} + +// settlementNotLanded releases only with evidence tied to the transaction's +// signed blockhash. A fresh GetLatestBlockhash response cannot establish this +// transaction's expiry and must never be used as a substitute. +func (a *Adapter) settlementNotLanded(ctx context.Context, signature solana.Signature, blockhash solana.Hash) bool { + validator, ok := a.rpc.(blockhashValidator) + if !ok { + return false + } + validity, err := validator.IsBlockhashValid(ctx, blockhash, rpc.CommitmentFinalized) + if err != nil || validity == nil || validity.Value { + return false + } + statuses, err := a.rpc.GetSignatureStatuses(ctx, true, signature) + return err == nil && statuses != nil && len(statuses.Value) > 0 && statuses.Value[0] == nil +} + func (a *Adapter) recentBlockhash() (string, error) { if a.blockhashProvider != nil { return a.blockhashProvider() @@ -525,9 +701,17 @@ func (a *Adapter) payTo(gate *paykit.Gate) paykit.Address { return a.cfg.Operator.Recipient } -func (a *Adapter) totalUnits(gate *paykit.Gate, _ string) string { - scaled := gate.Total().Amount().Shift(int32(proto.StablecoinDecimals)) - return scaled.Truncate(0).String() +func (a *Adapter) totalUnits(gate *paykit.Gate, _ string) (string, error) { + total := gate.Total().Amount() + scaled := total.Shift(int32(proto.StablecoinDecimals)) + units := scaled.Truncate(0) + if total.IsPositive() && units.IsZero() { + return "", fmt.Errorf("price %s resolves below one base unit", total) + } + if !scaled.Equal(units) { + return "", fmt.Errorf("price %s is not representable in base units", total) + } + return units.String(), nil } func normalizeNetwork(network string) string { diff --git a/go/paykit/adapters/x402/exact_test.go b/go/paykit/adapters/x402/exact_test.go index da106ed97..786616d14 100644 --- a/go/paykit/adapters/x402/exact_test.go +++ b/go/paykit/adapters/x402/exact_test.go @@ -45,7 +45,10 @@ func TestVerifyAcceptedBindingDirect(t *testing.T) { } adapter := a.(*Adapter) g := &paykit.Gate{Amount: paykit.MustParseUSD("0.10")} - route := adapter.routeAccepts(g) + route, err := adapter.routeAccepts(g) + if err != nil { + t.Fatalf("routeAccepts: %v", err) + } if err := adapter.verifyAcceptedBinding(g, &route); err != nil { t.Errorf("matching route should bind: %v", err) diff --git a/go/paykit/adapters/x402/replay_test.go b/go/paykit/adapters/x402/replay_test.go new file mode 100644 index 000000000..41d152e47 --- /dev/null +++ b/go/paykit/adapters/x402/replay_test.go @@ -0,0 +1,636 @@ +package x402 + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/solana-foundation/pay-kit/go/paycore" + "github.com/solana-foundation/pay-kit/go/paycore/signer" + "github.com/solana-foundation/pay-kit/go/paycore/solanatx" + "github.com/solana-foundation/pay-kit/go/paykit" + mppcore "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" + proto "github.com/solana-foundation/pay-kit/go/protocols/x402" + solana "github.com/solana-foundation/solana-go/v2" + "github.com/solana-foundation/solana-go/v2/rpc" + "github.com/solana-foundation/solana-go/v2/rpc/jsonrpc" +) + +// pendingRPC is a broadcast test double whose send always succeeds but whose +// confirmation status never advances past pending, so awaitConfirmation runs +// out its poll budget and returns a timeout. This models the "landed but +// unconfirmed" window: the RPC accepted the broadcast (the transaction may +// still land on-chain) yet the server never observes a confirmed status. +type pendingRPC struct { + sig solana.Signature + sends int +} + +func replaySourceAccount() *rpc.GetAccountInfoResult { + data := make([]byte, 165) + copy(data[32:64], solana.NewWallet().PublicKey().Bytes()) + return &rpc.GetAccountInfoResult{Value: &rpc.Account{ + Owner: solana.MustPublicKeyFromBase58(paycore.TokenProgram), + Data: rpc.DataBytesOrJSONFromBytes(data), + }} +} + +func (p *pendingRPC) GetAccountInfoWithOpts(context.Context, solana.PublicKey, *rpc.GetAccountInfoOpts) (*rpc.GetAccountInfoResult, error) { + return replaySourceAccount(), nil +} + +func (p *pendingRPC) SendEncodedTransactionWithOpts(_ context.Context, _ string, _ rpc.TransactionOpts) (solana.Signature, error) { + p.sends++ + return p.sig, nil +} + +func (p *pendingRPC) GetSignatureStatuses(_ context.Context, _ bool, _ ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + // Empty confirmation status with no error: neither confirmed nor + // finalized, so awaitConfirmation keeps polling until the budget is + // exhausted. + st := &rpc.SignatureStatusesResult{} + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{st}}, nil +} + +func (p *pendingRPC) GetLatestBlockhash(_ context.Context, _ rpc.CommitmentType) (*rpc.GetLatestBlockhashResult, error) { + return &rpc.GetLatestBlockhashResult{Value: &rpc.LatestBlockhashResult{}}, nil +} + +// exactCredential builds a structurally valid x402 exact transaction that pays +// the given operator, and returns the base64 credential a client would submit. +func exactCredential(t *testing.T, op paykit.Signer) string { + t.Helper() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + mint := solana.MustPublicKeyFromBase58(paycore.USDCMainnetMint) + tokenProgram := solana.MustPublicKeyFromBase58(paycore.TokenProgram) + authority := solana.NewWallet().PublicKey() + source := solana.NewWallet().PublicKey() + dest, err := solanatx.FindAssociatedTokenAddressWithProgram(opPub, mint, tokenProgram) + if err != nil { + t.Fatal(err) + } + computeBudget := solana.MustPublicKeyFromBase58(proto.ComputeBudgetProgram) + keys := solana.PublicKeySlice{opPub, authority, source, mint, dest, computeBudget, tokenProgram} + const amount = uint64(1000) + priceData := make([]byte, 9) + priceData[0] = 3 + binaryPutUint64(priceData[1:], 1000) + transferData := make([]byte, 10) + transferData[0] = 12 + binaryPutUint64(transferData[1:9], amount) + transferData[9] = 6 + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: keys, + Header: solana.MessageHeader{NumRequiredSignatures: 2}, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 5, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 5, Data: priceData}, + {ProgramIDIndex: 6, Accounts: []uint16{2, 3, 4, 1}, Data: transferData}, + }, + }, + Signatures: []solana.Signature{{}, solana.MustSignatureFromBase58(sampleClientSig)}, + } + wire, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + cred := proto.Credential{X402Version: proto.X402Version, Payload: proto.CredentialPayload{Transaction: base64.StdEncoding.EncodeToString(wire)}} + credJSON, err := json.Marshal(cred) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(credJSON) +} + +func binaryPutUint64(b []byte, v uint64) { + for i := range 8 { + b[i] = byte(v >> (8 * uint(i))) + } +} + +func opConfig(op paykit.Signer, store mppcore.Store) paykit.Config { + return paykit.Config{ + Network: paykit.SolanaLocalnet, + Stablecoins: []paykit.Stablecoin{paykit.USDC}, + Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, + X402: paykit.X402Config{Scheme: "exact", ReplayStore: store}, + } +} + +// TestConfirmationTimeoutKeepsReplayMarker is the double-pay-protection +// regression: after a +// successful broadcast the confirmation poll times out (the transaction may +// have landed), so the credential MUST stay consumed. A retry with the same +// credential must be rejected as signature_consumed rather than re-broadcast. +// +// Before the fix the deferred rollback deleted the marker whenever settlement +// did not complete, including on a confirmation timeout, reopening a +// double-serve window; this test drives exactly that path. +func TestConfirmationTimeoutKeepsReplayMarker(t *testing.T) { + op := signer.Generate() + fake := &pendingRPC{sig: solana.MustSignatureFromBase58(sampleSig)} + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + confirmAttempts: 2, + confirmDelay: time.Millisecond, + } + cred := exactCredential(t, op) + + // First submission: send succeeds, confirmation never lands -> timeout. + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) || perr.Code != "settlement_failed" { + t.Fatalf("first submission: expected settlement_failed on confirmation timeout, got %v", err) + } + if fake.sends != 1 { + t.Fatalf("expected exactly one broadcast on the first submission, got %d", fake.sends) + } + + // Retry with the same credential: the marker must still be held, so the + // adapter rejects the replay instead of broadcasting a second time. + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + if !errorsAs(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("retry after timeout: expected signature_consumed, got %v", err) + } + if fake.sends != 1 { + t.Fatalf("replay must not reach broadcast; expected 1 total send, got %d", fake.sends) + } +} + +// notLandedRPC models the definitive post-timeout not-landed check. Broadcast +// always succeeds; confirmation polling never advances (so awaitConfirmation +// runs out its budget); the final searchTransactionHistory status lookup is +// scripted per field so a test can drive either the provably-not-landed release +// path or one of the keep-the-marker (ambiguous) branches. +type notLandedRPC struct { + sig solana.Signature + // statusErr, when set, makes the final status lookup fail (indeterminate: + // we cannot prove the tx did not land, so the marker must be KEPT). + statusErr error + // found, when true, returns a landed on-chain status for the signature (the + // marker must be KEPT because the tx occupied its slot). + found bool + // lastValidBlockHeight is echoed from GetLatestBlockhash so the adapter can + // capture the blockhash validity window at broadcast time. + lastValidBlockHeight uint64 + // currentHeight is returned by GetBlockHeight: when it is past + // lastValidBlockHeight the blockhash is provably expired. + currentHeight uint64 + // heightErr, when set, makes GetBlockHeight fail so expiry cannot be proven + // (the marker must be KEPT). + heightErr error + sends int + // statusCalls counts confirmation-status lookups; the final definitive + // check is the last one. + statusCalls int +} + +func (n *notLandedRPC) GetAccountInfoWithOpts(context.Context, solana.PublicKey, *rpc.GetAccountInfoOpts) (*rpc.GetAccountInfoResult, error) { + return replaySourceAccount(), nil +} + +func (n *notLandedRPC) SendEncodedTransactionWithOpts(_ context.Context, _ string, _ rpc.TransactionOpts) (solana.Signature, error) { + n.sends++ + return n.sig, nil +} + +func (n *notLandedRPC) GetSignatureStatuses(_ context.Context, _ bool, _ ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + n.statusCalls++ + if n.statusErr != nil { + return nil, n.statusErr + } + if n.found { + st := &rpc.SignatureStatusesResult{ConfirmationStatus: rpc.ConfirmationStatusFinalized} + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{st}}, nil + } + // Signature not found: the per-signature entry is nil (JSON null) while the + // outer Value slice is non-nil. This is how a node reports "unknown + // transaction" for a searched-but-absent signature. + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{nil}}, nil +} + +func (n *notLandedRPC) GetLatestBlockhash(_ context.Context, _ rpc.CommitmentType) (*rpc.GetLatestBlockhashResult, error) { + return &rpc.GetLatestBlockhashResult{Value: &rpc.LatestBlockhashResult{LastValidBlockHeight: n.lastValidBlockHeight}}, nil +} + +func (n *notLandedRPC) GetBlockHeight(_ context.Context, _ rpc.CommitmentType) (uint64, error) { + if n.heightErr != nil { + return 0, n.heightErr + } + return n.currentHeight, nil +} + +func (n *notLandedRPC) IsBlockhashValid(_ context.Context, _ solana.Hash, _ rpc.CommitmentType) (*rpc.IsValidBlockhashResult, error) { + if n.heightErr != nil { + return nil, n.heightErr + } + return &rpc.IsValidBlockhashResult{Value: n.currentHeight <= n.lastValidBlockHeight}, nil +} + +// expiryRaceRPC models the precise TOCTOU in the old not-landed check. Before +// expiry is observed the signature is absent; once expiry is observed the +// transaction has landed in the final valid block and must keep its replay +// reservation. A status lookup before the height lookup would therefore +// incorrectly release the credential. +type expiryRaceRPC struct { + expired bool + calls []string + commitment rpc.CommitmentType + blockhash solana.Hash +} + +func (r *expiryRaceRPC) SendEncodedTransactionWithOpts(_ context.Context, _ string, _ rpc.TransactionOpts) (solana.Signature, error) { + return solana.Signature{}, nil +} + +func (r *expiryRaceRPC) GetSignatureStatuses(_ context.Context, _ bool, _ ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + r.calls = append(r.calls, "status") + if r.expired { + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{{ConfirmationStatus: rpc.ConfirmationStatusFinalized}}}, nil + } + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{nil}}, nil +} + +func (r *expiryRaceRPC) GetLatestBlockhash(_ context.Context, _ rpc.CommitmentType) (*rpc.GetLatestBlockhashResult, error) { + return &rpc.GetLatestBlockhashResult{Value: &rpc.LatestBlockhashResult{}}, nil +} + +func (r *expiryRaceRPC) IsBlockhashValid(_ context.Context, blockhash solana.Hash, commitment rpc.CommitmentType) (*rpc.IsValidBlockhashResult, error) { + r.calls = append(r.calls, "validity") + r.commitment = commitment + r.blockhash = blockhash + r.expired = true + return &rpc.IsValidBlockhashResult{Value: false}, nil +} + +// TestSettlementReleasesReservationWhenProvablyNotLanded is the not-landed +// release regression: +// after a successful broadcast the confirmation poll times out, then the +// definitive searchTransactionHistory status lookup reports the signature is +// absent AND the blockhash is provably expired (current height past +// lastValidBlockHeight). Because the transaction can never land, the credential +// must be released so an honest client can rebuild/resubmit. +// +// Before the fix Go pins the marker permanently on ANY confirmation timeout, so +// the retry is rejected as signature_consumed and never re-broadcasts. +func TestSettlementReleasesReservationWhenProvablyNotLanded(t *testing.T) { + op := signer.Generate() + fake := ¬LandedRPC{ + sig: solana.MustSignatureFromBase58(sampleSig), + lastValidBlockHeight: 100, + currentHeight: 101, // past lastValidBlockHeight: blockhash expired. + } + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + confirmAttempts: 2, + confirmDelay: time.Millisecond, + } + cred := exactCredential(t, op) + + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errors.As(err, &perr) || perr.Code != "settlement_failed" { + t.Fatalf("first submission: expected settlement_failed on not-landed timeout, got %v", err) + } + if fake.sends != 1 { + t.Fatalf("expected exactly one broadcast on the first submission, got %d", fake.sends) + } + + // The credential was provably never landed, so the reservation was + // released: a retry re-broadcasts instead of tripping signature_consumed. + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + if !errors.As(err, &perr) || perr.Code != "settlement_failed" { + t.Fatalf("retry after not-landed release: expected re-broadcast then settlement_failed, got %v", err) + } + if fake.sends != 2 { + t.Fatalf("released credential must re-broadcast; expected 2 total sends, got %d", fake.sends) + } +} + +// TestSettlementKeepsReservationWhenBlockhashStillValid is the not-landed +// companion: +// the definitive lookup reports the signature absent, but the blockhash has NOT +// expired (current height still within lastValidBlockHeight), so the tx may +// still land. The marker MUST be kept (fail-closed) and a retry is rejected. +func TestSettlementKeepsReservationWhenBlockhashStillValid(t *testing.T) { + op := signer.Generate() + fake := ¬LandedRPC{ + sig: solana.MustSignatureFromBase58(sampleSig), + lastValidBlockHeight: 100, + currentHeight: 100, // not past lastValidBlockHeight: still valid. + } + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + confirmAttempts: 2, + confirmDelay: time.Millisecond, + } + cred := exactCredential(t, op) + + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errors.As(err, &perr) || perr.Code != "settlement_failed" { + t.Fatalf("expected settlement_failed, got %v", err) + } + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + if !errors.As(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("blockhash still valid: retry must be rejected as signature_consumed, got %v", err) + } + if fake.sends != 1 { + t.Fatalf("marker must be kept; expected 1 total send, got %d", fake.sends) + } +} + +// TestSettlementKeepsReservationWhenStatusLookupErrors is the not-landed +// companion: +// the definitive status lookup itself fails (indeterminate). We cannot prove +// the tx did not land, so the marker MUST be kept. +func TestSettlementKeepsReservationWhenStatusLookupErrors(t *testing.T) { + op := signer.Generate() + fake := ¬LandedRPC{ + sig: solana.MustSignatureFromBase58(sampleSig), + statusErr: errors.New("rpc unavailable"), + lastValidBlockHeight: 100, + currentHeight: 101, + } + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + confirmAttempts: 2, + confirmDelay: time.Millisecond, + } + cred := exactCredential(t, op) + + if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}); err == nil { + t.Fatal("expected settlement_failed on the first submission") + } + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errors.As(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("indeterminate status: retry must be rejected as signature_consumed, got %v", err) + } +} + +// TestReplayStoreErrorReportsDistinctCode is the store-outage-code regression: a ReplayStore +// I/O failure on PutIfAbsent (e.g. a shared Redis outage) must be reported with +// a distinct code, not the client-facing signature_consumed replay rejection. +// +// Before the fix the store-error branch surfaced code "signature_consumed", +// telling honest clients their credential was already spent during an outage. +func TestReplayStoreErrorReportsDistinctCode(t *testing.T) { + op := signer.Generate() + fake := &fakeRPC{sig: solana.MustSignatureFromBase58(sampleSig), confirm: rpc.ConfirmationStatusConfirmed} + a := &Adapter{ + cfg: opConfig(op, &errStore{err: errors.New("redis down")}), + signer: op, + rpc: fake, + replay: &errStore{err: errors.New("redis down")}, + blockhashProvider: func() (string, error) { return "BH", nil }, + } + cred := exactCredential(t, op) + + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errors.As(err, &perr) { + t.Fatalf("expected a PaymentError on a store outage, got %v", err) + } + if perr.Code != "replay_store_error" { + t.Fatalf("store outage must surface replay_store_error, got %q", perr.Code) + } +} + +// TestSettlementNotLandedBranches exercises the remaining guard branches of the +// evidence-based release decision directly, pinning each fail-closed exit. +func TestSettlementNotLandedBranches(t *testing.T) { + op := signer.Generate() + sig := solana.MustSignatureFromBase58(sampleSig) + blockhash := solana.Hash{1} + + // RPC without per-blockhash validity support: + // expiry cannot be proven, keep the marker. + a := &Adapter{cfg: opConfig(op, nil), signer: op, rpc: &fakeRPC{}} + if a.settlementNotLanded(context.Background(), sig, blockhash) { + t.Fatal("an RPC without IsBlockhashValid must never release") + } + + // Signature found (landed): keep the marker even though a window exists. + a.rpc = ¬LandedRPC{found: true, lastValidBlockHeight: 100, currentHeight: 200} + if a.settlementNotLanded(context.Background(), sig, blockhash) { + t.Fatal("a landed signature must never release") + } + + // Provably absent + expired: release. + a.rpc = ¬LandedRPC{lastValidBlockHeight: 100, currentHeight: 101} + if !a.settlementNotLanded(context.Background(), sig, blockhash) { + t.Fatal("provably absent + expired blockhash must release") + } + + // Block-height lookup fails: expiry unprovable, keep the marker. + a.rpc = ¬LandedRPC{lastValidBlockHeight: 100, heightErr: errors.New("rpc down")} + if a.settlementNotLanded(context.Background(), sig, blockhash) { + t.Fatal("a failed blockhash-validity lookup must never release") + } +} + +// TestSettlementNotLandedChecksExpiryBeforeStatus pins the ordering that makes +// the two RPC observations safe to compose. Once a finalized height proves the +// blockhash is expired, the later full-history status lookup cannot race a new +// landing with that blockhash. +func TestSettlementNotLandedChecksExpiryBeforeStatus(t *testing.T) { + op := signer.Generate() + fake := &expiryRaceRPC{} + a := &Adapter{cfg: opConfig(op, nil), signer: op, rpc: fake} + sig := solana.MustSignatureFromBase58(sampleSig) + + blockhash := solana.Hash{1, 2, 3} + if a.settlementNotLanded(context.Background(), sig, blockhash) { + t.Fatal("a transaction that lands as the blockhash expires must keep its reservation") + } + if len(fake.calls) != 2 || fake.calls[0] != "validity" || fake.calls[1] != "status" { + t.Fatalf("RPC call order = %v, want [validity status]", fake.calls) + } + if fake.commitment != rpc.CommitmentFinalized { + t.Fatalf("blockhash-validity commitment = %q, want finalized", fake.commitment) + } + if fake.blockhash != blockhash { + t.Fatalf("validated blockhash = %s, want transaction blockhash %s", fake.blockhash, blockhash) + } +} + +// errStore is a ReplayStore whose PutIfAbsent always fails, modelling a +// shared-store outage. +type errStore struct{ err error } + +func (e *errStore) Get(context.Context, string) (json.RawMessage, bool, error) { + return nil, false, e.err +} +func (e *errStore) Put(context.Context, string, any) error { return e.err } +func (e *errStore) Delete(context.Context, string) error { return nil } +func (e *errStore) PutIfAbsent(context.Context, string, any) (bool, error) { + return false, e.err +} + +// TestSharedReplayStoreRejectsCrossReplica is the cross-replica-replay +// regression: two adapter +// instances built over one injected shared store behave like two replicas +// behind a load balancer. When the first replica consumes a signature the +// second replica must reject the same credential as signature_consumed. +// +// Before the fix the consumed-signature set was a per-Adapter sync.Map with no +// injection point, so replicas could not share it and each would settle the +// same credential once. +func TestSharedReplayStoreRejectsCrossReplica(t *testing.T) { + op := signer.Generate() + shared := mppcore.NewMemoryStore() + + newReplica := func() *Adapter { + built, err := New(opConfig(op, shared)) + if err != nil { + t.Fatalf("New: %v", err) + } + a, ok := built.(*Adapter) + if !ok { + t.Fatalf("New returned %T, want *Adapter", built) + } + a.rpc = &fakeRPC{sig: solana.MustSignatureFromBase58(sampleSig), confirm: rpc.ConfirmationStatusFinalized} + a.blockhashProvider = func() (string, error) { return "BH", nil } + return a + } + + replicaA := newReplica() + replicaB := newReplica() + cred := exactCredential(t, op) + + if _, err := replicaA.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}); err != nil { + t.Fatalf("replica A settle: %v", err) + } + _, err := replicaB.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("replica B: expected signature_consumed via the shared store, got %v", err) + } +} + +// TestAmbiguousSendFailureKeepsReplayMarker proves that a timeout does not +// establish whether the node accepted the transaction. The reservation must +// stay pinned so the same credential cannot be broadcast twice. +func TestAmbiguousSendFailureKeepsReplayMarker(t *testing.T) { + op := signer.Generate() + fake := &fakeRPC{sendErr: context.DeadlineExceeded} + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + } + cred := exactCredential(t, op) + + if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}); err == nil { + t.Fatal("expected send_failed on the first submission") + } + // A healthy retry is rejected because the first send may still have landed. + fake.sendErr = nil + fake.sig = solana.MustSignatureFromBase58(sampleSig) + fake.confirm = rpc.ConfirmationStatusConfirmed + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("retry after ambiguous send must be rejected, got %v", err) + } +} + +func TestDefinitivePreflightFailureReleasesReplayMarker(t *testing.T) { + op := signer.Generate() + fake := &fakeRPC{sendErr: &jsonrpc.RPCError{Code: -32002, Message: "transaction simulation failed"}} + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + } + cred := exactCredential(t, op) + + if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}); err == nil { + t.Fatal("expected send_failed on preflight rejection") + } + fake.sendErr = nil + fake.sig = solana.MustSignatureFromBase58(sampleSig) + fake.confirm = rpc.ConfirmationStatusConfirmed + if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}); err != nil { + t.Fatalf("retry after definitive preflight rejection should settle, got %v", err) + } +} + +func TestNonLocalnetRequiresSharedReplayStore(t *testing.T) { + t.Setenv("PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE", "") + op := signer.Generate() + cfg := opConfig(op, nil) + cfg.Network = paykit.SolanaDevnet + if _, err := New(cfg); err == nil { + t.Fatal("expected non-localnet configuration without a replay store to fail closed") + } + + cfg.X402.ReplayStore = mppcore.NewMemoryStore() + if _, err := New(cfg); err == nil { + t.Fatal("expected a process-local replay store to fail closed outside localnet") + } + + cfg.X402.ReplayStore = declaredReplayStore{Store: mppcore.NewMemoryStore()} + if _, err := New(cfg); err == nil { + t.Fatal("expected a store that reports non-durable replay protection to fail closed outside localnet") + } + + cfg.X402.ReplayStore = declaredReplayStore{Store: mppcore.NewMemoryStore(), durable: true} + if _, err := New(cfg); err != nil { + t.Fatalf("injected store declaring durable shared replay protection should be accepted: %v", err) + } +} + +func TestNonLocalnetInsecureMemoryOptIn(t *testing.T) { + t.Setenv("PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE", "1") + op := signer.Generate() + cfg := opConfig(op, nil) + cfg.Network = paykit.SolanaDevnet + if _, err := New(cfg); err != nil { + t.Fatalf("explicit insecure development opt-in should permit memory store: %v", err) + } +} + +func TestMainnetRejectsInsecureMemoryOptIn(t *testing.T) { + t.Setenv("PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE", "1") + op := signer.Generate() + cfg := opConfig(op, nil) + cfg.Network = paykit.SolanaMainnet + if _, err := New(cfg); err == nil { + t.Fatal("expected mainnet to reject the insecure memory-store opt-in") + } + + cfg.X402.ReplayStore = mppcore.NewMemoryStore() + if _, err := New(cfg); err == nil { + t.Fatal("expected mainnet to reject an injected process-local store under the insecure opt-in") + } +} + +// declaredReplayStore is a test double for an externally backed store. +// The embedded MemoryStore supplies behavior; the capability declaration is +// what New requires from a production implementation outside localnet. +type declaredReplayStore struct { + mppcore.Store + durable bool +} + +func (s declaredReplayStore) ProvidesDurableSharedReplayProtection() bool { return s.durable } diff --git a/go/paykit/adapters/x402/verify_test.go b/go/paykit/adapters/x402/verify_test.go index b242d89b3..39bf7b275 100644 --- a/go/paykit/adapters/x402/verify_test.go +++ b/go/paykit/adapters/x402/verify_test.go @@ -49,11 +49,11 @@ func newFixture(t *testing.T) fixture { } keys := solana.PublicKeySlice{ - feePayer, // 0 - source, // 1 - mint, // 2 - dest, // 3 - authority, // 4 + feePayer, // 0: fee payer signer + authority, // 1: transfer authority signer + source, // 2 + mint, // 3 + dest, // 4 computeBudget, // 5 tokenProgram, // 6 } @@ -74,7 +74,7 @@ func newFixture(t *testing.T) fixture { computePrice: solana.CompiledInstruction{ProgramIDIndex: 5, Data: priceData}, transfer: solana.CompiledInstruction{ ProgramIDIndex: 6, - Accounts: []uint16{1, 2, 3, 4}, + Accounts: []uint16{2, 3, 4, 1}, Data: transferData, }, req: proto.TransferRequirements{ @@ -92,8 +92,12 @@ func newFixture(t *testing.T) fixture { func (f fixture) tx(extra ...solana.CompiledInstruction) *solana.Transaction { ixs := append([]solana.CompiledInstruction{f.computeLimit, f.computePrice, f.transfer}, extra...) return &solana.Transaction{ - Message: solana.Message{AccountKeys: f.keys, Instructions: ixs}, - Signatures: []solana.Signature{{}}, + Message: solana.Message{ + AccountKeys: f.keys, + Header: solana.MessageHeader{NumRequiredSignatures: 2}, + Instructions: ixs, + }, + Signatures: []solana.Signature{{}, {}}, } } @@ -180,8 +184,12 @@ func TestVerifyEnforcesExpectedMemoMatch(t *testing.T) { func TestVerifyRejectsTooFewInstructions(t *testing.T) { f := newFixture(t) tx := &solana.Transaction{ - Message: solana.Message{AccountKeys: f.keys, Instructions: []solana.CompiledInstruction{f.computeLimit, f.computePrice}}, - Signatures: []solana.Signature{{}}, + Message: solana.Message{ + AccountKeys: f.keys, + Header: solana.MessageHeader{NumRequiredSignatures: 2}, + Instructions: []solana.CompiledInstruction{f.computeLimit, f.computePrice}, + }, + Signatures: []solana.Signature{{}, {}}, } if err := proto.VerifyExactTransaction(tx, f.req); err == nil { t.Error("expected rejection for <3 instructions") @@ -225,7 +233,7 @@ func TestVerifyRejectsWrongAmount(t *testing.T) { func TestVerifyRejectsWrongMint(t *testing.T) { f := newFixture(t) - f.keys[2] = solana.MustPublicKeyFromBase58(paycore.USDTMainnetMint) + f.keys[3] = solana.MustPublicKeyFromBase58(paycore.USDTMainnetMint) if err := proto.VerifyExactTransaction(f.tx(), f.req); err == nil { t.Error("expected rejection for mint mismatch") } @@ -233,7 +241,7 @@ func TestVerifyRejectsWrongMint(t *testing.T) { func TestVerifyRejectsWrongDestination(t *testing.T) { f := newFixture(t) - f.keys[3] = solana.NewWallet().PublicKey() // not the payTo ATA + f.keys[4] = solana.NewWallet().PublicKey() // not the payTo ATA if err := proto.VerifyExactTransaction(f.tx(), f.req); err == nil { t.Error("expected rejection for recipient ATA mismatch") } @@ -241,7 +249,7 @@ func TestVerifyRejectsWrongDestination(t *testing.T) { func TestVerifyRejectsFeePayerAsAuthority(t *testing.T) { f := newFixture(t) - f.keys[4] = f.req.ManagedSigners[0] // fee-payer moving the funds + f.keys[1] = f.req.ManagedSigners[0] // fee-payer moving the funds if err := proto.VerifyExactTransaction(f.tx(), f.req); err == nil { t.Error("expected rejection when fee-payer is the transfer authority") } @@ -342,7 +350,7 @@ func settleFixture(t *testing.T, fake *fakeRPC) (*Adapter, *paykit.Gate, string) } computeBudget := solana.MustPublicKeyFromBase58(proto.ComputeBudgetProgram) - keys := solana.PublicKeySlice{opPub, source, mint, dest, authority, computeBudget, tokenProgram} + keys := solana.PublicKeySlice{opPub, authority, source, mint, dest, computeBudget, tokenProgram} const amount = uint64(1000) priceData := make([]byte, 9) priceData[0] = 3 @@ -354,10 +362,11 @@ func settleFixture(t *testing.T, fake *fakeRPC) (*Adapter, *paykit.Gate, string) tx := &solana.Transaction{ Message: solana.Message{ AccountKeys: keys, + Header: solana.MessageHeader{NumRequiredSignatures: 2}, Instructions: []solana.CompiledInstruction{ {ProgramIDIndex: 5, Data: []byte{2, 0, 0, 0, 0}}, {ProgramIDIndex: 5, Data: priceData}, - {ProgramIDIndex: 6, Accounts: []uint16{1, 2, 3, 4}, Data: transferData}, + {ProgramIDIndex: 6, Accounts: []uint16{2, 3, 4, 1}, Data: transferData}, }, }, Signatures: []solana.Signature{{}, solana.MustSignatureFromBase58(sampleClientSig)}, @@ -451,19 +460,21 @@ func TestVerifyAndSettleConfirmationError(t *testing.T) { } } -func TestVerifyAndSettleSendFailureRollsBackReplay(t *testing.T) { +func TestVerifyAndSettleAmbiguousSendFailureKeepsReplay(t *testing.T) { fake := &fakeRPC{sendErr: context.DeadlineExceeded} a, gate, sig := settleFixture(t, fake) if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: gate, PaymentSig: sig}); err == nil { t.Fatal("expected send_failed") } - // Replay reservation must have been rolled back: a retry with a - // working RPC then succeeds rather than tripping signature_consumed. + // The node may have accepted the transaction before the timeout, so the + // reservation stays pinned and a retry cannot broadcast it again. fake.sendErr = nil fake.sig = solana.MustSignatureFromBase58(sampleSig) fake.confirm = rpc.ConfirmationStatusConfirmed - if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: gate, PaymentSig: sig}); err != nil { - t.Fatalf("retry after rollback should succeed, got %v", err) + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: gate, PaymentSig: sig}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("retry after ambiguous send must be rejected, got %v", err) } } @@ -671,6 +682,7 @@ func TestCosignPassthroughWhenOperatorAbsent(t *testing.T) { if err != nil { t.Fatal(err) } + tx.Signatures = make([]solana.Signature, len(tx.Message.Signers())) raw, err := tx.MarshalBinary() if err != nil { t.Fatal(err) @@ -701,6 +713,7 @@ func TestCosignDoesNotSignOperatorInLaterSignerSlot(t *testing.T) { if len(tx.Message.AccountKeys) < 2 || !tx.Message.AccountKeys[1].Equals(operator) { t.Fatalf("expected operator in later signer slot, got %v", tx.Message.AccountKeys) } + tx.Signatures = make([]solana.Signature, len(tx.Message.Signers())) raw, err := tx.MarshalBinary() if err != nil { t.Fatal(err) diff --git a/go/paykit/client.go b/go/paykit/client.go index 09cbeafa4..25a2741f2 100644 --- a/go/paykit/client.go +++ b/go/paykit/client.go @@ -189,6 +189,11 @@ func New(cfg Config) (*Client, error) { c := &Client{Config: cfg} for _, s := range cfg.Accept { + // The registered x402 adapter implements exact settlement and its + // replay-store policy. Upto-only deployments use the usage adapter below. + if s == X402 && cfg.X402.Scheme == "upto" { + continue + } b, ok := registeredBuilders[s] if !ok { continue diff --git a/go/paykit/client_test.go b/go/paykit/client_test.go index 245bf052e..18bf38f00 100644 --- a/go/paykit/client_test.go +++ b/go/paykit/client_test.go @@ -61,19 +61,61 @@ func TestX402ExactAllowsRecipientDifferentFromSigner(t *testing.T) { } } -func TestX402UptoWiresUsageAdapter(t *testing.T) { +func TestX402UptoOnNonLocalnetDoesNotRequireExactReplayStore(t *testing.T) { c, err := paykit.New(paykit.Config{ - Network: paykit.SolanaLocalnet, + Network: paykit.SolanaDevnet, Accept: []paykit.Protocol{paykit.X402}, Preflight: disabled(), X402: paykit.X402Config{Scheme: "upto"}, }) if err != nil { - t.Fatalf("x402 upto should wire usage adapter: %v", err) + t.Fatalf("non-localnet x402 upto should not require an exact replay store: %v", err) } if c.UsageAdapter() == nil { t.Fatal("expected usage adapter for x402 upto config") } + if c.X402Adapter() != nil { + t.Fatal("upto-only x402 config must not wire the exact adapter") + } +} + +func TestX402ExactOnNonLocalnetRequiresReplayStore(t *testing.T) { + _, err := paykit.New(paykit.Config{ + Network: paykit.SolanaDevnet, + Accept: []paykit.Protocol{paykit.X402}, + Preflight: disabled(), + X402: paykit.X402Config{Scheme: "exact"}, + }) + if err == nil { + t.Fatal("expected non-localnet exact x402 config without a replay store to fail closed") + } + if !strings.Contains(err.Error(), "ReplayStore") { + t.Fatalf("expected replay store error, got %v", err) + } +} + +func TestNewMixedMPPAndX402UptoWiresSelectedAdapters(t *testing.T) { + c, err := paykit.New(paykit.Config{ + Network: paykit.SolanaDevnet, + Accept: []paykit.Protocol{paykit.MPP, paykit.X402}, + Preflight: disabled(), + MPP: paykit.MPPConfig{ + ChallengeBindingSecret: []byte("test-secret"), + }, + X402: paykit.X402Config{Scheme: "upto"}, + }) + if err != nil { + t.Fatalf("mixed MPP and x402 upto config should wire selected adapters: %v", err) + } + if c.MppAdapter() == nil { + t.Fatal("expected MPP adapter for mixed config") + } + if c.UsageAdapter() == nil { + t.Fatal("expected x402 usage adapter for mixed config") + } + if c.X402Adapter() != nil { + t.Fatal("mixed config with x402 upto must not wire the exact adapter") + } } func TestSetErrorHandlerOverridesResponse(t *testing.T) { diff --git a/go/paykit/middleware.go b/go/paykit/middleware.go index c1a3ef93b..7595bdedf 100644 --- a/go/paykit/middleware.go +++ b/go/paykit/middleware.go @@ -145,7 +145,9 @@ func (c *Client) write402(w http.ResponseWriter, r *http.Request, gate *Gate, pe headers := map[string]string{} var challengeErrors []error if c.x402Adapter != nil && containsProtocol(accept, X402) && !gate.HasFees() { - if err := appendChallenge(gate, c.x402Adapter, &accepts, headers); err != nil { + x402Gate := *gate + x402Gate.Desc = r.URL.Path + if err := appendChallenge(&x402Gate, c.x402Adapter, &accepts, headers); err != nil { challengeErrors = append(challengeErrors, err) } } diff --git a/go/paykit/types.go b/go/paykit/types.go index d08f32041..267cd67c9 100644 --- a/go/paykit/types.go +++ b/go/paykit/types.go @@ -206,6 +206,28 @@ type X402Config struct { // ChannelProgram overrides the payment-channels program id advertised // by x402 upto. Leave empty for the canonical mainnet deployment. ChannelProgram string + // ReplayStore backs the x402 exact adapter's consumed-signature guard, + // which rejects a second submission of an already-settled (or in-flight) + // credential. Non-localnet exact settlement requires an injected store that + // implements [ReplayStoreCapability] and reports durable, shared replay + // protection. Localnet may use the process-local memory default; devnet can + // explicitly acknowledge that insecure scope with + // PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1. Multi-replica deployments MUST + // inject a shared store so a signature consumed on one replica is rejected + // on every other. + ReplayStore core.Store +} + +// ReplayStoreCapability is the durability declaration required of an x402 +// replay store outside localnet. The store must already implement +// [core.Store], including atomic PutIfAbsent; this capability separately +// declares that its reservations are durable and visible to every serving +// replica. A process-local store must not implement this as true. +// +// The interface is intentionally opt-in: an arbitrary Store cannot be +// assumed to survive process restarts or coordinate across replicas. +type ReplayStoreCapability interface { + ProvidesDurableSharedReplayProtection() bool } // MPPConfig groups the MPP-charge-specific knobs. diff --git a/harness/php-server/HarnessReplayStore.php b/harness/php-server/HarnessReplayStore.php new file mode 100644 index 000000000..88450cdd8 --- /dev/null +++ b/harness/php-server/HarnessReplayStore.php @@ -0,0 +1,31 @@ +store = new FileStore($directory); + } + + public function putIfAbsent(string $key, mixed $value): bool + { + return $this->store->putIfAbsent($key, $value); + } + + public function providesDurableSharedReplayProtection(): bool + { + // The harness injects one run-scoped directory shared across workers. + return true; + } +} diff --git a/harness/php-server/server.php b/harness/php-server/server.php index 8972db8e2..bd0920445 100644 --- a/harness/php-server/server.php +++ b/harness/php-server/server.php @@ -23,8 +23,10 @@ ini_set('display_errors', 'stderr'); require __DIR__ . '/../../php/vendor/autoload.php'; +require_once __DIR__ . '/HarnessReplayStore.php'; use Nyholm\Psr7\Factory\Psr17Factory; +use PayKitHarness\PhpServer\HarnessReplayStore; use PayKit\PayKit; use PayKit\Config; use PayKit\PayCore\Currency; @@ -140,13 +142,17 @@ function secret_key_from_json(string $raw): string mpp: new MppConfig(challengeBindingSecret: 'unused-x402'), preflight: false, )); - // The X402 adapter fails closed off localnet unless it is handed a shared - // replay store (the default in-memory store is process-local). The harness - // runs the x402 matrix on a non-localnet CAIP-2 network, so inject a - // process-scoped FileStore here, mirroring the MPP charge path below. + $replayStoreDirectory = getenv('PAY_KIT_HARNESS_REPLAY_STORE_DIR'); + if (!is_string($replayStoreDirectory) || trim($replayStoreDirectory) === '') { + throw new RuntimeException('PAY_KIT_HARNESS_REPLAY_STORE_DIR is required for the PHP x402 harness'); + } + // Every PHP worker in this harness run receives the same temporary root. + // The Node harness owns its lifecycle and removes it when the run exits. $adapter = new X402Adapter( $client->config, - replayStore: new FileStore(sys_get_temp_dir() . '/x402-php-harness-replay-' . getmypid()), + replayStore: new HarnessReplayStore( + rtrim($replayStoreDirectory, DIRECTORY_SEPARATOR) . '/php-x402', + ), ); $gate = new Gate(amount: Price::usd(format_decimal_amount($amountUnits))); } else { diff --git a/harness/src/process.ts b/harness/src/process.ts index a30fcbca1..5dda99bde 100644 --- a/harness/src/process.ts +++ b/harness/src/process.ts @@ -1,4 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createInterface } from "node:readline"; import { setTimeout as delay } from "node:timers/promises"; import { @@ -26,6 +29,31 @@ type AdapterEnvironment = Record; const ADAPTER_OUTPUT_TIMEOUT_MS = 120_000; const STDERR_RING_BUFFER_BYTES = 1024; +const REPLAY_STORE_DIRECTORY = mkdtempSync( + join(tmpdir(), "pay-kit-harness-replay-"), +); + +let replayStoreDirectoryRemoved = false; + +function removeReplayStoreDirectory(): void { + if (replayStoreDirectoryRemoved) return; + replayStoreDirectoryRemoved = true; + rmSync(REPLAY_STORE_DIRECTORY, { force: true, recursive: true }); +} + +process.once("exit", removeReplayStoreDirectory); +process.once("SIGINT", () => { + removeReplayStoreDirectory(); + process.exit(130); +}); +process.once("SIGHUP", () => { + removeReplayStoreDirectory(); + process.exit(129); +}); +process.once("SIGTERM", () => { + removeReplayStoreDirectory(); + process.exit(143); +}); const stderrCaptures = new WeakMap(); @@ -160,6 +188,9 @@ function spawnAdapter( delete env[name]; } } + // Reserved harness capability: callers cannot split workers onto + // process-local replay roots by overriding this value per child. + env.PAY_KIT_HARNESS_REPLAY_STORE_DIR = REPLAY_STORE_DIRECTORY; const child = spawn(command, args, { cwd: process.cwd(), env, diff --git a/harness/test/boot-policy.test.ts b/harness/test/boot-policy.test.ts index 8d35ad448..9f5a37524 100644 --- a/harness/test/boot-policy.test.ts +++ b/harness/test/boot-policy.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,7 +8,7 @@ import type { ImplementationDefinition } from "../src/implementations"; import { startServer, stopServer } from "../src/process"; // --------------------------------------------------------------------------- -// Cross-SDK "deployment policy" conformance: fail-CLOSED store construction. +// Cross-SDK deployment-policy conformance: fail-CLOSED store construction. // // The rest of the harness compares verify DECISIONS on fixed transactions. It // never exercises store-construction / boot-time safety policy, so nothing @@ -18,15 +18,14 @@ import { startServer, stopServer } from "../src/process"; // configured and PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE is unset — a // process-local in-memory store silently loses double-spend protection on // a multi-replica deploy (fail-OPEN). -// * TS and Python (the high-level server adapters the harness fixtures boot) -// fail OPEN today: they construct a process-local in-memory store off -// localnet and boot to `ready` anyway. +// * TypeScript and Python high-level server adapters now reject an omitted or +// process-local in-memory store off-localnet unless an explicit development +// opt-in is supplied. // -// SECURITY.md claims PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE is honored across the -// Go, TypeScript, and Python SDKs, but no test proved it. This file is that -// proof. It drives each SDK's real server/high-level-adapter *constructor* -// (reusing the harness `startServer` spawn machinery and the existing -// `*-server` fixtures) and asserts: +// Go, TypeScript, and Python share PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE. This +// file drives each SDK's real server/high-level-adapter *constructor* (reusing +// the harness `startServer` spawn machinery and the existing `*-server` +// fixtures) and asserts: // // 1. Constructed off-localnet (network=mainnet) with NO shared store and // WITHOUT PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 => it MUST fail CLOSED @@ -38,6 +37,13 @@ import { startServer, stopServer } from "../src/process"; // These are blocking regression probes: Go, TypeScript, and Python must reject // unsafe off-localnet construction, while the explicit devnet escape remains // usable. Any SDK that silently falls back to process-local state goes RED. +// PHP, Ruby, and Lua do NOT use PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE, but they +// DO enforce the same off-localnet fail-closed policy through their native +// config contract (reject an omitted or non-durable/non-shared replay store off +// localnet). The source-contract probes near the end of this file pin those +// real guards rather than pretending they implement this env-var API. Rust, +// Kotlin, and Swift ship no such store guard in-tree yet, so they are +// asserted-SKIPPED with an honest reason. // // FALSE-GREEN GUARD: the fail-closed assertion does not merely check "the boot // failed" — a missing toolchain, unbuilt binary, or bad RPC would fail boot for @@ -334,21 +340,6 @@ const unimplementedProbes: Array<{ id: string; reason: string }> = [ reason: "Rust MPP server exposes no PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE boot-time fail-closed guard", }, - { - id: "php", - reason: - "PHP SolanaChargeHandler has a MemoryStore but no off-localnet fail-closed boot guard", - }, - { - id: "ruby", - reason: - "Ruby MPP runtime has a MemoryStore but no off-localnet fail-closed boot guard", - }, - { - id: "lua", - reason: - "Lua resty.pay_kit exposes no in-memory-store fail-closed boot guard", - }, { id: "kotlin", reason: "Kotlin adapter exposes no in-memory-store fail-closed boot guard", @@ -362,11 +353,11 @@ const unimplementedProbes: Array<{ id: string; reason: string }> = [ // Loud note: surface the coverage gap in the run log, not just as silent skips. // eslint-disable-next-line no-console console.warn( - "[boot-policy] fail-closed store contract is only implemented/remediated for " + - "go, typescript, python. Asserting-SKIP the rest (no PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE " + - "boot guard in their SDK source): " + - unimplementedProbes.map((p) => p.id).join(", ") + - ". Extending the contract cross-SDK is an open audit gap.", + "[boot-policy] shared PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE opt-in contract: " + + "go, typescript, python. php, ruby, lua enforce the same off-localnet " + + "fail-closed policy via their native config contract (pinned by the source " + + "probes below). Asserting-SKIP (no store guard in-tree): " + + unimplementedProbes.map((p) => p.id).join(", ") + ".", ); // Boot the SDK at network=mainnet with NO opt-in and assert it fails CLOSED @@ -437,6 +428,7 @@ describe("boot-policy conformance: boots with the opt-in", () => { } }); + describe("boot-policy conformance: SDKs without the store fail-closed contract", () => { for (const probe of unimplementedProbes) { // eslint-disable-next-line no-console @@ -458,9 +450,6 @@ describe("boot-policy conformance: SDKs without the store fail-closed contract", // excluded automatically. const SDK_SOURCE_DIR: Record = { rust: "rust", - php: "php", - ruby: "ruby", - lua: "lua", kotlin: "kotlin", swift: "swift", }; @@ -484,3 +473,163 @@ describe("boot-policy: asserted-skip roster stays honest (no half-implemented co }); } }); + +// --------------------------------------------------------------------------- +// Native (non-env-var) off-localnet fail-CLOSED store guards: php, ruby, lua. +// +// These SDKs do NOT use the shared PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE opt-in, +// so the boot probes above skip them. They deliver the same money-path safety +// through their native config contract: off-localnet, an omitted or +// non-durable/non-shared replay store is REJECTED at adapter/server +// construction (fail-closed). That is a real, working guard, so it is COVERED +// here rather than mislabeled as absent. +// +// Detection mirrors the #238 sdkImplementsGuard mechanism (git-grep the SDK's +// tracked source for its guard marker): a probe is REQUIRED only when its guard +// is present in-tree, and asserts-SKIP with an honest reason otherwise (e.g. the +// SDK is not checked out in this split). `sdkFilesReferencingOptIn` above stays +// byte-identical to #238 for the env-var roster; this parallel helper carries +// the per-SDK native marker. When present, the probe pins BOTH sides of the +// contract: the missing-store rejection and the non-durable/non-shared one. +// --------------------------------------------------------------------------- +type NativeGuardAssertion = { file: string; mechanism: string; pattern: RegExp }; +type NativeGuardProbe = { + id: string; + label: string; + sdkDir: string; + // Stable substring of the guard's rejection message; its presence in-tree is + // what promotes the probe from asserted-skip to required. + guardMarker: string; + assertions: NativeGuardAssertion[]; +}; + +// git grep -l -- , same failure handling as +// sdkFilesReferencingOptIn (kept separate so that helper stays byte-identical to +// #238). Empty result => guard/source not in this checkout => honest skip. +function sdkFilesMatchingMarker(sdkDir: string, marker: string): string[] { + try { + const out = execFileSync("git", ["grep", "-l", marker, "--", sdkDir], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + return out.split("\n").filter(Boolean); + } catch (error) { + const status = (error as { status?: number }).status; + const stdout = (error as { stdout?: string }).stdout ?? ""; + // git grep exits 1 with empty output when there are no matches -> honest skip. + if (status === 1 && stdout.trim() === "") return []; + throw error; + } +} + +const nativeGuardProbes: NativeGuardProbe[] = [ + { + id: "php", + label: "PHP MPP adapter/handler off-localnet replay-store guard", + sdkDir: "php", + guardMarker: "replayStore is required outside localnet", + assertions: [ + { + file: "php/src/Protocols/Mpp/Adapter.php", + mechanism: "rejects an omitted replay store outside localnet", + pattern: + /\$replayStore === null[\s\S]*?network !== Network::SolanaLocalnet[\s\S]*?replayStore is required outside localnet/, + }, + { + file: "php/src/Protocols/Mpp/Adapter.php", + mechanism: + "rejects a non-durable/non-shared replay store outside localnet", + pattern: + /network !== Network::SolanaLocalnet[\s\S]*?providesDurableSharedReplayProtection\(\)[\s\S]*?must explicitly declare durable shared replay protection outside localnet/, + }, + { + file: "php/src/Protocols/Mpp/Server/SolanaChargeHandler.php", + mechanism: + "enforces the same off-localnet guard on direct handler construction", + pattern: + /network !== 'localnet'[\s\S]*?replayStore is required outside localnet/, + }, + ], + }, + { + id: "ruby", + label: "Ruby MPP runtime off-localnet replay-store guard", + sdkDir: "ruby", + // Keyed on the SHARED-store predicate the ruby capability leaf introduces + // (the pre-leaf runtime only pins durability, and this probe's non-durable/ + // non-shared assertion needs both sides): the probe stays honestly skipped + // until that leaf is in the tree, then activates with its full subject. + guardMarker: "durable_shared_replay_store?", + assertions: [ + { + file: "ruby/lib/pay_kit/protocols/mpp/runtime.rb", + mechanism: "rejects the implicit dev-only memory store outside localnet", + pattern: + /replay_store == DEV_ONLY_MEMORY_STORE[\s\S]*?unless localnet\?\(method\)[\s\S]*?requires a durable replay_store/, + }, + { + file: "ruby/lib/pay_kit/protocols/mpp/runtime.rb", + mechanism: "rejects a supplied non-durable replay store outside localnet", + pattern: + /unless localnet\?\(method\) \|\| durable_shared_replay_store\?\(replay_store\)[\s\S]*?requires a durable replay_store/, + }, + ], + }, + { + id: "lua", + label: "Lua MPP server off-localnet replay-store guard", + sdkDir: "lua", + guardMarker: "replay store is required outside localnet", + assertions: [ + { + file: "lua/pay_kit/protocols/mpp/init.lua", + mechanism: "rejects an omitted replay store outside localnet", + pattern: + /if not replay_store[\s\S]*?network ~= 'localnet'[\s\S]*?replay store is required outside localnet/, + }, + { + file: "lua/pay_kit/protocols/mpp/init.lua", + mechanism: "rejects a non-shared replay store outside localnet", + pattern: + /network ~= 'localnet' and not replay_store_is_shared\(replay_store\)[\s\S]*?must declare shared=true outside localnet/, + }, + { + file: "lua/pay_kit/protocols/mpp/server/init.lua", + mechanism: + "enforces the same off-localnet guard on the low-level server entry", + pattern: + /network ~= 'localnet'[\s\S]*?replay store must be shared outside localnet/, + }, + ], + }, +]; + +function readSdkSource(file: string): string { + return readFileSync(join(REPO_ROOT, file), "utf8"); +} + +describe("boot-policy: native off-localnet fail-closed store guards (php, ruby, lua)", () => { + for (const probe of nativeGuardProbes) { + const guardFiles = sdkFilesMatchingMarker(probe.sdkDir, probe.guardMarker); + if (guardFiles.length === 0) { + // eslint-disable-next-line no-console + console.warn( + `[boot-policy] SKIP ${probe.id} native guard probe: no "${probe.guardMarker}" ` + + `marker under ${probe.sdkDir}/ in this checkout (SDK source absent or guard moved).`, + ); + } + for (const assertion of probe.assertions) { + it.skipIf(guardFiles.length === 0)( + `${probe.id}: ${assertion.mechanism}`, + () => { + expect( + readSdkSource(assertion.file), + `${probe.label} regressed: expected ${assertion.file} to ${assertion.mechanism}. ` + + `${probe.id} enforces its off-localnet fail-closed guard through this native ` + + `contract, not ${OPT_IN_ENV}; fix the SDK, do not delete the probe.`, + ).toMatch(assertion.pattern); + }, + ); + } + } +}); diff --git a/harness/test/php-x402-startup.test.ts b/harness/test/php-x402-startup.test.ts new file mode 100644 index 000000000..1f5385415 --- /dev/null +++ b/harness/test/php-x402-startup.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { ImplementationDefinition } from "../src/implementations"; +import { startServer, stopServer } from "../src/process"; + +const KEYPAIR_BYTES = [ + 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102, 48, 49, 50, + 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102, 35, 188, 84, 145, 44, + 30, 110, 146, 196, 168, 104, 37, 200, 103, 226, 127, 253, 197, 85, 191, 251, + 212, 36, 79, 23, 162, 106, 191, 255, 238, 150, 93, +]; +const KEYPAIR_PUBKEY = "3QVq8D876hmq5C5L6J3CKpWXPhvbHASz8qFsddSXFDP2"; + +const phpX402Server: ImplementationDefinition = { + id: "php-x402-startup", + label: "PHP x402 harness startup", + role: "server", + command: ["php", "php-server/server.php"], + enabled: true, + reportsAs: "php", +}; + +let running: Awaited> | undefined; + +afterEach(async () => { + if (running) { + await stopServer(running); + running = undefined; + } +}); + +describe("PHP x402 harness fixture", () => { + it("boots on the non-localnet x402 network with its explicit replay capability", async () => { + running = await startServer(phpX402Server, { + PAY_KIT_HARNESS_PROTOCOL: "x402", + X402_HARNESS_RPC_URL: "http://127.0.0.1:1", + X402_HARNESS_NETWORK: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + X402_HARNESS_MINT: "USDC", + X402_HARNESS_AMOUNT: "1000", + X402_HARNESS_PAY_TO: KEYPAIR_PUBKEY, + X402_HARNESS_FACILITATOR_SECRET_KEY: JSON.stringify(KEYPAIR_BYTES), + // startServer owns this reserved path and must ignore per-child attempts + // to replace it with an unshared or unmanaged directory. + PAY_KIT_HARNESS_REPLAY_STORE_DIR: "", + }); + + expect(running.ready).toMatchObject({ + type: "ready", + role: "server", + implementation: "php", + }); + expect(running.ready.port).toBeGreaterThan(0); + }); +}); diff --git a/harness/test/process.test.ts b/harness/test/process.test.ts index c4a903a99..47ae483e7 100644 --- a/harness/test/process.test.ts +++ b/harness/test/process.test.ts @@ -1,3 +1,8 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { startServer, runClient, stopServer } from "../src/process"; import type { ImplementationDefinition } from "../src/implementations"; @@ -82,4 +87,51 @@ describe("process.startServer error enrichment", () => { } } }); + + it.skipIf(process.platform === "win32")( + "removes the run-scoped replay directory on SIGHUP", + async () => { + const temporaryRoot = mkdtempSync( + join(tmpdir(), "pay-kit-harness-sighup-"), + ); + const child = spawn( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + "await import('./src/process.ts'); console.log('ready'); setInterval(() => {}, 1000);", + ], + { + cwd: process.cwd(), + env: { ...process.env, TMPDIR: temporaryRoot }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const replayDirectories = () => + readdirSync(temporaryRoot).filter((entry) => + entry.startsWith("pay-kit-harness-replay-"), + ); + + try { + await Promise.race([ + once(child.stdout!, "data"), + once(child, "exit").then(([code]) => { + throw new Error(`cleanup probe exited before ready with code ${code}`); + }), + ]); + expect(replayDirectories()).toHaveLength(1); + child.kill("SIGHUP"); + const [code, signal] = await once(child, "exit"); + expect({ code, signal }).toEqual({ code: 129, signal: null }); + expect(replayDirectories()).toEqual([]); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + rmSync(temporaryRoot, { force: true, recursive: true }); + } + }, + ); }); diff --git a/php/src/Frameworks/Laravel/PayKitServiceProvider.php b/php/src/Frameworks/Laravel/PayKitServiceProvider.php index 764d4ca81..d73fa379f 100644 --- a/php/src/Frameworks/Laravel/PayKitServiceProvider.php +++ b/php/src/Frameworks/Laravel/PayKitServiceProvider.php @@ -16,10 +16,13 @@ use PayKit\Protocol; use PayKit\Protocols\Mpp\MppConfig; use PayKit\Protocols\Mpp\Adapter as MppAdapter; +use PayKit\Protocols\X402\Adapter as X402Adapter; use PayKit\Store\Store; use PayKit\Protocols\X402\X402Config; use PayKit\Signer; use PayKit\PayCore\Stablecoin; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; /** * Laravel service provider. Registers: @@ -53,6 +56,40 @@ public function register(): void } return new MppAdapter($client->config, $store); }); + + /** @var array $cfg */ + $cfg = $this->app['config']->get('paykit', []); + $configuredAccept = $cfg['accept'] ?? ['x402', 'mpp']; + $acceptsX402 = is_array($configuredAccept) + && in_array(Protocol::X402->value, $configuredAccept, true); + + if ($acceptsX402) { + $this->app->singleton(X402Adapter::class, function (Application $app): X402Adapter { + /** @var mixed $replayStoreId */ + $replayStoreId = $app['config']->get('paykit.x402_replay_store'); + $replayStore = self::resolveReplayStore($app, $replayStoreId); + + return new X402Adapter( + $app->make(PayKit::class)->config, + replayStore: $replayStore, + ); + }); + } + + $this->app->bind(RequirePaymentMiddleware::class, function (Application $app): RequirePaymentMiddleware { + $client = $app->make(PayKit::class); + $x402 = in_array(Protocol::X402, $client->config->accept, true) + ? $app->make(X402Adapter::class) + : null; + + return new RequirePaymentMiddleware( + $client, + $app, + $app->make(PsrHttpFactory::class), + $app->make(HttpFoundationFactory::class), + $x402, + ); + }); } public function boot(Router $router): void @@ -173,4 +210,20 @@ private static function signerFromValue(string $raw): ?\PayKit\Signer\LocalSigne } return Signer::base58($trimmed); } + + private static function resolveReplayStore(Application $app, mixed $configured): ?Store + { + if ($configured === null || $configured === '') { + return null; + } + + $store = is_string($configured) ? $app->make($configured) : $configured; + if (!$store instanceof Store) { + throw new \LogicException( + 'pay_kit: paykit.x402_replay_store must resolve to a PayKit\\Store\\Store service', + ); + } + + return $store; + } } diff --git a/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php b/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php index 90ad6ed51..5567dbe09 100644 --- a/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php +++ b/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php @@ -14,6 +14,7 @@ use PayKit\Payment; use PayKit\Pricing; use PayKit\Protocols\Mpp\Adapter as MppAdapter; +use PayKit\Protocols\X402\Adapter as X402Adapter; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -42,6 +43,7 @@ public function __construct( private readonly Container $container, private readonly PsrHttpFactory $psrFactory, private readonly HttpFoundationFactory $httpFactory, + private readonly ?X402Adapter $x402 = null, ) { } @@ -87,6 +89,7 @@ public function handle(\Psr\Http\Message\ServerRequestInterface $request): \Psr\ gateRef: $gateRef, pricing: $pricing, mppFactory: fn (): MppAdapter => $this->container->make(MppAdapter::class), + x402: $this->x402, ); $psrResponse = $mw->process($psrRequest, $handler); diff --git a/php/src/Frameworks/Laravel/config/paykit.php b/php/src/Frameworks/Laravel/config/paykit.php index a640053b9..f4c2942df 100644 --- a/php/src/Frameworks/Laravel/config/paykit.php +++ b/php/src/Frameworks/Laravel/config/paykit.php @@ -13,6 +13,10 @@ 'fee_payer' => true, ], 'x402_facilitator_url' => env('PAY_KIT_X402_FACILITATOR_URL'), + // Service id or class name for a Store with durable, shared, atomic + // replay protection. Required when x402 is accepted outside + // solana_localnet; ignored by MPP-only deployments. + 'x402_replay_store' => env('PAY_KIT_X402_REPLAY_STORE'), 'mpp_challenge_binding_secret' => env('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET'), 'mpp' => [ // Leave unset to derive a per-recipient realm (audit #15). Set diff --git a/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php b/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php index fa6ec7250..96ff40827 100644 --- a/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php +++ b/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php @@ -11,6 +11,7 @@ use PayKit\Protocol; use PayKit\Protocols\Mpp\MppConfig; use PayKit\Protocols\Mpp\Adapter as MppAdapter; +use PayKit\Protocols\X402\Adapter as X402Adapter; use PayKit\Protocols\X402\X402Config; use PayKit\Signer; use PayKit\PayCore\Stablecoin; @@ -41,7 +42,7 @@ public function load(array $configs, ContainerBuilder $container): void $mppFactory = null; if (in_array(Protocol::Mpp, $payKitConfig->accept, true)) { $storeService = $config['mpp_replay_store_service'] ?? null; - $definition = $container->register(MppAdapter::class) + $definition = $container->register(MppAdapter::class, MppAdapter::class) ->setArgument('$config', $payKitConfig) ->setPublic(true); if (is_string($storeService) && $storeService !== '') { @@ -50,13 +51,24 @@ public function load(array $configs, ContainerBuilder $container): void $mppFactory = new ServiceClosureArgument(new Reference(MppAdapter::class)); } - $listener = $container->register(RequirePaymentListener::class) + $x402 = null; + if (in_array(Protocol::X402, $payKitConfig->accept, true)) { + $adapter = $container->register(X402Adapter::class, X402Adapter::class) + ->setArgument('$config', $payKitConfig); + if ($config['x402_replay_store'] !== null) { + $adapter->setArgument('$replayStore', new Reference($config['x402_replay_store'])); + } + $x402 = new Reference(X402Adapter::class); + } + + $listener = $container->register(RequirePaymentListener::class, RequirePaymentListener::class) ->setArgument('$client', new Reference(PayKit::class)) ->setArgument('$pricing', null) ->setArgument('$psrFactory', new Reference('paykit.psr_http_factory')) ->setArgument('$httpFactory', new Reference('paykit.http_foundation_factory')) ->setArgument('$mpp', null) ->setArgument('$mppFactory', $mppFactory) + ->setArgument('$x402', $x402) ->setAutowired(true) ->setPublic(true); $listener->addTag('kernel.event_listener', [ @@ -93,6 +105,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->scalarNode('x402_facilitator_url')->defaultNull()->end() + ->scalarNode('x402_replay_store')->defaultNull()->end() ->scalarNode('mpp_challenge_binding_secret')->defaultNull()->end() ->scalarNode('mpp_replay_store_service')->defaultNull()->end() ->booleanNode('mpp_allow_unsafe_memory_store')->defaultFalse()->end() diff --git a/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php b/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php index 6a5a8cddd..67b948033 100644 --- a/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php +++ b/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php @@ -9,6 +9,7 @@ use PayKit\Middleware\RequirePayment as PsrRequirePayment; use PayKit\Pricing; use PayKit\Protocols\Mpp\Adapter as MppAdapter; +use PayKit\Protocols\X402\Adapter as X402Adapter; use PayKit\Frameworks\Symfony\Attribute\RequirePayment; use ReflectionMethod; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -30,6 +31,7 @@ public function __construct( private readonly HttpFoundationFactory $httpFactory, private readonly ?MppAdapter $mpp = null, private readonly ?Closure $mppFactory = null, + private readonly ?X402Adapter $x402 = null, ) { } @@ -47,6 +49,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo pricing: $this->pricing, mpp: $this->mpp, mppFactory: $this->mppFactory, + x402: $this->x402, ); $psrResponse = $middleware->process( $psrRequest, diff --git a/php/src/Protocols/X402/Adapter.php b/php/src/Protocols/X402/Adapter.php index e868f92ea..8587469a3 100644 --- a/php/src/Protocols/X402/Adapter.php +++ b/php/src/Protocols/X402/Adapter.php @@ -4,9 +4,13 @@ namespace PayKit\Protocols\X402; +use Brick\Math\BigInteger; +use Brick\Math\Exception\RoundingNecessaryException; use PayKit\Config; +use PayKit\Exception\ConfigurationException; use PayKit\Exception\InvalidProofException; use PayKit\Gate; +use PayKit\PayCore\Network; use PayKit\Payment; use PayKit\Protocol; use PayKit\PayCore\Rpc\RpcGateway; @@ -14,12 +18,14 @@ use PayKit\Protocols\X402\Exact\PaymentExtensions; use PayKit\Protocols\X402\Exact\Verifier; use PayKit\Store\MemoryStore; +use PayKit\Store\ReplayStoreCapability; use PayKit\Store\Store; use Psr\Http\Message\ServerRequestInterface; use RuntimeException; use SolanaPhpSdk\Keypair\Keypair; use SolanaPhpSdk\Rpc\RpcClient; use SolanaPhpSdk\Transaction\VersionedTransaction; +use SolanaPhpSdk\Util\Base58; use Throwable; /** @@ -69,6 +75,8 @@ final class Adapter * dev-only warning is emitted: a single-process memory store * loses replay protection across workers/restarts, so production * deployments MUST inject a shared atomic store (Redis, Postgres). + * An explicitly injected store on non-localnet must also declare + * the durable, shared, atomic replay capability. * @param ?RpcGateway $rpc Confirmation/broadcast gateway. Defaults to a * {@see SolanaRpcGateway} over the configured `rpcUrl`, created * lazily on first settlement. Inject a fake for unit tests. @@ -91,23 +99,55 @@ public function __construct( . 'leave X402Config::$facilitatorUrl null for self-hosted', ); } - if ($replayStore === null) { - self::warnDefaultReplayStore(); - $replayStore = new MemoryStore(); + if ( + $replayStore !== null + && $config->network !== Network::SolanaLocalnet + && (!$replayStore instanceof ReplayStoreCapability + || !$replayStore->providesDurableSharedReplayProtection()) + ) { + throw new ConfigurationException( + 'pay_kit: x402 replayStore must explicitly declare durable shared replay protection outside localnet', + ); } - $this->replayStore = $replayStore; + $this->replayStore = $replayStore ?? self::defaultReplayStore($config->network); $this->recentBlockhashProvider = $recentBlockhashProvider; $this->rpc = $rpc; } - private static function warnDefaultReplayStore(): void + /** + * Resolve the fallback replay store when the caller passed none. + * + * The in-memory default is only safe on localnet (single-process dev). Off + * localnet it is a replay hole: markers are process-local, so a restart or a + * second worker/replica would accept a replayed settlement. Fail closed + * unless `PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1` acknowledges single-process + * scope. Mirrors the MPP adapter guard. + */ + private static function defaultReplayStore(Network $network): Store { - if (function_exists('error_log')) { + $allowInMemory = getenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE') === '1'; + if ($network === Network::SolanaMainnet && $allowInMemory) { + throw new ConfigurationException( + 'pay_kit: PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 is forbidden on mainnet; ' + . 'inject a durable shared replay store', + ); + } + if ($network !== Network::SolanaLocalnet && !$allowInMemory) { + throw new ConfigurationException( + 'pay_kit: a shared replay store is required outside localnet. The default in-memory ' + . 'store is process-local, so a second replica or a restart would accept a replayed ' + . 'settlement. Inject a shared, persistent Store (ideally one with an atomic reserve, ' + . 'e.g. Redis SET NX) into the x402 adapter, or set ' + . 'PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 to acknowledge single-process replay scope.', + ); + } + if ($network !== Network::SolanaLocalnet && function_exists('error_log')) { error_log( - 'pay_kit: WARN: x402 adapter using in-memory replay store; ' - . 'dev-only. Inject a shared atomic Store (Redis/Postgres) in production.', + 'pay_kit: WARN: x402 adapter using in-memory replay store off localnet ' + . '(PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1); replay protection is process-local.', ); } + return new MemoryStore(); } private function rpc(): RpcGateway @@ -130,7 +170,7 @@ public function acceptsEntry(Gate $gate, ServerRequestInterface $request): array $asset = \PayKit\PayCore\Solana\Mints::resolve($coin, $this->config->network->mintsLabel()) ?? $coin; $tokenProgram = \PayKit\PayCore\Solana\Mints::tokenProgramFor($coin, $this->config->network->mintsLabel()); $payTo = $gate->payTo ?? $this->config->effectiveRecipient(); - $amount = (string) $gate->total()->amount->multipliedBy(1_000_000)->toInt(); + $amount = self::exactBaseUnits($gate); $signer = $this->config->effectiveX402Signer(); $extra = [ 'feePayer' => $signer?->pubkey() ?? '', @@ -162,6 +202,29 @@ public function acceptsEntry(Gate $gate, ServerRequestInterface $request): array ]; } + /** + * Convert the gate total to an exact unsigned u64 wire value. + * + * JSON can represent native PHP integers exactly through ``PHP_INT_MAX``. + * Above that boundary keep the decimal form as a string, which preserves + * the full u64 value without asking a JSON consumer to round it. + */ + private static function exactBaseUnits(Gate $gate): string + { + try { + $amount = $gate->total()->amount->multipliedBy(1_000_000)->toBigInteger(); + } catch (RoundingNecessaryException $e) { + throw new ConfigurationException( + 'pay_kit: x402 amount has more precision than the token supports', + previous: $e, + ); + } + if ($amount->isNegative() || $amount->isGreaterThan(BigInteger::of('18446744073709551615'))) { + throw new ConfigurationException('pay_kit: x402 amount must fit an unsigned u64'); + } + return (string) $amount; + } + private function fetchRecentBlockhash(): ?string { if ($this->recentBlockhashProvider !== null) { @@ -304,6 +367,14 @@ public function verifyAndSettle(Gate $gate, ServerRequestInterface $request): Pa $kp = Keypair::fromSecretKey($signer->secretKey()); $tx->partialSign($kp); $cosignedWire = $tx->serialize(verifySignatures: false); + $expectedSig = Base58::encode($tx->signatures[0]); + + // Claim before the first RPC side effect. A transport error cannot + // prove the node did not accept the transaction, so the marker is + // deliberately retained on every broadcast/confirmation ambiguity. + if (!$this->replayStore->putIfAbsent(self::REPLAY_KEY_PREFIX . $expectedSig, true)) { + throw new InvalidProofException('pay_kit: signature_consumed'); + } // Broadcast via the raw-wire path so PHP doesn't have to // reconstruct a SignedTransaction wrapper just to send. @@ -322,16 +393,8 @@ public function verifyAndSettle(Gate $gate, ServerRequestInterface $request): Pa if (!is_string($sig) || $sig === '') { throw new InvalidProofException('pay_kit: empty broadcast result'); } - - // Reserve in the replay store BETWEEN broadcast and confirmation. - // RPC has accepted the transaction, so it may land even if the - // await below times out or the process crashes. Reserving first - // means a retry of the same credential trips the consumed guard - // rather than re-settling. Mirrors the MPP SolanaChargeHandler - // (settle() reserves between sendRawTransaction and - // awaitConfirmation; PR #85 Greptile P1 / audit gap G05). - if (!$this->replayStore->putIfAbsent(self::REPLAY_KEY_PREFIX . $sig, true)) { - throw new InvalidProofException('pay_kit: signature_consumed'); + if ($sig !== $expectedSig) { + throw new InvalidProofException('pay_kit: broadcast signature mismatch'); } // Confirm BEFORE returning the payment-response success. RPC @@ -449,7 +512,7 @@ private function matchCanonicalCredential(array $envelope, array $offer): void if (!is_array($accepted)) { throw new InvalidProofException('invalid_exact_svm_payload_envelope'); } - foreach (['scheme', 'network', 'asset', 'payTo'] as $key) { + foreach (['scheme', 'network', 'asset', 'payTo', 'amount', 'maxAmountRequired'] as $key) { if (($accepted[$key] ?? null) !== ($offer[$key] ?? null)) { throw new InvalidProofException( 'pay_kit: charge_request_mismatch: ' diff --git a/php/src/Protocols/X402/Exact/Verifier.php b/php/src/Protocols/X402/Exact/Verifier.php index f82bbe3af..3ed637f8f 100644 --- a/php/src/Protocols/X402/Exact/Verifier.php +++ b/php/src/Protocols/X402/Exact/Verifier.php @@ -4,6 +4,7 @@ namespace PayKit\Protocols\X402\Exact; +use Brick\Math\BigInteger; use PayKit\Exception\InvalidProofException; use PayKit\PayCore\Solana\Mints; use SolanaPhpSdk\Keypair\PublicKey; @@ -56,7 +57,7 @@ final class Verifier * @param array $requirement The x402 accepts[] entry. * @param list $managedSigners Server-managed pubkeys (typically the facilitator). * - * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:int} + * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:string} */ public static function verify( string $transactionBase64, @@ -164,7 +165,7 @@ private static function verifyComputePrice(object $ix, array $accountKeys): void ); } $micro = self::readU64Le($data, 1); - if ($micro > self::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS) { + if ($micro->isGreaterThan(self::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS)) { throw new InvalidProofException( 'invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high', ); @@ -176,7 +177,7 @@ private static function verifyComputePrice(object $ix, array $accountKeys): void * @param list $accountKeys * @param array $requirement * @param list $managedSigners - * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:int} + * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:string} */ private static function verifyTransfer( object $ix, @@ -240,7 +241,7 @@ private static function verifyTransfer( // Rule 8: amount match. $amount = self::readU64Le($data, 1); $expectedAmount = self::amountField($requirement); - if ($amount !== $expectedAmount) { + if (!$amount->isEqualTo($expectedAmount)) { throw new InvalidProofException('invalid_exact_svm_payload_amount_mismatch'); } @@ -250,7 +251,7 @@ private static function verifyTransfer( 'mint' => $mint, 'destination' => $destination, 'authority' => $authority, - 'amount' => $amount, + 'amount' => (string) $amount, ]; } @@ -325,24 +326,56 @@ private static function stringExtra(array $requirement, string $key, bool $requi return is_string($v) ? $v : null; } + /** Maximum unsigned u64, the wire upper bound for base-unit amounts. */ + private static function u64Max(): BigInteger + { + return BigInteger::of('18446744073709551615'); + } + /** + * Parses the requirement amount into an exact unsigned u64. Wire + * amounts are u64, but PHP's native int is signed 64-bit: `(int)` on a + * decimal string above PHP_INT_MAX saturates, so a high-bit amount + * would never compare equal to (or, worse, could be made to collide + * with) the transferred amount. Anything that is not a canonical + * non-negative integer within the u64 range fails closed. + * * @param array $requirement */ - private static function amountField(array $requirement): int + private static function amountField(array $requirement): BigInteger { $v = $requirement['amount'] ?? $requirement['maxAmountRequired'] ?? null; - if (!is_string($v) && !is_int($v)) { + if (is_int($v)) { + if ($v < 0) { + throw new InvalidProofException('invalid_exact_svm_payload_missing_field_amount'); + } + return BigInteger::of($v); + } + if (!is_string($v) || preg_match('/^[0-9]+$/', $v) !== 1) { throw new InvalidProofException('invalid_exact_svm_payload_missing_field_amount'); } - return (int) $v; + $normalized = ltrim($v, '0'); + $amount = BigInteger::of($normalized === '' ? '0' : $normalized); + if ($amount->isGreaterThan(self::u64Max())) { + throw new InvalidProofException('invalid_exact_svm_payload_missing_field_amount'); + } + return $amount; } - private static function readU64Le(string $data, int $offset): int + /** + * Reads a little-endian u64 as an exact unsigned value. `unpack('P')` + * yields a signed PHP int, so values in [2^63, 2^64) come back + * negative; rebuild from two unsigned 32-bit halves instead. + */ + private static function readU64Le(string $data, int $offset): BigInteger { if (strlen($data) < $offset + 8) { throw new InvalidProofException('invalid_exact_svm_payload_no_transfer_instruction'); } - $b = unpack('P', substr($data, $offset, 8)); - return $b === false ? 0 : (int) $b[1]; + $parts = unpack('Vlo/Vhi', substr($data, $offset, 8)); + if ($parts === false) { + throw new InvalidProofException('invalid_exact_svm_payload_no_transfer_instruction'); + } + return BigInteger::of($parts['hi'])->shiftedLeft(32)->plus($parts['lo']); } } diff --git a/php/tests/Frameworks/ReplayStoreInjectionTest.php b/php/tests/Frameworks/ReplayStoreInjectionTest.php new file mode 100644 index 000000000..24a78c967 --- /dev/null +++ b/php/tests/Frameworks/ReplayStoreInjectionTest.php @@ -0,0 +1,581 @@ + */ + private array $factories; + + public function __construct(mixed ...$factories) + { + $this->factories = $factories; + } + + public function createRequest(mixed $request): \Psr\Http\Message\ServerRequestInterface + { + $method = method_exists($request, 'getMethod') ? $request->getMethod() : 'GET'; + $uri = method_exists($request, 'getRequestUri') ? $request->getRequestUri() : '/paid'; + + return (new \Nyholm\Psr7\Factory\Psr17Factory())->createServerRequest($method, $uri); + } + } + } + + if (!class_exists(HttpFoundationFactory::class)) { + final class HttpFoundationFactory + { + public function createResponse(\Psr\Http\Message\ResponseInterface $response): \Symfony\Component\HttpFoundation\Response + { + return new \Symfony\Component\HttpFoundation\Response( + (string) $response->getBody(), + $response->getStatusCode(), + $response->getHeaders(), + ); + } + } + } +} + +namespace Illuminate\Contracts\Container { + if (!interface_exists(Container::class)) { + interface Container + { + public function bound(string $abstract): bool; + + public function make(string $abstract): mixed; + } + } +} + +namespace Illuminate\Contracts\Foundation { + if (!interface_exists(Application::class)) { + interface Application extends \Illuminate\Contracts\Container\Container, \ArrayAccess + { + public function singleton(string $abstract, \Closure $concrete): void; + + public function bind(string $abstract, \Closure $concrete): void; + + public function configPath(string $path = ''): string; + } + } +} + +namespace Illuminate\Support { + if (!class_exists(ServiceProvider::class)) { + abstract class ServiceProvider + { + public function __construct(protected \Illuminate\Contracts\Foundation\Application $app) + { + } + + protected function mergeConfigFrom(string $path, string $key): void + { + } + + /** @param array $paths */ + protected function publishes(array $paths, ?string $group = null): void + { + } + } + } +} + +namespace Illuminate\Routing { + if (!class_exists(Router::class)) { + final class Router + { + /** @var array */ + private array $aliases = []; + + /** @param class-string $class */ + public function aliasMiddleware(string $name, string $class): void + { + $this->aliases[$name] = $class; + } + + public function middlewareAlias(string $name): ?string + { + return $this->aliases[$name] ?? null; + } + } + } +} + +namespace Illuminate\Http { + if (!class_exists(Request::class)) { + final class Request + { + public object $attributes; + + public function __construct() + { + $this->attributes = new class () { + /** @var array */ + private array $values = []; + + public function get(string $key): mixed + { + return $this->values[$key] ?? null; + } + + public function set(string $key, mixed $value): void + { + $this->values[$key] = $value; + } + }; + } + } + } +} + +namespace PayKit\Tests\Frameworks { + use Illuminate\Contracts\Container\Container as LaravelContainerContract; + use Illuminate\Contracts\Foundation\Application as LaravelApplicationContract; + use Illuminate\Http\Request as LaravelRequest; + use Illuminate\Routing\Router as LaravelRouter; + use Nyholm\Psr7\Factory\Psr17Factory; + use PayKit\Config; + use PayKit\Exception\ConfigurationException; + use PayKit\Frameworks\Laravel\PayKitServiceProvider; + use PayKit\Frameworks\Laravel\RequirePaymentMiddleware; + use PayKit\Frameworks\Symfony\Attribute\RequirePayment as SymfonyRequirePayment; + use PayKit\Frameworks\Symfony\DependencyInjection\PayKitExtension; + use PayKit\Frameworks\Symfony\EventListener\RequirePaymentListener; + use PayKit\Gate; + use PayKit\Operator; + use PayKit\PayCore\Network; + use PayKit\PayKit; + use PayKit\Price; + use PayKit\Pricing; + use PayKit\Protocol; + use PayKit\Protocols\Mpp\MppConfig; + use PayKit\Protocols\X402\Adapter as X402Adapter; + use PayKit\Signer; + use PayKit\Store\MemoryStore; + use PayKit\Store\ReplayStoreCapability; + use PayKit\Store\Store; + use PHPUnit\Framework\TestCase; + use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; + use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; + use Symfony\Component\DependencyInjection\ContainerBuilder; + use Symfony\Component\DependencyInjection\Reference; + use Symfony\Component\HttpFoundation\Request as SymfonyRequest; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; + use Symfony\Component\HttpKernel\HttpKernelInterface; + + final class ReplayStoreInjectionTest extends TestCase + { + private PayKit $client; + + protected function setUp(): void + { + $signer = Signer::generate(); + $this->client = new PayKit(new Config( + network: Network::SolanaDevnet, + accept: [Protocol::X402], + operator: new Operator( + recipient: $signer->pubkey(), + signer: $signer, + feePayer: true, + ), + mpp: new MppConfig( + challengeBindingSecret: 'framework-test-secret-0123456789abcdef', + replayStore: new FrameworkDurableSharedReplayStore(), + ), + preflight: false, + )); + } + + public function testLaravelUsesInjectedX402Adapter(): void + { + $middleware = new RequirePaymentMiddleware( + $this->client, + new FrameworkLaravelContainer($this->pricing()), + $this->psrFactory(), + new HttpFoundationFactory(), + $this->x402Adapter(), + ); + + $response = $middleware->handle( + new LaravelRequest(), + static fn (): Response => new Response('next'), + 'paid', + ); + + self::assertSame(402, $response->getStatusCode()); + } + + public function testLaravelFailsClosedWithoutAnInjectedX402Adapter(): void + { + $middleware = new RequirePaymentMiddleware( + $this->client, + new FrameworkLaravelContainer($this->pricing()), + $this->psrFactory(), + new HttpFoundationFactory(), + ); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('shared replay store is required outside localnet'); + $middleware->handle(new LaravelRequest(), static fn (): Response => new Response('next'), 'paid'); + } + + public function testLaravelMppOnlyProviderBootsAndRunsWithoutX402State(): void + { + $app = $this->laravelApplication([ + 'network' => 'solana_localnet', + 'accept' => ['mpp'], + 'preflight' => false, + 'mpp_challenge_binding_secret' => 'framework-test-secret-0123456789abcdef', + // MPP requires an explicit process-local opt-in even on localnet + // (no implicit network escape); acknowledge single-process scope. + 'mpp' => ['allow_unsafe_memory_store' => true], + 'x402_replay_store' => 'missing.x402.replay_store', + ]); + $provider = new PayKitServiceProvider($app); + + $provider->register(); + $router = new LaravelRouter(); + $provider->boot($router); + + self::assertFalse($app->bound(X402Adapter::class)); + self::assertSame(RequirePaymentMiddleware::class, $router->middlewareAlias('paykit')); + $middleware = $app->make(RequirePaymentMiddleware::class); + self::assertInstanceOf(RequirePaymentMiddleware::class, $middleware); + + $response = $middleware->handle( + new LaravelRequest(), + static fn (): Response => new Response('next'), + 'paid', + ); + self::assertSame(402, $response->getStatusCode()); + self::assertNotSame('', $response->headers->get('www-authenticate', '')); + } + + public function testLaravelDefaultProtocolsStillRegisterX402Adapter(): void + { + $app = $this->laravelApplication([ + 'network' => 'solana_localnet', + 'preflight' => false, + 'mpp_challenge_binding_secret' => 'framework-test-secret-0123456789abcdef', + ]); + + (new PayKitServiceProvider($app))->register(); + + self::assertTrue($app->bound(X402Adapter::class)); + self::assertInstanceOf(RequirePaymentMiddleware::class, $app->make(RequirePaymentMiddleware::class)); + } + + public function testSymfonyUsesInjectedX402Adapter(): void + { + $listener = new RequirePaymentListener( + $this->client, + $this->pricing(), + $this->psrFactory(), + new HttpFoundationFactory(), + x402: $this->x402Adapter(), + ); + $event = $this->symfonyEvent(); + + $listener->onKernelControllerArguments($event); + + $response = ($event->getController())(); + self::assertInstanceOf(Response::class, $response); + self::assertSame(402, $response->getStatusCode()); + } + + public function testSymfonyFailsClosedWithoutAnInjectedX402Adapter(): void + { + $listener = new RequirePaymentListener( + $this->client, + $this->pricing(), + $this->psrFactory(), + new HttpFoundationFactory(), + ); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('shared replay store is required outside localnet'); + $listener->onKernelControllerArguments($this->symfonyEvent()); + } + + public function testSymfonyMppOnlyContainerBootsAndRunsWithoutX402State(): void + { + $container = new ContainerBuilder(); + $container->set('paykit.psr_http_factory', $this->psrFactory()); + $container->set('paykit.http_foundation_factory', new HttpFoundationFactory()); + + (new PayKitExtension())->load([[ + 'network' => 'solana_localnet', + 'accept' => ['mpp'], + 'preflight' => false, + 'mpp_challenge_binding_secret' => 'framework-test-secret-0123456789abcdef', + // MPP requires an explicit process-local opt-in even on localnet + // (no implicit network escape); acknowledge single-process scope. + 'mpp_allow_unsafe_memory_store' => true, + 'x402_replay_store' => 'missing.x402.replay_store', + ]], $container); + + self::assertFalse($container->hasDefinition(X402Adapter::class)); + $listenerDefinition = $container->getDefinition(RequirePaymentListener::class); + self::assertNull($listenerDefinition->getArgument('$x402')); + $listenerDefinition->setArgument('$pricing', $this->pricing()); + + $listener = $container->get(RequirePaymentListener::class); + self::assertInstanceOf(RequirePaymentListener::class, $listener); + $event = $this->symfonyEvent(); + $listener->onKernelControllerArguments($event); + + $response = ($event->getController())(); + self::assertInstanceOf(Response::class, $response); + self::assertSame(402, $response->getStatusCode()); + self::assertNotSame('', $response->headers->get('www-authenticate', '')); + } + + public function testSymfonyDefaultProtocolsInjectConfiguredReplayStoreIntoTheX402Adapter(): void + { + $container = new ContainerBuilder(); + $container->set('app.replay_store', new FrameworkDurableSharedReplayStore()); + $container->set('paykit.psr_http_factory', new \stdClass()); + $container->set('paykit.http_foundation_factory', new \stdClass()); + + (new PayKitExtension())->load([[ + 'network' => 'solana_devnet', + 'preflight' => false, + 'x402_replay_store' => 'app.replay_store', + ]], $container); + + $adapter = $container->getDefinition(X402Adapter::class); + $replayStore = $adapter->getArgument('$replayStore'); + self::assertInstanceOf(Reference::class, $replayStore); + self::assertSame('app.replay_store', (string) $replayStore); + + $listener = $container->getDefinition(RequirePaymentListener::class); + $listenerAdapter = $listener->getArgument('$x402'); + self::assertInstanceOf(Reference::class, $listenerAdapter); + self::assertSame(X402Adapter::class, (string) $listenerAdapter); + self::assertInstanceOf(X402Adapter::class, $container->get(X402Adapter::class)); + } + + private function pricing(): Pricing + { + return new class () extends Pricing { + public readonly Gate $paid; + + public function __construct() + { + $this->paid = new Gate(amount: Price::usd('0.10')); + } + }; + } + + private function x402Adapter(): X402Adapter + { + return new X402Adapter( + $this->client->config, + replayStore: new FrameworkDurableSharedReplayStore(), + recentBlockhashProvider: fn () => null, + ); + } + + /** @param array $config */ + private function laravelApplication(array $config): FrameworkLaravelApplication + { + $app = new FrameworkLaravelApplication($config); + $app->instance(Pricing::class, $this->pricing()); + $app->instance(PsrHttpFactory::class, $this->psrFactory()); + $app->instance(HttpFoundationFactory::class, new HttpFoundationFactory()); + + return $app; + } + + private function psrFactory(): PsrHttpFactory + { + $constructor = (new \ReflectionClass(PsrHttpFactory::class))->getConstructor(); + $arguments = array_fill(0, $constructor?->getNumberOfRequiredParameters() ?? 0, new Psr17Factory()); + + return new PsrHttpFactory(...$arguments); + } + + private function symfonyEvent(): ControllerArgumentsEvent + { + return new ControllerArgumentsEvent( + new FrameworkKernel(), + [new FrameworkPaidController(), 'paid'], + [], + SymfonyRequest::create('/paid'), + HttpKernelInterface::MAIN_REQUEST, + ); + } + } + + final class FrameworkLaravelContainer implements LaravelContainerContract + { + public function __construct(private readonly Pricing $pricing) + { + } + + public function bound(string $abstract): bool + { + return $abstract === Pricing::class; + } + + public function make(string $abstract): mixed + { + if ($abstract === Pricing::class) { + return $this->pricing; + } + + throw new \LogicException("Unexpected Laravel container resolution: $abstract"); + } + } + + final class FrameworkLaravelApplication implements LaravelApplicationContract + { + /** @var array */ + private array $instances = []; + + /** @var array */ + private array $singletons = []; + + /** @var array */ + private array $bindings = []; + + /** @param array $config */ + public function __construct(array $config) + { + $this->instances['config'] = new FrameworkLaravelConfigRepository(['paykit' => $config]); + } + + public function singleton(string $abstract, \Closure $concrete): void + { + $this->singletons[$abstract] = $concrete; + } + + public function bind(string $abstract, \Closure $concrete): void + { + $this->bindings[$abstract] = $concrete; + } + + public function configPath(string $path = ''): string + { + return '/tmp/' . ltrim($path, '/'); + } + + public function instance(string $abstract, object $instance): void + { + $this->instances[$abstract] = $instance; + } + + public function bound(string $abstract): bool + { + return isset($this->instances[$abstract]) + || isset($this->singletons[$abstract]) + || isset($this->bindings[$abstract]); + } + + public function make(string $abstract): mixed + { + if (isset($this->instances[$abstract])) { + return $this->instances[$abstract]; + } + if (isset($this->singletons[$abstract])) { + $instance = ($this->singletons[$abstract])($this); + if (!is_object($instance)) { + throw new \LogicException("Laravel singleton did not resolve an object: $abstract"); + } + return $this->instances[$abstract] = $instance; + } + if (isset($this->bindings[$abstract])) { + return ($this->bindings[$abstract])($this); + } + + throw new \LogicException("Unexpected Laravel application resolution: $abstract"); + } + + public function offsetExists(mixed $offset): bool + { + return is_string($offset) && isset($this->instances[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return is_string($offset) ? $this->make($offset) : null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if (!is_string($offset) || !is_object($value)) { + throw new \LogicException('Laravel application offsets require a string and object'); + } + $this->instances[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + if (is_string($offset)) { + unset($this->instances[$offset]); + } + } + } + + final class FrameworkLaravelConfigRepository + { + /** @param array $values */ + public function __construct(private readonly array $values) + { + } + + public function get(string $key, mixed $default = null): mixed + { + $value = $this->values; + foreach (explode('.', $key) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + return $default; + } + $value = $value[$segment]; + } + + return $value; + } + } + + final class FrameworkKernel implements HttpKernelInterface + { + public function handle(SymfonyRequest $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } + } + + final class FrameworkPaidController + { + #[SymfonyRequirePayment('paid')] + public function paid(): Response + { + return new Response('paid'); + } + } + + final class FrameworkDurableSharedReplayStore implements Store, ReplayStoreCapability + { + private MemoryStore $store; + + public function __construct() + { + $this->store = new MemoryStore(); + } + + public function putIfAbsent(string $key, mixed $value): bool + { + return $this->store->putIfAbsent($key, $value); + } + + public function providesDurableSharedReplayProtection(): bool + { + return true; + } + } +} diff --git a/php/tests/Harness/HarnessReplayStoreTest.php b/php/tests/Harness/HarnessReplayStoreTest.php new file mode 100644 index 000000000..13dd2f223 --- /dev/null +++ b/php/tests/Harness/HarnessReplayStoreTest.php @@ -0,0 +1,80 @@ +directory = sys_get_temp_dir() . '/pay-kit-harness-replay-' . bin2hex(random_bytes(8)); + } + + protected function tearDown(): void + { + foreach (glob($this->directory . '/*') ?: [] as $path) { + unlink($path); + } + if (is_dir($this->directory)) { + rmdir($this->directory); + } + } + + public function testSatisfiesTheExplicitCapabilityAcrossHarnessWorkers(): void + { + $store = new HarnessReplayStore($this->directory); + + self::assertInstanceOf(Store::class, $store); + self::assertInstanceOf(ReplayStoreCapability::class, $store); + self::assertTrue($store->providesDurableSharedReplayProtection()); + self::assertTrue($store->putIfAbsent('settlement', 'first')); + self::assertFalse( + (new HarnessReplayStore($this->directory))->putIfAbsent('settlement', 'second'), + 'a second harness worker must observe the first worker replay marker', + ); + + $script = sprintf( + 'require %s; require %s; $store = new %s(%s); exit($store->putIfAbsent(%s, %s) ? 1 : 0);', + var_export(dirname(__DIR__, 2) . '/vendor/autoload.php', true), + var_export(dirname(__DIR__, 3) . '/harness/php-server/HarnessReplayStore.php', true), + '\\' . HarnessReplayStore::class, + var_export($this->directory, true), + var_export('settlement', true), + var_export('child', true), + ); + exec(escapeshellarg(PHP_BINARY) . ' -r ' . escapeshellarg($script), $output, $exitCode); + self::assertSame(0, $exitCode, 'a restarted PHP worker must observe the existing replay marker'); + + $signer = Signer::generate(); + $adapter = new Adapter( + new Config( + network: Network::SolanaDevnet, + operator: new Operator( + recipient: $signer->pubkey(), + signer: $signer, + feePayer: true, + ), + preflight: false, + ), + replayStore: $store, + recentBlockhashProvider: fn () => null, + ); + + self::assertInstanceOf(Adapter::class, $adapter); + } +} diff --git a/php/tests/Middleware/RequirePaymentTest.php b/php/tests/Middleware/RequirePaymentTest.php index 5ee765417..5c0528d76 100644 --- a/php/tests/Middleware/RequirePaymentTest.php +++ b/php/tests/Middleware/RequirePaymentTest.php @@ -7,6 +7,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PayKit\PayKit; use PayKit\Config; +use PayKit\Exception\ConfigurationException; use PayKit\PayCore\Currency; use PayKit\Gate; use PayKit\Middleware\RequirePayment; @@ -17,6 +18,7 @@ use PayKit\Pricing; use PayKit\Protocol; use PayKit\Protocols\Mpp\MppConfig; +use PayKit\Protocols\X402\Adapter as X402Adapter; use PayKit\Signer; use PayKit\Store\MemoryStore; use PayKit\Store\ReplayStoreCapability; @@ -33,38 +35,55 @@ final class RequirePaymentTest extends TestCase public function testX402OnlyConstructionDoesNotRequireMppReplayStore(): void { - $client = new PayKit(new Config( - network: Network::SolanaDevnet, - accept: [Protocol::X402], - operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate()), - preflight: false, - )); - $middleware = new RequirePayment($client, new Gate(amount: Price::usd('0.10'))); - self::assertInstanceOf(RequirePayment::class, $middleware); + // x402 requires a durable shared replay store off-localnet; this + // single-process unit test acknowledges that scope via the opt-in so it + // can exercise the auto-wiring path. The assertion under test is that an + // x402-only client needs NO MPP replay store (the MPP adapter stays lazy). + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1'); + try { + $client = new PayKit(new Config( + network: Network::SolanaDevnet, + accept: [Protocol::X402], + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate()), + preflight: false, + )); + $middleware = new RequirePayment($client, new Gate(amount: Price::usd('0.10'))); + self::assertInstanceOf(RequirePayment::class, $middleware); + } finally { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + } } public function testX402OnlyGateDoesNotConstructMppReplayStore(): void { - $client = new PayKit(new Config( - network: Network::SolanaDevnet, - accept: [Protocol::X402, Protocol::Mpp], - operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate()), - preflight: false, - mpp: new MppConfig( - challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', - ), - )); - $middleware = new RequirePayment( - $client, - new Gate(amount: Price::usd('0.10'), accept: [Protocol::X402]), - ); + // Same single-process x402 opt-in as above; the assertion is that an + // x402-only gate never constructs the MPP replay store even when the + // client also accepts MPP. + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1'); + try { + $client = new PayKit(new Config( + network: Network::SolanaDevnet, + accept: [Protocol::X402, Protocol::Mpp], + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate()), + preflight: false, + mpp: new MppConfig( + challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', + ), + )); + $middleware = new RequirePayment( + $client, + new Gate(amount: Price::usd('0.10'), accept: [Protocol::X402]), + ); - $response = $middleware->process( - $this->factory->createServerRequest('GET', '/paid'), - $this->nextHandler(), - ); + $response = $middleware->process( + $this->factory->createServerRequest('GET', '/paid'), + $this->nextHandler(), + ); - self::assertSame(402, $response->getStatusCode()); + self::assertSame(402, $response->getStatusCode()); + } finally { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + } } protected function setUp(): void @@ -81,6 +100,23 @@ protected function setUp(): void $this->factory = new Psr17Factory(); } + /** + * @param Gate|string|\Closure(ServerRequestInterface):Gate $gateRef + */ + private function middleware(Gate|string|\Closure $gateRef, ?Pricing $pricing = null): RequirePayment + { + return new RequirePayment( + $this->client, + $gateRef, + $pricing, + x402: new X402Adapter( + $this->client->config, + replayStore: new MiddlewareDurableSharedReplayStore(), + recentBlockhashProvider: fn () => null, + ), + ); + } + private function nextHandler(): RequestHandlerInterface { $factory = $this->factory; @@ -100,7 +136,7 @@ public function handle(ServerRequestInterface $req): ResponseInterface public function testEmits402WhenNoCredentialPresent(): void { $gate = new Gate(amount: Price::usd('0.10')); - $mw = new RequirePayment($this->client, $gate); + $mw = $this->middleware($gate); $request = $this->factory->createServerRequest('GET', '/paid'); $response = $mw->process($request, $this->nextHandler()); $this->assertSame(402, $response->getStatusCode()); @@ -112,7 +148,7 @@ public function testEmits402WhenNoCredentialPresent(): void public function test402BodyCarriesAcceptsEntries(): void { $gate = new Gate(amount: Price::usd('0.10')); - $mw = new RequirePayment($this->client, $gate); + $mw = $this->middleware($gate); $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); $body = json_decode((string) $response->getBody(), true); $this->assertGreaterThanOrEqual(1, count($body['accepts'])); @@ -124,7 +160,7 @@ public function test402SetsCacheControlNoStore(): void // cached. Without no-store a CDN could replay a stale challenge // (different blockhash / expiry / amount) to a later client. $gate = new Gate(amount: Price::usd('0.10')); - $mw = new RequirePayment($this->client, $gate); + $mw = $this->middleware($gate); $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); $this->assertSame(402, $response->getStatusCode()); $this->assertSame('no-store', $response->getHeaderLine('cache-control')); @@ -133,7 +169,7 @@ public function test402SetsCacheControlNoStore(): void public function testWwwAuthenticateHeaderStampedFromMpp(): void { $gate = new Gate(amount: Price::usd('0.10')); - $mw = new RequirePayment($this->client, $gate); + $mw = $this->middleware($gate); $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); $this->assertNotEmpty($response->getHeaderLine('www-authenticate')); } @@ -147,7 +183,7 @@ public function __construct() $this->reportGate = new Gate(amount: Price::usd('0.10')); } }; - $mw = new RequirePayment($this->client, 'reportGate', $pricing); + $mw = $this->middleware('reportGate', $pricing); $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); $this->assertSame(402, $response->getStatusCode()); } @@ -155,14 +191,14 @@ public function __construct() public function testClosureGateInvoked(): void { $closure = fn (ServerRequestInterface $req): Gate => new Gate(amount: Price::usd('0.25')); - $mw = new RequirePayment($this->client, $closure); + $mw = $this->middleware($closure); $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); $this->assertSame(402, $response->getStatusCode()); } public function testStringHandleWithoutPricingRaises(): void { - $mw = new RequirePayment($this->client, 'reportGate'); + $mw = $this->middleware('reportGate'); $this->expectException(\LogicException::class); $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); } @@ -170,7 +206,7 @@ public function testStringHandleWithoutPricingRaises(): void public function testMalformedAuthorizationFallsThroughTo402(): void { $gate = new Gate(amount: Price::usd('0.10')); - $mw = new RequirePayment($this->client, $gate); + $mw = $this->middleware($gate); $request = $this->factory->createServerRequest('GET', '/paid') ->withHeader('Authorization', 'Payment garbage-not-valid'); $response = $mw->process($request, $this->nextHandler()); @@ -206,6 +242,14 @@ public function testRequirePaymentNamespaceFunctionRaisesWithoutPayment(): void $this->expectException(\PayKit\Exception\PaymentRequiredException::class); \PayKit\Middleware\requirePayment($request); } + + public function testMissingX402ReplayAdapterFailsClosedOnDevnet(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('shared replay store is required outside localnet'); + + new RequirePayment($this->client, new Gate(amount: Price::usd('0.10'))); + } } final class MiddlewareDurableSharedReplayStore implements Store, ReplayStoreCapability diff --git a/php/tests/Protocols/X402/AdapterTest.php b/php/tests/Protocols/X402/AdapterTest.php index dda611586..4f1e1c1b4 100644 --- a/php/tests/Protocols/X402/AdapterTest.php +++ b/php/tests/Protocols/X402/AdapterTest.php @@ -6,11 +6,15 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PayKit\Config; +use PayKit\Exception\ConfigurationException; use PayKit\Exception\InvalidProofException; use PayKit\Gate; use PayKit\PayCore\Network; use PayKit\Operator; use PayKit\Price; +use PayKit\Store\FileStore; +use PayKit\Store\MemoryStore; +use PayKit\Store\Store; use PayKit\Protocols\X402\Adapter; use PayKit\Protocols\X402\X402Config; use PayKit\Signer; @@ -18,10 +22,12 @@ final class AdapterTest extends TestCase { - private function makeConfig(?X402Config $x402 = null): Config - { + private function makeConfig( + ?X402Config $x402 = null, + Network $network = Network::SolanaDevnet, + ): Config { return new Config( - network: Network::SolanaDevnet, + network: $network, operator: new Operator( recipient: Signer::generate()->pubkey(), signer: Signer::generate(), @@ -32,10 +38,15 @@ private function makeConfig(?X402Config $x402 = null): Config ); } + private function replayStore(): Store + { + return new ExplicitCapabilityTestReplayStore(); + } + public function testAcceptsEntryHasCanonicalX402Shape(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => 'BLOCKHASH-STUB'); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => 'BLOCKHASH-STUB'); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $entry = $adapter->acceptsEntry($gate, $req); @@ -49,11 +60,57 @@ public function testAcceptsEntryHasCanonicalX402Shape(): void $this->assertNotEmpty($entry['extra']['feePayer']); } + public function testAcceptsEntrySupportsExactMaximumU64Amount(): void + { + $adapter = new Adapter( + $this->makeConfig(), + replayStore: $this->replayStore(), + recentBlockhashProvider: fn () => null, + ); + $gate = new Gate(amount: Price::usd('18446744073709.551615')); + $request = (new Psr17Factory())->createServerRequest('GET', '/paid'); + + $entry = $adapter->acceptsEntry($gate, $request); + + $this->assertSame('18446744073709551615', $entry['amount']); + $this->assertSame('18446744073709551615', $entry['maxAmountRequired']); + } + + public function testAcceptsEntryRejectsAmountAboveU64(): void + { + $adapter = new Adapter( + $this->makeConfig(), + replayStore: $this->replayStore(), + recentBlockhashProvider: fn () => null, + ); + $gate = new Gate(amount: Price::usd('18446744073709.551616')); + $request = (new Psr17Factory())->createServerRequest('GET', '/paid'); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('unsigned u64'); + $adapter->acceptsEntry($gate, $request); + } + + public function testAcceptsEntryRejectsFractionalBaseUnit(): void + { + $adapter = new Adapter( + $this->makeConfig(), + replayStore: $this->replayStore(), + recentBlockhashProvider: fn () => null, + ); + $gate = new Gate(amount: Price::usd('0.0000001')); + $request = (new Psr17Factory())->createServerRequest('GET', '/paid'); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('more precision'); + $adapter->acceptsEntry($gate, $request); + } + public function testAcceptsEntryEmbedsRecentBlockhash(): void { // Ruby PR #142 caveat #5. $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => 'ABC123BLOCKHASH'); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => 'ABC123BLOCKHASH'); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $entry = $adapter->acceptsEntry($gate, $req); @@ -63,7 +120,7 @@ public function testAcceptsEntryEmbedsRecentBlockhash(): void public function testAcceptsEntryOmitsBlockhashWhenProviderReturnsNull(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => null); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $entry = $adapter->acceptsEntry($gate, $req); @@ -73,7 +130,7 @@ public function testAcceptsEntryOmitsBlockhashWhenProviderReturnsNull(): void public function testChallengeHeadersAreBase64JsonEnvelope(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => null); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $headers = $adapter->challengeHeaders($gate, $req); @@ -93,10 +150,102 @@ public function testDelegatedModeRejectedAtConstruction(): void new Adapter($cfg); } + public function testRequiresSharedStoreOffLocalnet(): void + { + // H3: off localnet, constructing without a store must fail closed rather + // than silently use the process-local in-memory store. + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + $cfg = $this->makeConfig(); // SolanaDevnet + $this->expectException(ConfigurationException::class); + new Adapter($cfg, recentBlockhashProvider: fn () => null); + } + + public function testInMemoryOptOutAllowedOffLocalnet(): void + { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1'); + try { + $adapter = new Adapter($this->makeConfig(), recentBlockhashProvider: fn () => null); + $this->assertInstanceOf(Adapter::class, $adapter); + } finally { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + } + } + + public function testInMemoryOptOutRejectedOnMainnet(): void + { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1'); + try { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('forbidden on mainnet'); + new Adapter( + $this->makeConfig(network: Network::SolanaMainnet), + recentBlockhashProvider: fn () => null, + ); + } finally { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + } + } + + public function testRejectsMemoryStoreInjectionOffLocalnet(): void + { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1'); + try { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('must explicitly declare durable shared replay protection'); + new Adapter( + $this->makeConfig(), + replayStore: new MemoryStore(), + recentBlockhashProvider: fn () => null, + ); + } finally { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + } + } + + public function testRejectsFileStoreInjectionOffLocalnet(): void + { + $directory = sys_get_temp_dir() . '/pay-kit-x402-' . bin2hex(random_bytes(8)); + try { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('must explicitly declare durable shared replay protection'); + new Adapter( + $this->makeConfig(), + replayStore: new FileStore($directory), + recentBlockhashProvider: fn () => null, + ); + } finally { + if (is_dir($directory)) { + rmdir($directory); + } + } + } + + public function testAcceptsDurableSharedReplayStoreOffLocalnet(): void + { + $adapter = new Adapter( + $this->makeConfig(), + replayStore: $this->replayStore(), + recentBlockhashProvider: fn () => null, + ); + + $this->assertInstanceOf(Adapter::class, $adapter); + } + + public function testAllowsMemoryStoreInjectionOnLocalnet(): void + { + $adapter = new Adapter( + $this->makeConfig(network: Network::SolanaLocalnet), + replayStore: new MemoryStore(), + recentBlockhashProvider: fn () => null, + ); + + $this->assertInstanceOf(Adapter::class, $adapter); + } + public function testVerifyAndSettleRaisesWithoutPaymentSignature(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => null); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $this->expectException(InvalidProofException::class); @@ -106,7 +255,7 @@ public function testVerifyAndSettleRaisesWithoutPaymentSignature(): void public function testVerifyAndSettleRaisesOnMalformedBase64(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => null); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid') ->withHeader('Payment-Signature', '@@@-not-base64-@@@'); @@ -117,7 +266,7 @@ public function testVerifyAndSettleRaisesOnMalformedBase64(): void public function testVerifyAndSettleRaisesOnInvalidVersion(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => null); $gate = new Gate(amount: Price::usd('0.10')); $envelope = base64_encode(json_encode(['x402Version' => 99]) ?: '{}'); $req = (new Psr17Factory())->createServerRequest('GET', '/paid') diff --git a/php/tests/Protocols/X402/ConfirmationTest.php b/php/tests/Protocols/X402/ConfirmationTest.php index 78d275800..59c2c530c 100644 --- a/php/tests/Protocols/X402/ConfirmationTest.php +++ b/php/tests/Protocols/X402/ConfirmationTest.php @@ -43,6 +43,7 @@ private function makeAdapter(FakeRpcGateway $rpc, int $attempts = 3): Adapter ); return new Adapter( $config, + replayStore: new ExplicitCapabilityTestReplayStore(), recentBlockhashProvider: fn () => null, rpc: $rpc, confirmationAttempts: $attempts, diff --git a/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php b/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php index 8ebaec045..1bba6902c 100644 --- a/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php +++ b/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php @@ -48,6 +48,8 @@ private function buildTransaction( ?string $authority = null, array $signerTail = [], string $tokenProgram = self::TOKEN_PROGRAM, + string $amount = '100000', + ?string $amountWire = null, ): array { $payer = Keypair::generate()->getPublicKey()->toBase58(); $authority ??= Keypair::generate()->getPublicKey()->toBase58(); @@ -55,12 +57,12 @@ private function buildTransaction( $payTo = Keypair::generate()->getPublicKey()->toBase58(); $mint = self::USDC_MINT; $destination = Mints::deriveAta($payTo, $mint, $tokenProgram); - $amount = 100000; + $amountWire ??= pack('P', (int) $amount); // Instruction data blobs. $computeLimitData = chr(2) . pack('V', 200000); // tag 2 + u32 $computePriceData = chr(3) . pack('P', 1); // tag 3 + u64 - $transferData = chr(12) . pack('P', $amount) . chr(6); // tag 12 + u64 + u8 decimals + $transferData = chr(12) . $amountWire . chr(6); // tag 12 + u64 + u8 decimals // Build the ordered account-key table and an index resolver. $keys = []; @@ -143,10 +145,20 @@ public function testTransactionWithoutOptionalsVerifies(): void [$tx, $req] = $this->buildTransaction([]); $result = Verifier::verify($tx, $req, [$this->unrelatedManagedSigner()]); $this->assertSame(self::TOKEN_PROGRAM, $result['program']); - $this->assertSame(100000, $result['amount']); + $this->assertSame('100000', $result['amount']); $this->assertArrayNotHasKey('destinationCreateAta', $result); } + public function testUnsignedU64MaxAmountVerifiesThroughFullWirePath(): void + { + $max = '18446744073709551615'; + [$tx, $req] = $this->buildTransaction([], amount: $max, amountWire: str_repeat("\xff", 8)); + + $result = Verifier::verify($tx, $req, [$this->unrelatedManagedSigner()]); + + $this->assertSame($max, $result['amount']); + } + public function testLighthouseOptionalInstructionAccepted(): void { // Wallets (Phantom 1, Solflare 2) inject Lighthouse guard @@ -156,7 +168,7 @@ public function testLighthouseOptionalInstructionAccepted(): void ['program' => self::LIGHTHOUSE, 'data' => chr(0), 'accounts' => []], ]); $result = Verifier::verify($tx, $req, [$this->unrelatedManagedSigner()]); - $this->assertSame(100000, $result['amount']); + $this->assertSame('100000', $result['amount']); } public function testMemoOptionalInstructionAccepted(): void @@ -165,7 +177,7 @@ public function testMemoOptionalInstructionAccepted(): void ['program' => self::MEMO_PROGRAM, 'data' => 'abc123nonce', 'accounts' => []], ]); $result = Verifier::verify($tx, $req, [$this->unrelatedManagedSigner()]); - $this->assertSame(100000, $result['amount']); + $this->assertSame('100000', $result['amount']); } public function testOfferWithoutExtraTokenProgramStillVerifies(): void @@ -182,7 +194,7 @@ public function testOfferWithoutExtraTokenProgramStillVerifies(): void $result = Verifier::verify($tx, $req, [$this->unrelatedManagedSigner()]); $this->assertSame(self::TOKEN_PROGRAM, $result['program']); - $this->assertSame(100000, $result['amount']); + $this->assertSame('100000', $result['amount']); } public function testManagedSignerAsDirectSourceRejected(): void diff --git a/php/tests/Protocols/X402/Exact/VerifierTest.php b/php/tests/Protocols/X402/Exact/VerifierTest.php index e91eda849..5c7e8bb8d 100644 --- a/php/tests/Protocols/X402/Exact/VerifierTest.php +++ b/php/tests/Protocols/X402/Exact/VerifierTest.php @@ -64,4 +64,37 @@ public function testAssociatedTokenProgramConstantRemoved(): void 'ASSOCIATED_TOKEN_PROGRAM must be removed: ATA-create is not an allowed optional slot', ); } + + public function testUnsignedU64DecodingStaysExactAbovePhpIntMax(): void + { + $readU64 = new \ReflectionMethod(Verifier::class, 'readU64Le'); + + $this->assertSame( + '9223372036854775808', + (string) $readU64->invoke(null, "\x00\x00\x00\x00\x00\x00\x00\x80", 0), + ); + $this->assertSame( + '18446744073709551615', + (string) $readU64->invoke(null, str_repeat("\xff", 8), 0), + ); + } + + public function testRequirementAmountParsesTheFullU64Range(): void + { + $amountField = new \ReflectionMethod(Verifier::class, 'amountField'); + + $this->assertSame( + '18446744073709551615', + (string) $amountField->invoke(null, ['amount' => '18446744073709551615']), + ); + } + + public function testRequirementAmountAboveU64FailsClosed(): void + { + $amountField = new \ReflectionMethod(Verifier::class, 'amountField'); + $this->expectException(InvalidProofException::class); + $this->expectExceptionMessage('invalid_exact_svm_payload_missing_field_amount'); + + $amountField->invoke(null, ['amount' => '18446744073709551616']); + } } diff --git a/php/tests/Protocols/X402/ExplicitCapabilityTestReplayStore.php b/php/tests/Protocols/X402/ExplicitCapabilityTestReplayStore.php new file mode 100644 index 000000000..e9704fa00 --- /dev/null +++ b/php/tests/Protocols/X402/ExplicitCapabilityTestReplayStore.php @@ -0,0 +1,33 @@ +store = new MemoryStore(); + } + + public function putIfAbsent(string $key, mixed $value): bool + { + return $this->store->putIfAbsent($key, $value); + } + + public function providesDurableSharedReplayProtection(): bool + { + return true; + } +} diff --git a/php/tests/Protocols/X402/LegacyWireTest.php b/php/tests/Protocols/X402/LegacyWireTest.php index b7a98e96b..09fceb060 100644 --- a/php/tests/Protocols/X402/LegacyWireTest.php +++ b/php/tests/Protocols/X402/LegacyWireTest.php @@ -45,7 +45,7 @@ private function makeAdapter(Network $network = Network::SolanaDevnet): Adapter ), preflight: false, ); - return new Adapter($config, recentBlockhashProvider: fn () => null); + return new Adapter($config, replayStore: new ExplicitCapabilityTestReplayStore(), recentBlockhashProvider: fn () => null); } private function makeGate(): Gate @@ -76,6 +76,42 @@ private function invoke(Adapter $adapter, string $method, mixed ...$args): mixed return $ref->invoke($adapter, ...$args); } + /** @return array */ + private function canonicalOffer(): array + { + return [ + 'scheme' => 'exact', + 'network' => 'solana:devnet', + 'asset' => 'USDC', + 'payTo' => 'recipient', + 'amount' => '100000', + 'maxAmountRequired' => '100000', + 'extra' => ['feePayer' => 'payer', 'tokenProgram' => 'token', 'memo' => '/paid'], + ]; + } + + public function testCanonicalCredentialRejectsTamperedAmount(): void + { + $offer = $this->canonicalOffer(); + $accepted = $offer; + $accepted['amount'] = '999999'; + + $this->expectException(InvalidProofException::class); + $this->expectExceptionMessage('charge_request_mismatch'); + $this->invoke($this->makeAdapter(), 'matchCanonicalCredential', ['accepted' => $accepted], $offer); + } + + public function testCanonicalCredentialRejectsTamperedMaximumAmount(): void + { + $offer = $this->canonicalOffer(); + $accepted = $offer; + $accepted['maxAmountRequired'] = '999999'; + + $this->expectException(InvalidProofException::class); + $this->expectExceptionMessage('charge_request_mismatch'); + $this->invoke($this->makeAdapter(), 'matchCanonicalCredential', ['accepted' => $accepted], $offer); + } + // ── caip2ForCluster: legacy plain-slug -> CAIP-2 normalization ────────── public function testCaip2ForClusterMapsPlainSlugs(): void diff --git a/typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts b/typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts new file mode 100644 index 000000000..d62a5b78e --- /dev/null +++ b/typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts @@ -0,0 +1,704 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InvalidProofError } from '../errors.js'; + +// --- Boundary mocks ------------------------------------------------------- +// The x402 exact adapter settles through an in-process @x402/core facilitator +// (which needs a live RPC + real signatures) and reads a recent blockhash from +// @solana/kit. Both are stubbed so the adapter's own wiring (challenge +// building, header parsing, verify/settle branching) is exercised offline. + +type VerifyResult = { + invalidMessage?: string; + invalidReason?: string; + isValid: boolean; + payer?: string; +}; +type SettleResult = { + errorMessage?: string; + errorReason?: string; + payer?: string; + success: boolean; + transaction?: string; +}; + +const facilitatorControl: { + settle: () => Promise; + verify: () => Promise; +} = { + settle: () => Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }), + verify: () => Promise.resolve({ isValid: true, payer: 'VerifyPayer' }), +}; + +const blockhashControl: { impl: () => Promise<{ context: { slot: bigint }; value: unknown }> } = { + impl: () => + Promise.resolve({ + context: { slot: 314n }, + value: { blockhash: 'BlockHash111', lastValidBlockHeight: 4242n }, + }), +}; + +// Counts every getLatestBlockhash().send() invocation, and an optional gate the +// test releases once all concurrent challenges are in flight, before a +// non-single-flight cache would already have fired N RPCs. +const rpcMeter: { calls: number; gate?: Promise; release?: () => void } = { calls: 0 }; + +const decodeControl: { impl: (header: string) => unknown } = { + impl: () => ({ accepted: { network: 'solana:test' }, payload: {} }), +}; + +vi.mock('@x402/core/facilitator', () => { + class FakeFacilitator { + register(): this { + return this; + } + verify(): Promise { + return facilitatorControl.verify(); + } + settle(): Promise { + return facilitatorControl.settle(); + } + } + return { x402Facilitator: FakeFacilitator }; +}); + +vi.mock('@x402/svm', async () => { + const actual = await vi.importActual('@x402/svm'); + return { ...actual, toFacilitatorSvmSigner: () => ({}) }; +}); + +vi.mock('@x402/svm/exact/facilitator', () => ({ ExactSvmScheme: class {} })); + +vi.mock('@x402/core/http', () => ({ + decodePaymentSignatureHeader: (header: string) => decodeControl.impl(header), + encodePaymentRequiredHeader: () => 'ENCODED_PAYMENT_REQUIRED', + encodePaymentResponseHeader: () => 'ENCODED_PAYMENT_RESPONSE', +})); + +vi.mock('@solana/kit', async () => { + const actual = await vi.importActual('@solana/kit'); + return { + ...actual, + createSolanaRpc: () => ({ + getLatestBlockhash: () => ({ + send: async () => { + rpcMeter.calls += 1; + if (rpcMeter.gate) await rpcMeter.gate; + return blockhashControl.impl(); + }, + }), + }), + }; +}); + +const { createX402ExactAdapter } = await import('../adapters/x402.js'); +const { configure } = await import('../config.js'); +const { Gate } = await import('../gate.js'); +const { usd } = await import('../price.js'); +const { gateDefaults } = await import('../pricing.js'); +const { createMemoryReplayStore } = await import('../replay-store.js'); + +async function setup() { + const config = await configure({ + accept: ['x402'], + mpp: { challengeBindingSecret: 'x402-adapter-secret' }, + network: 'solana_localnet', + replayStore: createMemoryReplayStore(), + }); + return { adapter: createX402ExactAdapter(config), config }; +} + +function paidRequest(header = 'PAYMENT_CRED'): Request { + return new Request('http://localhost/report', { headers: { 'x-payment': header } }); +} + +async function gateFor(amount = usd('0.10')) { + const config = await configure({ + accept: ['x402'], + mpp: { challengeBindingSecret: 'x402-adapter-secret' }, + network: 'solana_localnet', + }); + return Gate.create({ amount, name: 'report' }, gateDefaults(config)); +} + +describe('createX402ExactAdapter', () => { + beforeEach(() => { + facilitatorControl.verify = () => Promise.resolve({ isValid: true, payer: 'VerifyPayer' }); + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + blockhashControl.impl = () => + Promise.resolve({ + context: { slot: 314n }, + value: { blockhash: 'BlockHash111', lastValidBlockHeight: 4242n }, + }); + decodeControl.impl = () => ({ accepted: { network: 'solana:test' }, payload: {} }); + rpcMeter.calls = 0; + rpcMeter.gate = undefined; + rpcMeter.release = undefined; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('exposes protocol/scheme metadata', async () => { + const { adapter } = await setup(); + expect(adapter.protocol).toBe('x402'); + expect(adapter.scheme).toBe('exact'); + }); + + it('embeds a server-fetched blockhash in the challenge requirements', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + const headers = await adapter.challengeHeaders(gate, new Request('http://localhost/report')); + expect(headers['payment-required']).toBe('ENCODED_PAYMENT_REQUIRED'); + }); + + it('falls back to bare requirements when the blockhash fetch fails', async () => { + blockhashControl.impl = () => Promise.reject(new Error('rpc down')); + const { adapter } = await setup(); + const gate = await gateFor(); + // Still produces a challenge; the catch branch swallows the RPC error. + const headers = await adapter.challengeHeaders(gate, new Request('http://localhost/report')); + expect(headers['payment-required']).toBe('ENCODED_PAYMENT_REQUIRED'); + }); + + it('collapses a concurrent challenge burst to a single getLatestBlockhash RPC', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + + // Latch the RPC so every concurrent challenge enters challengeRequirements + // before the first fetch resolves; without single-flight this fires N RPCs. + rpcMeter.gate = new Promise(resolve => { + rpcMeter.release = resolve; + }); + const bursts = Array.from({ length: 20 }, () => + adapter.challengeHeaders(gate, new Request('http://localhost/report')), + ); + // Give the microtask queue a chance to line every request up at the gate. + await Promise.resolve(); + rpcMeter.release?.(); + await Promise.all(bursts); + + expect(rpcMeter.calls).toBe(1); + }); + + it('reuses the cached blockhash for a second challenge within the TTL (no extra RPC)', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + await adapter.challengeHeaders(gate, new Request('http://localhost/report')); + await adapter.challengeHeaders(gate, new Request('http://localhost/report')); + expect(rpcMeter.calls).toBe(1); + }); + + it('rejects a request with no payment header', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, new Request('http://localhost/report'))).rejects.toMatchObject({ + code: 'missing_x402_payment_header', + }); + }); + + it('rejects an undecodable payment header', async () => { + decodeControl.impl = () => { + throw new Error('bad base64'); + }; + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, paidRequest())).rejects.toMatchObject({ + code: 'invalid_x402_payment_header', + }); + }); + + it('rejects when verification fails, surfacing the invalid reason', async () => { + facilitatorControl.verify = () => + Promise.resolve({ invalidMessage: 'nope', invalidReason: 'signature_consumed', isValid: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, paidRequest())).rejects.toMatchObject({ + code: 'signature_consumed', + }); + }); + + it('defaults the invalid reason when verification omits one', async () => { + facilitatorControl.verify = () => Promise.resolve({ isValid: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, paidRequest())).rejects.toMatchObject({ code: 'invalid_proof' }); + }); + + it('rejects when settlement fails, surfacing the error reason', async () => { + facilitatorControl.settle = () => + Promise.resolve({ errorMessage: 'rpc down', errorReason: 'settlement_failed', success: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, paidRequest())).rejects.toMatchObject({ + code: 'settlement_failed', + }); + }); + + it('defaults the error reason when settlement omits one', async () => { + facilitatorControl.settle = () => Promise.resolve({ success: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, paidRequest())).rejects.toMatchObject({ + code: 'settlement_failed', + }); + }); + + it('settles a valid payment and returns the payment record', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + const payment = await adapter.verifyAndSettle(gate, paidRequest('CRED123')); + expect(payment).toMatchObject({ + gateName: 'report', + payer: 'SettlePayer', + protocol: 'x402', + raw: 'CRED123', + scheme: 'exact', + transaction: 'TxSig', + }); + expect(payment.settlementHeaders['x-payment-response']).toBe('ENCODED_PAYMENT_RESPONSE'); + }); + + it('falls back to the verify payer when settlement omits one', async () => { + facilitatorControl.settle = () => Promise.resolve({ success: true, transaction: 'TxSig' }); + const { adapter } = await setup(); + const gate = await gateFor(); + const payment = await adapter.verifyAndSettle(gate, paidRequest()); + expect(payment.payer).toBe('VerifyPayer'); + }); + + it('reads the credential from the payment-signature header', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + const request = new Request('http://localhost/report', { headers: { 'payment-signature': 'SIG' } }); + const payment = await adapter.verifyAndSettle(gate, request); + expect(payment.raw).toBe('SIG'); + }); + + it('surfaces an InvalidProofError instance on missing header', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, new Request('http://localhost/report'))).rejects.toBeInstanceOf( + InvalidProofError, + ); + }); + + // ── replay / in-flight dedup ──────────────────────────────────────────── + + it('rejects a sequential replay of the same payment payload', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + + const first = await adapter.verifyAndSettle(gate, paidRequest('REPLAY_CRED')); + expect(first.transaction).toBe('TxSig'); + + await expect(adapter.verifyAndSettle(gate, paidRequest('REPLAY_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('rejects a concurrent duplicate payload while the first settles', async () => { + let settleCalls = 0; + facilitatorControl.settle = () => { + settleCalls += 1; + return new Promise(resolve => + setTimeout(() => resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }), 20), + ); + }; + const { adapter } = await setup(); + const gate = await gateFor(); + + const results = await Promise.allSettled([ + adapter.verifyAndSettle(gate, paidRequest('RACE_CRED')), + adapter.verifyAndSettle(gate, paidRequest('RACE_CRED')), + ]); + const fulfilled = results.filter(result => result.status === 'fulfilled'); + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]!.reason).toMatchObject({ code: 'x402_payment_replayed' }); + expect(settleCalls).toBe(1); + }); + + it('rejects facilitator-slot mutations without collapsing distinct transaction messages', async () => { + const kit = await vi.importActual('@solana/kit'); + const facilitator = await kit.generateKeyPairSigner(); + const client = await kit.generateKeyPairSigner(); + const message = kit.pipe( + kit.createTransactionMessage({ version: 0 }), + msg => kit.setTransactionMessageFeePayerSigner(facilitator, msg), + msg => + kit.appendTransactionMessageInstructions( + [ + { + accounts: [{ address: client.address, role: kit.AccountRole.READONLY_SIGNER }], + data: new Uint8Array(), + programAddress: kit.address('11111111111111111111111111111111'), + }, + ], + msg, + ), + msg => + kit.setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' as never, + lastValidBlockHeight: 0n, + }, + msg, + ), + ); + const compiled = kit.compileTransaction(message); + const unsignedFacilitator = { + ...compiled, + signatures: { + ...compiled.signatures, + [client.address]: new Uint8Array(64).fill(2), + [facilitator.address]: null, + }, + }; + const unsignedFacilitatorWire = kit.getBase64EncodedWireTransaction(unsignedFacilitator) as string; + const facilitatorSlotMutationWire = kit.getBase64EncodedWireTransaction({ + ...unsignedFacilitator, + signatures: { + ...unsignedFacilitator.signatures, + [facilitator.address]: new Uint8Array(64).fill(1), + }, + }) as string; + const distinct = kit.compileTransaction( + kit.appendTransactionMessageInstructions( + [ + { + accounts: [{ address: client.address, role: kit.AccountRole.READONLY_SIGNER }], + data: new Uint8Array([1]), + programAddress: kit.address('11111111111111111111111111111111'), + }, + ], + message, + ), + ); + const distinctWire = kit.getBase64EncodedWireTransaction({ + ...distinct, + signatures: { + ...distinct.signatures, + [client.address]: new Uint8Array(64).fill(2), + [facilitator.address]: null, + }, + }) as string; + + const unsigned = kit.getTransactionDecoder().decode(kit.getBase64Codec().encode(unsignedFacilitatorWire)); + const mutated = kit.getTransactionDecoder().decode(kit.getBase64Codec().encode(facilitatorSlotMutationWire)); + const distinctMessage = kit.getTransactionDecoder().decode(kit.getBase64Codec().encode(distinctWire)); + expect(mutated.messageBytes).toEqual(unsigned.messageBytes); + expect(mutated.signatures[facilitator.address]).not.toEqual(unsigned.signatures[facilitator.address]); + expect(distinctMessage.messageBytes).not.toEqual(unsigned.messageBytes); + + let settleCalls = 0; + facilitatorControl.settle = () => { + settleCalls += 1; + return Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + }; + decodeControl.impl = header => ({ + accepted: { network: 'solana:test' }, + payload: { + transaction: + header === 'UNSIGNED_FACILITATOR' + ? unsignedFacilitatorWire + : header === 'FACILITATOR_SLOT_MUTATION' + ? facilitatorSlotMutationWire + : distinctWire, + }, + }); + + const { adapter } = await setup(); + const gate = await gateFor(); + + await adapter.verifyAndSettle(gate, paidRequest('UNSIGNED_FACILITATOR')); + await expect(adapter.verifyAndSettle(gate, paidRequest('FACILITATOR_SLOT_MUTATION'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + await adapter.verifyAndSettle(gate, paidRequest('DISTINCT_TRANSACTION')); + expect(settleCalls).toBe(2); + }); + + it('releases the payload key on a pre-broadcast settle failure so a retry can proceed', async () => { + // A verification-class errorReason proves the transaction never + // broadcast, so the reservation is released for an honest retry. + facilitatorControl.settle = () => + Promise.resolve({ errorMessage: 're-verify failed', errorReason: 'verification_failed', success: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('RETRY_CRED'))).rejects.toMatchObject({ + code: 'verification_failed', + }); + + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + const retried = await adapter.verifyAndSettle(gate, paidRequest('RETRY_CRED')); + expect(retried.transaction).toBe('TxSig'); + }); + + it('keeps the payload key on a landed-but-unconfirmed settle failure (transaction_failed)', async () => { + // `@x402/svm` collapses a confirmation-poll timeout on a tx that may + // have landed into {success:false, errorReason:'transaction_failed'}. + // Releasing the reservation here would let another replica re-serve the + // same landed payment, so the key must stay claimed. + facilitatorControl.settle = () => + Promise.resolve({ errorMessage: 'confirm timeout', errorReason: 'transaction_failed', success: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('LANDED_CRED'))).rejects.toMatchObject({ + code: 'transaction_failed', + }); + + // A second attempt with the same payload is rejected as a replay: the + // reservation was NOT released, so the landed tx cannot be re-served + // even if the next call would have settled successfully. + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + await expect(adapter.verifyAndSettle(gate, paidRequest('LANDED_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('keeps the payload key on an unknown settle failure reason (fail closed)', async () => { + // Any reason outside the enumerated release-safe set defaults to KEEP. + facilitatorControl.settle = () => Promise.resolve({ errorReason: 'some_new_unmapped_reason', success: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('UNKNOWN_CRED'))).rejects.toMatchObject({ + code: 'some_new_unmapped_reason', + }); + + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + await expect(adapter.verifyAndSettle(gate, paidRequest('UNKNOWN_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('keeps the payload key when settlement throws so an ambiguous result cannot replay', async () => { + // Facilitator hooks and implementations can throw after a broadcast but + // before confirmation is returned. Without a typed, pre-broadcast-only + // failure signal, retaining the reservation is the safe outcome. + facilitatorControl.settle = () => Promise.reject(new Error('cosign failed')); + const { adapter } = await setup(); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('THROW_CRED'))).rejects.toThrow(/cosign failed/); + + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + await expect(adapter.verifyAndSettle(gate, paidRequest('THROW_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('surfaces the original error type when settlement throws pre-broadcast', async () => { + // The thrown value is wrapped as an InvalidProofError with a stable code + // so callers get the canonical taxonomy, not the raw internal error. + facilitatorControl.settle = () => Promise.reject(new Error('cosign boom')); + const { adapter } = await setup(); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('THROW_CODE_CRED'))).rejects.toBeInstanceOf( + InvalidProofError, + ); + }); + + it('lets distinct payloads settle independently', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + + const first = await adapter.verifyAndSettle(gate, paidRequest('CRED_ONE')); + const second = await adapter.verifyAndSettle(gate, paidRequest('CRED_TWO')); + expect(first.transaction).toBe('TxSig'); + expect(second.transaction).toBe('TxSig'); + }); + + it('expires consumed entries after the completion window', async () => { + vi.useFakeTimers(); + try { + const { adapter } = await setup(); + const gate = await gateFor(); + + await adapter.verifyAndSettle(gate, paidRequest('WINDOW_CRED')); + // Within the 300s window the replay is still rejected. + vi.setSystemTime(Date.now() + 299_000); + await expect(adapter.verifyAndSettle(gate, paidRequest('WINDOW_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + // Past the window the blockhash has expired on-chain; the local + // entry is pruned and the ledger owns dedup from here. + vi.setSystemTime(Date.now() + 302_000); + const late = await adapter.verifyAndSettle(gate, paidRequest('WINDOW_CRED')); + expect(late.transaction).toBe('TxSig'); + } finally { + vi.useRealTimers(); + } + }); + + // ── cross-process dedup via a reserving replay store ──────────────────── + + /** A reserving Store double: `reserve` is an atomic check-and-set. */ + function reservingStore() { + const map = new Map(); + const calls: { reserve: Array<{ key: string; ttlSeconds?: number }>; deletes: string[] } = { + reserve: [], + deletes: [], + }; + return { + calls, + store: { + get: (key: string) => Promise.resolve(map.get(key) ?? null), + put: (key: string, value: unknown) => { + map.set(key, value); + return Promise.resolve(); + }, + delete: (key: string) => { + calls.deletes.push(key); + map.delete(key); + return Promise.resolve(); + }, + reserve: (key: string, value: unknown = true, ttlSeconds?: number) => { + calls.reserve.push({ key, ttlSeconds }); + if (map.has(key)) return Promise.resolve(false); + map.set(key, value); + return Promise.resolve(true); + }, + }, + }; + } + + async function setupWithStore(store: unknown) { + const config = await configure({ + accept: ['x402'], + mpp: { challengeBindingSecret: 'x402-adapter-secret' }, + network: 'solana_localnet', + replayStore: store as never, + }); + return { adapter: createX402ExactAdapter(config) }; + } + + it('claims through the reserving store (with a TTL) when one is configured', async () => { + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + await adapter.verifyAndSettle(gate, paidRequest('STORE_CRED')); + expect(reserving.calls.reserve).toHaveLength(1); + expect(reserving.calls.reserve[0]!.key.startsWith('x402-svm-exact:consumed:')).toBe(true); + expect(reserving.calls.reserve[0]!.ttlSeconds).toBe(300); + }); + + it('rejects a sequential replay via the reserving store', async () => { + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + await adapter.verifyAndSettle(gate, paidRequest('DUP_CRED')); + await expect(adapter.verifyAndSettle(gate, paidRequest('DUP_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('rejects a concurrent duplicate via the reserving store', async () => { + let settleCalls = 0; + facilitatorControl.settle = () => { + settleCalls += 1; + return new Promise(resolve => + setTimeout(() => resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }), 20), + ); + }; + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + const results = await Promise.allSettled([ + adapter.verifyAndSettle(gate, paidRequest('RACE_STORE')), + adapter.verifyAndSettle(gate, paidRequest('RACE_STORE')), + ]); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1); + expect(results.filter(r => r.status === 'rejected')).toHaveLength(1); + expect(settleCalls).toBe(1); + }); + + it('rejects replay across independent adapters sharing the atomic memory store', async () => { + const shared = createMemoryReplayStore(); + const [{ adapter: replicaA }, { adapter: replicaB }] = await Promise.all([ + setupWithStore(shared), + setupWithStore(shared), + ]); + const gate = await gateFor(); + + await replicaA.verifyAndSettle(gate, paidRequest('CROSS_INSTANCE_CRED')); + await expect(replicaB.verifyAndSettle(gate, paidRequest('CROSS_INSTANCE_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('releases the reserving-store key on a pre-broadcast settle failure', async () => { + facilitatorControl.settle = () => + Promise.resolve({ errorMessage: 're-verify failed', errorReason: 'verification_failed', success: false }); + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('REL_CRED'))).rejects.toMatchObject({ + code: 'verification_failed', + }); + expect(reserving.calls.deletes).toHaveLength(1); + + // The released key can be reclaimed by a retry. + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + const retried = await adapter.verifyAndSettle(gate, paidRequest('REL_CRED')); + expect(retried.transaction).toBe('TxSig'); + }); + + it('keeps the reserving-store key on a landed-but-unconfirmed settle failure', async () => { + // The cross-process invariant: a `transaction_failed` (confirm-timeout) + // must not delete the shared reservation, so a second replica reading + // the same store still rejects the payload as a replay. + facilitatorControl.settle = () => + Promise.resolve({ errorMessage: 'confirm timeout', errorReason: 'transaction_failed', success: false }); + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('KEEP_CRED'))).rejects.toMatchObject({ + code: 'transaction_failed', + }); + expect(reserving.calls.deletes).toHaveLength(0); + + // A second replica sharing the store still rejects the payload: the + // reservation was never released. + const { adapter: replica } = await setupWithStore(reserving.store); + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + await expect(replica.verifyAndSettle(gate, paidRequest('KEEP_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('keeps the reserving-store key when settlement throws', async () => { + facilitatorControl.settle = () => Promise.reject(new Error('cosign failed')); + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('THROW_STORE'))).rejects.toThrow(/cosign failed/); + expect(reserving.calls.deletes).toHaveLength(0); + + // Another replica sharing the store cannot reclaim an ambiguous payment. + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + await expect(adapter.verifyAndSettle(gate, paidRequest('THROW_STORE'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); +}); diff --git a/typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts b/typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts new file mode 100644 index 000000000..f60fb3345 --- /dev/null +++ b/typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + assertPaymentHeaderWithinCap, + errorMessage, + MAX_PAYMENT_SIGNATURE_HEADER_LEN, + x402PaymentHeader, +} from '../adapters/x402-shared.js'; + +describe('x402PaymentHeader', () => { + it('reads the X-PAYMENT header', () => { + const request = new Request('http://t/', { headers: { 'x-payment': 'cred-a' } }); + expect(x402PaymentHeader(request)).toBe('cred-a'); + }); + + it('falls back to the PAYMENT-SIGNATURE header', () => { + const request = new Request('http://t/', { headers: { 'payment-signature': 'cred-b' } }); + expect(x402PaymentHeader(request)).toBe('cred-b'); + }); + + it('prefers X-PAYMENT when both are present', () => { + const request = new Request('http://t/', { + headers: { 'payment-signature': 'cred-b', 'x-payment': 'cred-a' }, + }); + expect(x402PaymentHeader(request)).toBe('cred-a'); + }); + + it('returns undefined when neither header is present', () => { + expect(x402PaymentHeader(new Request('http://t/'))).toBeUndefined(); + }); +}); + +describe('errorMessage', () => { + it('extracts the message from an Error', () => { + expect(errorMessage(new Error('boom'))).toBe('boom'); + }); + + it('returns undefined for a non-Error value', () => { + expect(errorMessage('boom')).toBeUndefined(); + expect(errorMessage(undefined)).toBeUndefined(); + expect(errorMessage({ message: 'nope' })).toBeUndefined(); + }); +}); + +describe('assertPaymentHeaderWithinCap', () => { + it('accepts a header at exactly the cap', () => { + expect(() => assertPaymentHeaderWithinCap('A'.repeat(MAX_PAYMENT_SIGNATURE_HEADER_LEN))).not.toThrow(); + }); + + it('rejects a header one byte over the cap', () => { + expect(() => assertPaymentHeaderWithinCap('A'.repeat(MAX_PAYMENT_SIGNATURE_HEADER_LEN + 1))).toThrow( + /exceeds maximum size/, + ); + }); + + it('measures the cap in UTF-8 bytes, not code units', () => { + // A multi-byte code point: a string of MAX/2 + 1 two-byte chars is under + // the cap by code-unit count but over it by byte count. The cap is + // byte-based (matching Rust's raw-header len()), so this must reject. + const twoByteChar = 'é'; // 'é', 2 bytes UTF-8, 1 UTF-16 code unit + const overByBytes = twoByteChar.repeat(MAX_PAYMENT_SIGNATURE_HEADER_LEN / 2 + 1); + expect(overByBytes.length).toBeLessThanOrEqual(MAX_PAYMENT_SIGNATURE_HEADER_LEN); + expect(Buffer.byteLength(overByBytes, 'utf8')).toBeGreaterThan(MAX_PAYMENT_SIGNATURE_HEADER_LEN); + expect(() => assertPaymentHeaderWithinCap(overByBytes)).toThrow(/exceeds maximum size/); + }); +}); + +// --- Adapter-level cap enforcement ---------------------------------------- +// A hostile client sending a multi-megabyte X-PAYMENT header must be rejected +// before either adapter hands the header to decodePaymentSignatureHeader (which +// does the base64 + JSON work). The decode is spied so the assertion proves the +// cap fires FIRST, so no decode work is done for an over-cap header. + +const decodeSpy = vi.fn((_header: string) => ({ accepted: { network: 'solana:test' }, payload: {} })); + +vi.mock('@x402/core/facilitator', () => { + class FakeFacilitator { + register(): this { + return this; + } + verify(): Promise<{ isValid: boolean; payer?: string }> { + return Promise.resolve({ isValid: true, payer: 'VerifyPayer' }); + } + settle(): Promise<{ success: boolean; transaction?: string }> { + return Promise.resolve({ success: true, transaction: 'TxSig' }); + } + } + return { x402Facilitator: FakeFacilitator }; +}); + +vi.mock('@x402/svm', async () => { + const actual = await vi.importActual('@x402/svm'); + return { ...actual, toFacilitatorSvmSigner: () => ({}) }; +}); + +vi.mock('@x402/svm/exact/facilitator', () => ({ ExactSvmScheme: class {} })); +vi.mock('@x402/svm/upto/facilitator', () => ({ UptoSvmScheme: class {} })); + +vi.mock('@x402/core/http', () => ({ + decodePaymentSignatureHeader: (header: string) => decodeSpy(header), + encodePaymentRequiredHeader: () => 'ENCODED_PAYMENT_REQUIRED', + encodePaymentResponseHeader: () => 'ENCODED_PAYMENT_RESPONSE', +})); + +vi.mock('@solana/kit', async () => { + const actual = await vi.importActual('@solana/kit'); + return { + ...actual, + createSolanaRpc: () => ({ + getLatestBlockhash: () => ({ + send: () => + Promise.resolve({ + context: { slot: 314n }, + value: { blockhash: 'BH', lastValidBlockHeight: 1n }, + }), + }), + }), + }; +}); + +const { createX402ExactAdapter } = await import('../adapters/x402.js'); +const { X402Upto } = await import('../adapters/x402-upto.js'); +const { configure } = await import('../config.js'); +const { Gate } = await import('../gate.js'); +const { usd } = await import('../price.js'); +const { gateDefaults } = await import('../pricing.js'); +const { InvalidProofError } = await import('../errors.js'); +const { createMemoryReplayStore } = await import('../replay-store.js'); + +async function payKitConfig() { + return configure({ + accept: ['x402'], + mpp: { challengeBindingSecret: 'x402-cap-secret' }, + network: 'solana_localnet', + replayStore: createMemoryReplayStore(), + }); +} + +function oversizedHeader(): string { + return 'A'.repeat(MAX_PAYMENT_SIGNATURE_HEADER_LEN + 1); +} + +describe('payment-header size cap enforced by the adapters', () => { + beforeEach(() => { + decodeSpy.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('exact adapter rejects an over-cap header before decoding it', async () => { + const config = await payKitConfig(); + const adapter = createX402ExactAdapter(config); + const gate = Gate.create({ amount: usd('0.10'), name: 'report' }, gateDefaults(config)); + const request = new Request('http://localhost/report', { headers: { 'x-payment': oversizedHeader() } }); + + await expect(adapter.verifyAndSettle(gate, request)).rejects.toMatchObject({ + code: 'x402_payment_header_too_large', + }); + await expect(adapter.verifyAndSettle(gate, request)).rejects.toBeInstanceOf(InvalidProofError); + expect(decodeSpy).not.toHaveBeenCalled(); + }); + + it('upto adapter rejects an over-cap header before decoding it', async () => { + const config = await payKitConfig(); + const upto = new X402Upto(config); + const request = new Request('http://localhost/meter', { headers: { 'x-payment': oversizedHeader() } }); + + await expect(upto.verifyOpen(request, usd('1.00'))).rejects.toMatchObject({ + code: 'x402_payment_header_too_large', + }); + await expect(upto.verifyOpen(request, usd('1.00'))).rejects.toBeInstanceOf(InvalidProofError); + expect(decodeSpy).not.toHaveBeenCalled(); + }); + + it('both adapters still decode an at-cap header (boundary is inclusive)', async () => { + const config = await payKitConfig(); + const adapter = createX402ExactAdapter(config); + const gate = Gate.create({ amount: usd('0.10'), name: 'report' }, gateDefaults(config)); + const atCap = 'A'.repeat(MAX_PAYMENT_SIGNATURE_HEADER_LEN); + const request = new Request('http://localhost/report', { headers: { 'x-payment': atCap } }); + + // At the cap the header is NOT rejected on size; decode is reached. + await adapter.verifyAndSettle(gate, request); + expect(decodeSpy).toHaveBeenCalledWith(atCap); + }); + + it('rejects one channel across independent upto engines sharing the atomic store', async () => { + vi.useFakeTimers({ now: 1_700_000_000_000 }); + try { + const config = await payKitConfig(); + const first = new X402Upto(config); + const second = new X402Upto(config); + decodeSpy.mockReturnValue({ + accepted: { network: 'solana:test' }, + payload: { channelId: 'shared-channel', expiresAt: 1_700_003_600, from: 'payer' }, + }); + const request = new Request('http://localhost/meter', { headers: { 'x-payment': 'credential' } }); + + await expect(first.verifyOpen(request, usd('1.00'))).resolves.toMatchObject({ + maxBaseUnits: 1_000_000n, + }); + vi.advanceTimersByTime(301_000); + await expect(second.verifyOpen(request, usd('1.00'))).rejects.toMatchObject({ + code: 'upto_channel_replayed', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('accepts independent channels on independent usage routes', async () => { + const config = await payKitConfig(); + const first = new X402Upto(config); + const second = new X402Upto(config); + + decodeSpy + .mockReturnValueOnce({ + accepted: { network: 'solana:test' }, + payload: { channelId: 'route-a-channel', expiresAt: 1_700_003_600, from: 'payer' }, + }) + .mockReturnValueOnce({ + accepted: { network: 'solana:test' }, + payload: { channelId: 'route-b-channel', expiresAt: 1_700_003_600, from: 'payer' }, + }); + + await expect( + first.verifyOpen( + new Request('http://localhost/usage/a', { headers: { 'x-payment': 'credential-a' } }), + usd('1.00'), + ), + ).resolves.toBeDefined(); + await expect( + second.verifyOpen( + new Request('http://localhost/usage/b', { headers: { 'x-payment': 'credential-b' } }), + usd('1.00'), + ), + ).resolves.toBeDefined(); + }); + + it('binds a verified channel to one replay route, not the whole engine', async () => { + const config = await payKitConfig(); + const first = new X402Upto(config); + const second = new X402Upto(config); + decodeSpy.mockReturnValue({ + accepted: { network: 'solana:test' }, + payload: { channelId: 'same-channel', expiresAt: 1_700_003_600, from: 'payer' }, + }); + + await first.verifyOpen( + new Request('http://localhost/usage/a', { headers: { 'x-payment': 'credential' } }), + usd('1.00'), + ); + const request = new Request('http://localhost/usage/b', { headers: { 'x-payment': 'credential' } }); + + await expect(second.verifyOpen(request, usd('1.00'))).rejects.toMatchObject({ + code: 'upto_route_mismatch', + }); + }); + + it('does not claim that the current upto wire binds the challenge route', async () => { + const config = await payKitConfig(); + const upto = new X402Upto(config); + decodeSpy.mockReturnValue({ + accepted: { network: 'solana:test' }, + payload: { channelId: 'unbound-route-channel', expiresAt: 1_700_003_600, from: 'payer' }, + }); + + // `accepts()` only describes the challenge. The upstream payload has + // no signed resource pathname, so a credential created from that + // challenge cannot be checked against `/usage/a` here. + await upto.accepts(usd('1.00'), new Request('http://localhost/usage/a')); + await expect( + upto.verifyOpen( + new Request('http://localhost/usage/b', { headers: { 'x-payment': 'credential' } }), + usd('1.00'), + ), + ).resolves.toBeDefined(); + }); +}); diff --git a/typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts b/typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts new file mode 100644 index 000000000..96f5b7c54 --- /dev/null +++ b/typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; + +// The upto engine broadcasts the open through an in-process @x402/core +// facilitator (real crypto + RPC). Stub verify so verifyOpen reaches the replay +// reservation offline; the point under test is the TTL the reservation gets. + +vi.mock('@x402/core/facilitator', () => { + class FakeFacilitator { + register(): this { + return this; + } + verify(): Promise<{ isValid: boolean; payer: string }> { + return Promise.resolve({ isValid: true, payer: 'PAYER' }); + } + } + return { x402Facilitator: FakeFacilitator }; +}); + +vi.mock('@x402/core/http', () => ({ + decodePaymentSignatureHeader: (header: string) => JSON.parse(header), + encodePaymentRequiredHeader: () => 'ENCODED_PAYMENT_REQUIRED', + encodePaymentResponseHeader: () => 'ENCODED_PAYMENT_RESPONSE', +})); + +const { X402Upto } = await import('../adapters/x402-upto.js'); +const { configure } = await import('../config.js'); +const { usd } = await import('../price.js'); + +const NOW = () => Math.floor(Date.now() / 1000); + +function recordingStore() { + const reserve: { key: string; ttlSeconds?: number }[] = []; + return { + reserve, + store: { + delete: () => Promise.resolve(), + get: () => Promise.resolve(null), + put: () => Promise.resolve(), + reserve: (key: string, _value: unknown, ttlSeconds?: number) => { + reserve.push({ key, ttlSeconds }); + return Promise.resolve(true); + }, + }, + }; +} + +async function uptoFor(expiresAt: number) { + const recorder = recordingStore(); + const config = await configure({ + accept: ['x402'], + network: 'solana_localnet', + replayStore: recorder.store as never, + }); + const upto = new X402Upto(config); + const payload = JSON.stringify({ + accepted: { network: 'solana:localnet' }, + payload: { channelId: 'CH1', expiresAt, from: 'PAYER' }, + }); + await upto.verifyOpen(new Request('http://localhost/u', { headers: { 'x-payment': payload } }), usd('1.00')); + return recorder.reserve.map(entry => entry.ttlSeconds); +} + +describe('x402 upto reservation TTL is bounded by the payer-signed expiresAt', () => { + it('caps a far-future expiresAt at the hard ceiling (24h)', async () => { + const tenYears = NOW() + 10 * 365 * 24 * 3600; + const ttls = await uptoFor(tenYears); + expect(ttls.length).toBeGreaterThan(0); + for (const ttl of ttls) expect(ttl).toBe(24 * 60 * 60); + }); + + it('uses the remaining window when it sits between the floor and the ceiling', async () => { + const inTenMinutes = NOW() + 600; + const ttls = await uptoFor(inTenMinutes); + expect(ttls.length).toBeGreaterThan(0); + for (const ttl of ttls) expect(ttl).toBeCloseTo(600, -1); + }); + + it('floors an already-expired channel at the completion window (300s)', async () => { + const past = NOW() - 10; + const ttls = await uptoFor(past); + expect(ttls.length).toBeGreaterThan(0); + for (const ttl of ttls) expect(ttl).toBe(300); + }); +}); diff --git a/typescript/packages/pay-kit/src/__tests__/x402.test.ts b/typescript/packages/pay-kit/src/__tests__/x402.test.ts index 539056b33..856ca2639 100644 --- a/typescript/packages/pay-kit/src/__tests__/x402.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/x402.test.ts @@ -32,9 +32,14 @@ import { configure, type PayKitConfig } from '../config.js'; import { Gate } from '../gate.js'; import { usd } from '../price.js'; import { gateDefaults } from '../pricing.js'; +import { createMemoryReplayStore } from '../replay-store.js'; async function testConfig(): Promise { - return await configure({ accept: ['x402'], network: 'solana_localnet' }); + return await configure({ + accept: ['x402'], + network: 'solana_localnet', + replayStore: createMemoryReplayStore(), + }); } function gateFor(config: PayKitConfig, amount = usd('0.10')): Gate { @@ -66,6 +71,21 @@ describe('x402 exact adapter', () => { expect((entry.extra as { feePayer?: string }).feePayer).toBe(config.operator.signer.pubkey); }); + it('binds exact requirements to the request pathname', async () => { + const config = await testConfig(); + const adapter = createX402ExactAdapter(config); + const gate = gateFor(config); + + const first = await adapter.acceptsEntry(gate, new Request('http://localhost/reports/a')); + const second = await adapter.acceptsEntry(gate, new Request('http://localhost/reports/b')); + const firstMemo = (first.extra as { memo?: unknown } | undefined)?.memo; + const secondMemo = (second.extra as { memo?: unknown } | undefined)?.memo; + + expect(firstMemo).toBe('/reports/a'); + expect(secondMemo).toBe('/reports/b'); + expect(firstMemo).not.toBe(secondMemo); + }); + it('detects the x402 payment header', async () => { const config = await testConfig(); const adapter = createX402ExactAdapter(config); @@ -171,3 +191,31 @@ describe('Charge meter', () => { expect(charge.settledBaseUnits()).toBe(250_000n); }); }); + +describe('x402 replay-store provisioning', () => { + it('boots on localnet with no explicit replayStore (exact + upto)', async () => { + const config = await configure({ accept: ['x402'], network: 'solana_localnet' }); + expect(config.replayStore).toBeUndefined(); + expect(() => createX402ExactAdapter(config)).not.toThrow(); + expect(() => new X402Upto(config)).not.toThrow(); + }); + + it('fails closed off localnet without a store or the opt-in (exact + upto)', async () => { + delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + const config = await configure({ accept: ['x402'], network: 'solana_devnet' }); + expect(config.replayStore).toBeUndefined(); + expect(() => createX402ExactAdapter(config)).toThrow(/atomic reserve capability/); + expect(() => new X402Upto(config)).toThrow(/atomic reserve capability/); + }); + + it('permits an in-memory store off localnet under the explicit opt-in', async () => { + process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; + try { + const config = await configure({ accept: ['x402'], network: 'solana_devnet' }); + expect(() => createX402ExactAdapter(config)).not.toThrow(); + expect(() => new X402Upto(config)).not.toThrow(); + } finally { + delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + } + }); +}); diff --git a/typescript/packages/pay-kit/src/adapters/x402-shared.ts b/typescript/packages/pay-kit/src/adapters/x402-shared.ts index 83855117a..463707042 100644 --- a/typescript/packages/pay-kit/src/adapters/x402-shared.ts +++ b/typescript/packages/pay-kit/src/adapters/x402-shared.ts @@ -1,3 +1,50 @@ +import { createSolanaRpc } from '@solana/kit'; + +import type { PayKitConfig } from '../config.js'; +import { ConfigurationError, InvalidProofError } from '../errors.js'; +import { + atomicReplayStoreView, + createMemoryReplayStore, + isAtomicReplayStore, + isReservingReplayStore, + type ReservingReplayStore, +} from '../replay-store.js'; + +export { isReservingReplayStore, type ReservingReplayStore } from '../replay-store.js'; + +/** Env opt-in that permits a process-local in-memory replay store off localnet. */ +const ALLOW_INMEMORY_REPLAY_STORE_ENV = 'PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'; + +/** + * Resolve the reserving replay store an x402 adapter fences settlement against. + * + * A caller-supplied store wins (it must expose the atomic `reserve` capability). + * With none supplied, localnet (or the explicit off-localnet opt-in) gets a + * process-local in-memory store so a dev boot needs no wiring, mirroring how + * the MPP session adapter provisions its store. Off localnet without the opt-in + * this fails closed: no silent memory store on devnet/mainnet. + */ +export function resolveX402ReplayStore(config: PayKitConfig, scheme: string): ReservingReplayStore { + const store = config.replayStore; + if (store !== undefined) { + if (isReservingReplayStore(store)) { + return store; + } + // A store built for MPP's atomic `putIfAbsent` contract (e.g. the shared + // process-local store a dual x402+mpp config provisions from the unsafe + // opt-in) is upgraded to the reserve surface via the atomic view, so both + // protocols fence against the same underlying consumed-signature markers. + if (isAtomicReplayStore(store)) { + return atomicReplayStoreView(store); + } + throw new ConfigurationError(`x402 ${scheme} requires a replayStore with atomic reserve capability.`); + } + if (config.network === 'solana_localnet' || process.env[ALLOW_INMEMORY_REPLAY_STORE_ENV] === '1') { + return createMemoryReplayStore(); + } + throw new ConfigurationError(`x402 ${scheme} requires a replayStore with atomic reserve capability.`); +} + /** The x402 payment credential header, read from either accepted name. */ export function x402PaymentHeader(request: Request): string | undefined { return request.headers.get('x-payment') ?? request.headers.get('payment-signature') ?? undefined; @@ -7,3 +54,61 @@ export function x402PaymentHeader(request: Request): string | undefined { export function errorMessage(error: unknown): string | undefined { return error instanceof Error ? error.message : undefined; } + +/** Maximum UTF-8 byte length accepted for an x402 credential header. */ +export const MAX_PAYMENT_SIGNATURE_HEADER_LEN = 16 * 1024; + +/** Reject oversized credentials before base64 or JSON decoding. */ +export function assertPaymentHeaderWithinCap(header: string): void { + if (Buffer.byteLength(header, 'utf8') > MAX_PAYMENT_SIGNATURE_HEADER_LEN) { + throw new InvalidProofError( + 'x402_payment_header_too_large', + `payment header exceeds maximum size of ${MAX_PAYMENT_SIGNATURE_HEADER_LEN} bytes`, + ); + } +} + +export type CachedBlockhash = { + readonly blockhash: string; + readonly fetchedAtMs: number; + readonly lastValidBlockHeight: string; + readonly recentSlot: string; +}; + +/** Keep challenge blockhashes fresh while collapsing unauthenticated RPC bursts. */ +export const CHALLENGE_BLOCKHASH_TTL_MS = 5_000; + +/** Short-TTL, single-flight blockhash cache shared by the exact and upto adapters. */ +export class ChallengeBlockhashCache { + readonly #cache = new Map(); + readonly #inFlight = new Map>(); + + async recentBlockhash(rpcUrl: string): Promise { + const cached = this.#cache.get(rpcUrl); + if (cached !== undefined && Date.now() - cached.fetchedAtMs < CHALLENGE_BLOCKHASH_TTL_MS) { + return cached; + } + const pending = this.#inFlight.get(rpcUrl); + if (pending !== undefined) return await pending; + + const fetchPromise = (async (): Promise => { + try { + const { context, value } = await createSolanaRpc(rpcUrl).getLatestBlockhash().send(); + const entry: CachedBlockhash = { + blockhash: value.blockhash, + fetchedAtMs: Date.now(), + lastValidBlockHeight: value.lastValidBlockHeight.toString(), + recentSlot: context.slot.toString(), + }; + this.#cache.set(rpcUrl, entry); + return entry; + } catch { + return undefined; + } finally { + this.#inFlight.delete(rpcUrl); + } + })(); + this.#inFlight.set(rpcUrl, fetchPromise); + return await fetchPromise; + } +} diff --git a/typescript/packages/pay-kit/src/adapters/x402-upto.ts b/typescript/packages/pay-kit/src/adapters/x402-upto.ts index 195280ba3..686bada4e 100644 --- a/typescript/packages/pay-kit/src/adapters/x402-upto.ts +++ b/typescript/packages/pay-kit/src/adapters/x402-upto.ts @@ -20,7 +20,13 @@ import { assertReplayStorePolicy, type PayKitConfig } from '../config.js'; import { InvalidProofError } from '../errors.js'; import type { Price } from '../price.js'; import { caip2 } from '../protocol.js'; -import { errorMessage, x402PaymentHeader } from './x402-shared.js'; +import { + assertPaymentHeaderWithinCap, + ChallengeBlockhashCache, + errorMessage, + resolveX402ReplayStore, + x402PaymentHeader, +} from './x402-shared.js'; /** Settlement-response header mirrored by the x402 SDK family. */ const PAYMENT_RESPONSE_HEADER = 'x-payment-response'; @@ -29,7 +35,16 @@ const PAYMENT_REQUIRED_HEADER = 'payment-required'; const X402_VERSION = 2; const MAX_TIMEOUT_SECONDS = 300; const DEFAULT_WITHDRAW_DELAY_SECONDS = 900; +/** + * Hard ceiling on a replay reservation's TTL. `expiresAt` is payer-signed and + * unbounded above; without a cap a far-future value would mint effectively + * permanent route/consumed keys. 24h comfortably covers any real open-to-settle + * window while keeping stale keys self-expiring. + */ +const MAX_RESERVATION_TTL_SECONDS = 24 * 60 * 60; const BASIS_POINTS_DENOMINATOR = 10_000; +const CONSUMED_PREFIX = 'x402-svm-upto:consumed:'; +const ROUTE_PREFIX = 'x402-svm-upto:route:'; /** * Usage meter handed to a usage-gated handler. The handler reports the actual @@ -104,7 +119,9 @@ export class X402Upto { readonly #signer: PayKitConfig['operator']['signer']; readonly #recipient: string; readonly #rpcUrl: string; + readonly #replayStore: import('../replay-store.js').ReservingReplayStore; readonly #stablecoins: readonly string[]; + readonly #blockhashCache = new ChallengeBlockhashCache(); constructor(config: PayKitConfig) { assertReplayStorePolicy(config); @@ -114,6 +131,7 @@ export class X402Upto { this.#signer = config.operator.signer; this.#recipient = config.operator.recipient; this.#rpcUrl = config.rpcUrl; + this.#replayStore = resolveX402ReplayStore(config, 'upto'); this.#stablecoins = config.stablecoins; this.#facilitator = new x402Facilitator().register( this.#network, @@ -151,7 +169,8 @@ export class X402Upto { * requirement (`extra.recentBlockhash` + `extra.recentSlot`) the header * carries, so body-based `upto` clients can build the channel open too. */ - async accepts(maxPrice: Price): Promise { + async accepts(maxPrice: Price, request?: Request): Promise { + void request; return [await this.#challengeRequirements(maxPrice)]; } @@ -164,6 +183,7 @@ export class X402Upto { async verifyOpen(request: Request, maxPrice: Price): Promise { const header = x402PaymentHeader(request); if (!header) throw new InvalidProofError('missing_x402_payment_header'); + assertPaymentHeaderWithinCap(header); let payload: PaymentPayload; try { @@ -177,7 +197,21 @@ export class X402Upto { if (!verification.isValid) { throw new InvalidProofError(verification.invalidReason ?? 'invalid_proof', verification.invalidMessage); } - return { maxBaseUnits: BigInt(requirements.amount), payer: verification.payer ?? '', payload, requirements }; + const channel = parseUptoPayload(payload); + const remaining = channel.expiresAt - Math.floor(Date.now() / 1000); + const ttlSeconds = Math.min(Math.max(MAX_TIMEOUT_SECONDS, remaining), MAX_RESERVATION_TTL_SECONDS); + await this.#bindChannelRoute(channel.channelId, request, ttlSeconds); + const channelId = channel.channelId; + const replayKey = `${CONSUMED_PREFIX}${channelId}`; + if (!(await this.#replayStore.reserve(replayKey, true, ttlSeconds))) { + throw new InvalidProofError('upto_channel_replayed', 'channel already used or in flight'); + } + return { + maxBaseUnits: BigInt(requirements.amount), + payer: verification.payer ?? '', + payload, + requirements, + }; } /** @@ -262,6 +296,35 @@ export class X402Upto { return getBase58Decoder().decode(signature); } + /** + * Bind a verified channel's replay scope to the first route that accepts it. + * + * The x402 `upto` wire commits the channel ID in the payer-signed open + * transaction, so this reservation is channel-specific and does not block + * unrelated routes. It does not prove which HTTP route issued the + * challenge: the upstream `upto` transaction has no resource-path field. + * A route-specific cryptographic guarantee therefore requires a protocol + * extension; this is only a fail-closed same-channel replay binding. + * + * `reserve` and the fallback `get` are separate store operations because + * the public replay-store contract has no compare-and-swap. At the TTL + * boundary another caller may replace the value between them; that can + * only produce a mismatch rejection here, never acceptance for a different + * route. A future store contract can collapse this to atomic bind-or-verify. + */ + async #bindChannelRoute(channelId: string, request: Request, ttlSeconds: number): Promise { + const route = new URL(request.url).pathname; + const key = `${ROUTE_PREFIX}${this.#network}:${this.#recipient}:${channelId}`; + if (await this.#replayStore.reserve(key, route, ttlSeconds)) return; + const existing = await this.#replayStore.get(key); + if (existing !== route) { + throw new InvalidProofError( + 'upto_route_mismatch', + `x402 upto is already bound to route ${String(existing)}`, + ); + } + } + #requirements(maxPrice: Price): PaymentRequirements { const coin = resolveCoin(maxPrice, this.#stablecoins); const mint = requireMint(coin, resolveStablecoinMint(coin, this.#network), this.#network); @@ -297,21 +360,17 @@ export class X402Upto { */ async #challengeRequirements(maxPrice: Price): Promise { const base = this.#requirements(maxPrice); - let context, value; - try { - ({ context, value } = await createSolanaRpc(this.#rpcUrl).getLatestBlockhash().send()); - } catch (error) { - throw new Error( - `x402 upto challenge requires extra.recentBlockhash/recentSlot; getLatestBlockhash failed: ${errorMessage(error)}`, - ); + const cached = await this.#blockhashCache.recentBlockhash(this.#rpcUrl); + if (cached === undefined) { + throw new Error('x402 upto challenge requires extra.recentBlockhash/recentSlot; getLatestBlockhash failed'); } return { ...base, extra: { ...base.extra, - lastValidBlockHeight: value.lastValidBlockHeight.toString(), - recentBlockhash: value.blockhash, - recentSlot: context.slot.toString(), + lastValidBlockHeight: cached.lastValidBlockHeight, + recentBlockhash: cached.blockhash, + recentSlot: cached.recentSlot, }, }; } @@ -329,7 +388,9 @@ function parseUptoPayload(payload: PaymentPayload): UptoPaymentChannelPayload { !raw || typeof raw.channelId !== 'string' || typeof raw.from !== 'string' || - typeof raw.expiresAt !== 'number' + typeof raw.expiresAt !== 'number' || + !Number.isSafeInteger(raw.expiresAt) || + raw.expiresAt <= 0 ) { throw new InvalidProofError('invalid_upto_payload'); } diff --git a/typescript/packages/pay-kit/src/adapters/x402.ts b/typescript/packages/pay-kit/src/adapters/x402.ts index d5d9fa7e6..85018c66d 100644 --- a/typescript/packages/pay-kit/src/adapters/x402.ts +++ b/typescript/packages/pay-kit/src/adapters/x402.ts @@ -1,4 +1,6 @@ -import { createSolanaRpc } from '@solana/kit'; +import { createHash } from 'node:crypto'; + +import { getBase64Codec, getTransactionDecoder } from '@solana/kit'; import { x402Facilitator } from '@x402/core/facilitator'; import { decodePaymentSignatureHeader, @@ -17,7 +19,13 @@ import { InvalidProofError } from '../errors.js'; import type { Gate } from '../gate.js'; import type { Payment } from '../payment.js'; import { caip2 } from '../protocol.js'; -import { errorMessage, x402PaymentHeader } from './x402-shared.js'; +import { + assertPaymentHeaderWithinCap, + ChallengeBlockhashCache, + errorMessage, + resolveX402ReplayStore, + x402PaymentHeader, +} from './x402-shared.js'; /** x402 v2 protocol version advertised in the challenge envelope. */ const X402_VERSION = 2; @@ -27,6 +35,24 @@ const MAX_TIMEOUT_SECONDS = 300; const PAYMENT_RESPONSE_HEADER = 'x-payment-response'; /** 402 challenge header read by x402 clients (alongside the JSON body). */ const PAYMENT_REQUIRED_HEADER = 'payment-required'; +const CONSUMED_PREFIX = 'x402-svm-exact:consumed:'; + +const RELEASE_SAFE_SETTLE_REASONS: ReadonlySet = new Set([ + 'verification_failed', + 'unsupported_scheme', + 'network_mismatch', + 'fee_payer_not_managed_by_facilitator', + 'invalid_exact_svm_payload_missing_fee_payer', + 'invalid_exact_svm_payload_transaction_could_not_be_decoded', + 'invalid_exact_svm_payload_transaction_instructions_length', + 'invalid_exact_svm_payload_no_transfer_instruction', + 'invalid_exact_svm_payload_mint_mismatch', + 'invalid_exact_svm_payload_recipient_mismatch', + 'invalid_exact_svm_payload_amount_mismatch', + 'invalid_exact_svm_payload_memo_count', + 'invalid_exact_svm_payload_memo_mismatch', + 'invalid_exact_svm_payload_transaction_fee_payer_transferring_funds', +]); /** * The x402 `exact` protocol adapter: wraps `@x402/svm`'s exact scheme behind @@ -40,6 +66,7 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { assertReplayStorePolicy(config); const network = caip2(config.network) as Network; const operator = config.operator.signer.pubkey; + const blockhashCache = new ChallengeBlockhashCache(); // In-process facilitator: the operator both fee-pays and signs settlement. const facilitator = new x402Facilitator().register( @@ -48,6 +75,37 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { toFacilitatorSvmSigner(config.operator.signer.signer, { defaultRpcUrl: config.rpcUrl }), ), ); + const reserveStore = resolveX402ReplayStore(config, 'exact'); + + async function claimPayload(key: string): Promise { + return await reserveStore.reserve(`${CONSUMED_PREFIX}${key}`, true, MAX_TIMEOUT_SECONDS); + } + + async function releasePayload(key: string): Promise { + await reserveStore.delete(`${CONSUMED_PREFIX}${key}`); + } + + function payloadKey(header: string, payload: PaymentPayload, requirements: PaymentRequirements): string { + const transaction = (payload.payload as { transaction?: unknown } | undefined)?.transaction; + if (typeof transaction !== 'string' || transaction === '') return `header:${header}`; + try { + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(transaction)); + const identity = JSON.stringify({ + amount: requirements.amount, + asset: requirements.asset, + extra: requirements.extra, + maxTimeoutSeconds: requirements.maxTimeoutSeconds, + message: getBase64Codec().decode(decoded.messageBytes), + network: requirements.network, + payTo: requirements.payTo, + scheme: requirements.scheme, + }); + return `msg:${createHash('sha256').update(identity).digest('hex')}`; + } catch { + // Verification remains authoritative; malformed transactions fall back to raw bytes. + } + return `tx:${transaction}`; + } function mintFor(gate: Gate): string { const coin = resolveCoin(gate.amount, config.stablecoins); @@ -55,11 +113,11 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { } /** The route's pinned requirements — the credential is bound to this exact amount. */ - function requirementsFor(gate: Gate): PaymentRequirements { + function requirementsFor(gate: Gate, request: Request): PaymentRequirements { return { amount: gate.total().baseUnits().toString(), asset: mintFor(gate), - extra: { feePayer: operator }, + extra: { feePayer: operator, memo: new URL(request.url).pathname }, maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, network, payTo: gate.payTo, @@ -73,32 +131,29 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { * round-trip (mirroring MPP's `recentBlockhash`). Falls back to the bare * requirements if the fetch fails (the client then fetches its own). */ - async function challengeRequirements(gate: Gate): Promise { - const base = requirementsFor(gate); - try { - const { value } = await createSolanaRpc(config.rpcUrl).getLatestBlockhash().send(); - return { - ...base, - extra: { - ...base.extra, - lastValidBlockHeight: value.lastValidBlockHeight.toString(), - recentBlockhash: value.blockhash, - }, - }; - } catch { - return base; - } + async function challengeRequirements(gate: Gate, request: Request): Promise { + const base = requirementsFor(gate, request); + const cached = await blockhashCache.recentBlockhash(config.rpcUrl); + if (cached === undefined) return base; + return { + ...base, + extra: { + ...base.extra, + lastValidBlockHeight: cached.lastValidBlockHeight, + recentBlockhash: cached.blockhash, + }, + }; } return { - acceptsEntry(gate: Gate): Promise { - const requirements = requirementsFor(gate); + acceptsEntry(gate: Gate, request: Request): Promise { + const requirements = requirementsFor(gate, request); return Promise.resolve({ ...requirements, protocol: 'x402' }); }, async challengeHeaders(gate: Gate, request: Request): Promise>> { const paymentRequired: PaymentRequired = { - accepts: [await challengeRequirements(gate)], + accepts: [await challengeRequirements(gate, request)], resource: { url: new URL(request.url).pathname }, x402Version: X402_VERSION, }; @@ -115,6 +170,7 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { async verifyAndSettle(gate: Gate, request: Request): Promise { const header = x402PaymentHeader(request); if (!header) throw new InvalidProofError('missing_x402_payment_header'); + assertPaymentHeaderWithinCap(header); let payload: PaymentPayload; try { @@ -123,15 +179,30 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { throw new InvalidProofError('invalid_x402_payment_header', errorMessage(error)); } - const requirements = requirementsFor(gate); + const requirements = requirementsFor(gate, request); const verification = await facilitator.verify(payload, requirements); if (!verification.isValid) { throw new InvalidProofError(verification.invalidReason ?? 'invalid_proof', verification.invalidMessage); } - const settlement = await facilitator.settle(payload, requirements); + const key = payloadKey(header, payload, requirements); + if (!(await claimPayload(key))) { + throw new InvalidProofError('x402_payment_replayed', 'payment payload already used or in flight'); + } + + let settlement: Awaited>; + try { + settlement = await facilitator.settle(payload, requirements); + } catch (error) { + // A facilitator can throw after broadcast (for example while + // awaiting confirmation), so its side-effect boundary is + // ambiguous. Preserve the reservation and fail closed. + throw new InvalidProofError('settlement_failed', errorMessage(error)); + } if (!settlement.success) { - throw new InvalidProofError(settlement.errorReason ?? 'settlement_failed', settlement.errorMessage); + const reason = settlement.errorReason ?? 'settlement_failed'; + if (RELEASE_SAFE_SETTLE_REASONS.has(reason)) await releasePayload(key); + throw new InvalidProofError(reason, settlement.errorMessage); } return { diff --git a/typescript/packages/pay-kit/src/index.ts b/typescript/packages/pay-kit/src/index.ts index 788a1a9ca..6230f20eb 100644 --- a/typescript/packages/pay-kit/src/index.ts +++ b/typescript/packages/pay-kit/src/index.ts @@ -91,5 +91,6 @@ export { } from './pricing.js'; export { caip2, type Network, type NetworkSlug, toNetwork, type Protocol, toSolanaNetwork } from './protocol.js'; export { type KeychainSigner, type PayKitSigner, Signer } from './signer.js'; +export { createMemoryReplayStore, isReservingReplayStore, type ReservingReplayStore } from './replay-store.js'; // Replay-protection stores (memory, Redis, Upstash, Cloudflare KV) come from mppx. export { Store } from 'mppx'; diff --git a/typescript/packages/pay-kit/src/replay-store.ts b/typescript/packages/pay-kit/src/replay-store.ts index d8138ba6c..549b4f57f 100644 --- a/typescript/packages/pay-kit/src/replay-store.ts +++ b/typescript/packages/pay-kit/src/replay-store.ts @@ -79,3 +79,60 @@ export function atomicReplayStoreView( reserve(key: string, value?: unknown, ttlSeconds?: number): Promise; }; } + +/** Replay store capability required by x402's reserve-before-settle lifecycle. */ +export interface ReservingReplayStore extends Store.Store { + /** Alternative capability name for a durable shared backend. */ + readonly isDurable?: boolean; + /** Explicitly affirm that reservations survive restarts and span replicas. */ + readonly isShared?: boolean; + reserve(key: string, value?: unknown, ttlSeconds?: number): Promise; +} + +/** Whether a store provides an atomic reserve operation. */ +export function isReservingReplayStore(store: Store.Store): store is ReservingReplayStore { + return typeof (store as Partial).reserve === 'function'; +} + +/** + * Process-local atomic replay store for local development and explicit insecure + * off-localnet opt-in. Share the returned instance between handlers when they + * run in one process; production replicas should inject a durable implementation + * whose `reserve` maps to a datastore-native compare-and-set (for example SET NX). + */ +export function createMemoryReplayStore(): ReservingReplayStore { + const entries = new Map(); + + function live(key: string): { expiresAt?: number; value: unknown } | undefined { + const entry = entries.get(key); + if (entry?.expiresAt !== undefined && entry.expiresAt <= Date.now()) { + entries.delete(key); + return undefined; + } + return entry; + } + + return { + delete(key: string): Promise { + entries.delete(key); + return Promise.resolve(); + }, + get(key: string) { + return Promise.resolve(live(key)?.value ?? null); + }, + isDurable: false, + isShared: false, + put(key: string, value: unknown): Promise { + entries.set(key, { value }); + return Promise.resolve(); + }, + reserve(key: string, value: unknown = true, ttlSeconds?: number): Promise { + if (live(key) !== undefined) return Promise.resolve(false); + entries.set(key, { + expiresAt: ttlSeconds === undefined ? undefined : Date.now() + ttlSeconds * 1000, + value, + }); + return Promise.resolve(true); + }, + }; +}