diff --git a/go/Justfile b/go/Justfile index 66fb7dd18..245bed382 100644 --- a/go/Justfile +++ b/go/Justfile @@ -41,5 +41,5 @@ check: build lint audit test-cover # Boot the dual-protocol example server serve-example port="4567": - go run ./examples/paykit-server + go run ./examples/simple-server diff --git a/go/protocols/mpp/internal_test.go b/go/protocols/mpp/internal_test.go index b625fcc3c..df8c74729 100644 --- a/go/protocols/mpp/internal_test.go +++ b/go/protocols/mpp/internal_test.go @@ -1,6 +1,8 @@ package mpp import ( + "context" + "fmt" "testing" solana "github.com/gagliardetto/solana-go" @@ -9,6 +11,23 @@ import ( "github.com/solana-foundation/pay-kit/go/signer" ) +// errSigner is a paykit.Signer stub whose Sign method always returns the given +// error, exercising the signerBridge.Sign error propagation branch. +type errSigner struct { + pubkey string + err error + raw []byte // when non-nil, Sign returns this slice without error +} + +func (e *errSigner) Pubkey() paykit.Address { return paykit.Address(e.pubkey) } +func (e *errSigner) IsDemo() bool { return false } +func (e *errSigner) Sign(_ context.Context, _ []byte) ([]byte, error) { + if e.err != nil { + return nil, e.err + } + return e.raw, nil +} + func testCfg() paykit.Config { demo := signer.Demo() return paykit.Config{ @@ -165,6 +184,111 @@ func TestVerifyAndSettleRejectsGarbageCredential(t *testing.T) { } } +// TestSignerBridgeSignPropagatesSignerError proves that when the wrapped +// paykit.Signer.Sign returns an error, signerBridge.Sign surfaces it wrapped +// with the "signerBridge:" prefix. +func TestSignerBridgeSignPropagatesSignerError(t *testing.T) { + demo := signer.Demo() + bad := &errSigner{ + pubkey: string(demo.Pubkey()), + err: fmt.Errorf("KMS unavailable"), + } + b := &signerBridge{signer: bad} + _, err := b.Sign([]byte("hello")) + if err == nil { + t.Fatal("expected an error from failing inner signer") + } + if err.Error() == "" { + t.Fatal("expected non-empty error message") + } +} + +// TestSignerBridgeSignRejectsWrongLength proves that when the inner signer +// returns a raw byte slice that is not exactly 64 bytes, signerBridge.Sign +// returns an error rather than copying a truncated or oversized value into a +// solana.Signature. +func TestSignerBridgeSignRejectsWrongLength(t *testing.T) { + demo := signer.Demo() + short := &errSigner{ + pubkey: string(demo.Pubkey()), + raw: make([]byte, 32), // 32 bytes — not 64 + } + b := &signerBridge{signer: short} + _, err := b.Sign([]byte("hello")) + if err == nil { + t.Fatal("expected an error for a 32-byte (non-64) signature") + } +} + +// TestChallengeHeadersReturnsNilOnBadRecipient proves that ChallengeHeaders +// returns nil (rather than panicking) when the gate's PayTo address is not a +// valid Solana pubkey, which causes serverFor -> server.New to fail. +func TestChallengeHeadersReturnsNilOnBadRecipient(t *testing.T) { + a := &Adapter{cfg: testCfg()} + gate := &paykit.Gate{ + Amount: paykit.MustParseUSD("0.10"), + PayTo: paykit.Address("!!!not-a-valid-pubkey"), + } + if headers := a.ChallengeHeaders(gate); headers != nil { + t.Errorf("expected nil for ChallengeHeaders with bad recipient, got %v", headers) + } +} + +// TestVerifyAndSettleReturnsErrOnBadRecipient proves VerifyAndSettle wraps +// the serverFor failure in a PaymentError (code="invalid_proof") when the +// gate's PayTo address is not a valid Solana pubkey. +func TestVerifyAndSettleReturnsErrOnBadRecipient(t *testing.T) { + a := &Adapter{cfg: testCfg()} + gate := &paykit.Gate{ + Amount: paykit.MustParseUSD("0.10"), + PayTo: paykit.Address("!!!not-a-valid-pubkey"), + } + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{ + Gate: gate, + Authorization: "Payment bm90LWEtY3JlZGVudGlhbA==", + }) + if err == nil { + t.Fatal("expected an error for a gate with an invalid recipient") + } +} + +// TestChargeOptionsIncludesFeeWithinAndFeeOnTopSplits proves that chargeOptions +// appends paycore.Split entries for both FeeWithin and FeeOnTop fees declared +// on the gate. This exercises the two range-loop bodies in chargeOptions that +// the existing AcceptsEntry tests do not reach. +func TestChargeOptionsIncludesFeeWithinAndFeeOnTopSplits(t *testing.T) { + a := &Adapter{cfg: testCfg()} + gate := &paykit.Gate{ + Amount: paykit.MustParseUSD("10.00"), + FeeWithin: paykit.Fees{paykit.Address("PLATFORM"): paykit.MustParseUSD("0.30")}, + FeeOnTop: paykit.Fees{paykit.Address("GATEWAY"): paykit.MustParseUSD("0.50")}, + } + opts := a.chargeOptions(gate) + if len(opts.Splits) != 2 { + t.Fatalf("expected 2 splits in chargeOptions, got %d: %+v", len(opts.Splits), opts.Splits) + } + recipients := make(map[string]bool) + for _, s := range opts.Splits { + recipients[s.Recipient] = true + } + if !recipients["PLATFORM"] || !recipients["GATEWAY"] { + t.Errorf("chargeOptions splits missing expected recipients: %v", opts.Splits) + } +} + +// TestPriceCoinUsesExplicitSettlementWhenPresent proves that priceCoin returns +// the first explicit settlement stablecoin rather than falling back to the +// adapter config when the Price was built with an explicit settlement. +func TestPriceCoinUsesExplicitSettlementWhenPresent(t *testing.T) { + a := &Adapter{cfg: testCfg()} // cfg.Stablecoins = [USDC] + // Build a price with an explicit USDT settlement; priceCoin must return + // USDT (the explicit settlement) rather than USDC (the config default). + priceUSDT := paykit.MustParseUSD("0.30", paykit.USDT) + if got := a.priceCoin(priceUSDT); got != "USDT" { + t.Errorf("priceCoin: got %q want USDT", got) + } +} + func TestVerifyAndSettleReachesCredentialVerification(t *testing.T) { cfg := testCfg() cfg.RPCURL = "http://127.0.0.1:1" // unreachable; charge build tolerates it diff --git a/go/protocols/mpp/server/parity_test.go b/go/protocols/mpp/server/parity_test.go new file mode 100644 index 000000000..8f536370a --- /dev/null +++ b/go/protocols/mpp/server/parity_test.go @@ -0,0 +1,285 @@ +package server + +import ( + "context" + "errors" + "strings" + "testing" + + solana "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gagliardetto/solana-go/programs/token" + + "github.com/solana-foundation/pay-kit/go/internal/testutil" + "github.com/solana-foundation/pay-kit/go/paycore" + "github.com/solana-foundation/pay-kit/go/paycore/solanatx" + core "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" + "github.com/solana-foundation/pay-kit/go/protocols/mpp/intents" +) + +// errTxNotFound forces the on-chain fetch to fail in push-mode tests. +var errTxNotFound = errors.New("transaction not found") + +// boolp is a small helper for *bool challenge fields. +func boolp(b bool) *bool { return &b } + +// u8p is a small helper for *uint8 challenge fields. +func u8p(v uint8) *uint8 { return &v } + +// buildSPLTransferWithAuthority builds a transferChecked whose authority, +// source ATA, and decimals byte can each be controlled, so the fee-payer +// and decimals guards can be exercised independently. Matches the rust +// account layout [source, mint, destination, authority]. +func buildSPLTransferWithAuthority(t *testing.T, authority, source, recipient, mint solana.PublicKey, amount uint64, decimals uint8) solana.Instruction { + t.Helper() + recipientATA, err := solanatx.FindAssociatedTokenAddressWithProgram(recipient, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("recipient ata: %v", err) + } + ix, err := token.NewTransferCheckedInstruction(amount, decimals, source, mint, recipientATA, authority, nil).ValidateAndBuild() + if err != nil { + t.Fatalf("build transfer: %v", err) + } + return ix +} + +// Finding #15: the configured fee payer must not authorize the SPL +// payment transfer (rust charge.rs:1642-1647). Hard reject. +func TestSPLRejectsFeePayerAsAuthority(t *testing.T) { + feePayer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + source, _ := solanatx.FindAssociatedTokenAddressWithProgram(testutil.NewPrivateKey().PublicKey(), mint, solana.TokenProgramID) + transfer := buildSPLTransferWithAuthority(t, feePayer.PublicKey(), source, recipient, mint, 1000, 6) + tx := newTestTransaction(t, feePayer, transfer) + + details := paycore.MethodDetails{FeePayer: boolp(true), FeePayerKey: feePayer.PublicKey().String(), Decimals: u8p(6)} + err := verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", details) + if err == nil || !strings.Contains(err.Error(), "fee payer cannot authorize") { + t.Fatalf("expected fee-payer-authority rejection, got %v", err) + } +} + +// Finding #15: the configured fee payer's token account must not fund +// the SPL payment transfer (rust charge.rs:1649-1657). Hard reject. +func TestSPLRejectsFeePayerATAAsSource(t *testing.T) { + feePayer := testutil.NewPrivateKey() + authority := testutil.NewPrivateKey().PublicKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + feePayerATA, _ := solanatx.FindAssociatedTokenAddressWithProgram(feePayer.PublicKey(), mint, solana.TokenProgramID) + transfer := buildSPLTransferWithAuthority(t, authority, feePayerATA, recipient, mint, 1000, 6) + tx := newTestTransaction(t, feePayer, transfer) + + details := paycore.MethodDetails{FeePayer: boolp(true), FeePayerKey: feePayer.PublicKey().String(), Decimals: u8p(6)} + err := verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", details) + if err == nil || !strings.Contains(err.Error(), "fee payer token account cannot fund") { + t.Fatalf("expected fee-payer-source rejection, got %v", err) + } +} + +// Finding #17: the transferChecked decimals byte must match the +// challenge-pinned decimals (rust charge.rs:1623-1624). A transfer with +// the wrong decimals byte does not match. +func TestSPLRejectsDecimalsByteMismatch(t *testing.T) { + payer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + source, _ := solanatx.FindAssociatedTokenAddressWithProgram(payer.PublicKey(), mint, solana.TokenProgramID) + // Wrong decimals byte (9) versus the pinned 6. + transfer := buildSPLTransferWithAuthority(t, payer.PublicKey(), source, recipient, mint, 1000, 9) + tx := newTestTransaction(t, payer, transfer) + + details := paycore.MethodDetails{Decimals: u8p(6)} + err := verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", details) + if err == nil || !strings.Contains(err.Error(), "no matching token transfer") { + t.Fatalf("expected decimals-mismatch rejection, got %v", err) + } +} + +// Finding #17 (positive): a matching decimals byte still verifies. +func TestSPLAcceptsMatchingDecimalsByte(t *testing.T) { + payer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + source, _ := solanatx.FindAssociatedTokenAddressWithProgram(payer.PublicKey(), mint, solana.TokenProgramID) + transfer := buildSPLTransferWithAuthority(t, payer.PublicKey(), source, recipient, mint, 1000, 6) + tx := newTestTransaction(t, payer, transfer) + + details := paycore.MethodDetails{Decimals: u8p(6)} + if err := verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", details); err != nil { + t.Fatalf("expected matching-decimals transfer to pass, got %v", err) + } +} + +// Finding #16: the configured fee payer must not fund the SOL payment +// transfer (rust charge.rs:1525-1528). Hard reject. +func TestSOLRejectsFeePayerAsSource(t *testing.T) { + feePayer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + + transfer, err := system.NewTransferInstruction(1000, feePayer.PublicKey(), recipient).ValidateAndBuild() + if err != nil { + t.Fatalf("build sol transfer: %v", err) + } + tx := newTestTransaction(t, feePayer, transfer) + + details := paycore.MethodDetails{FeePayer: boolp(true), FeePayerKey: feePayer.PublicKey().String()} + verr := verifyTransfersAgainstChallenge(tx, 1000, "sol", recipient, "", details) + if verr == nil || !strings.Contains(verr.Error(), "fee payer cannot fund the SOL") { + t.Fatalf("expected SOL fee-payer-source rejection, got %v", verr) + } +} + +// Finding #18 (verify side): when a split required an ATA-create but the +// currency is a stablecoin symbol rather than a raw mint address, the +// verifier rejects (rust charge.rs:1120-1124). +func TestVerifyRejectsATARequiredWithSymbolCurrency(t *testing.T) { + payer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + splitRecipient := testutil.NewPrivateKey().PublicKey() + + mint := solana.MustPublicKeyFromBase58(paycore.USDCMainnetMint) + transfer := buildSPLTransferIx(t, payer.PublicKey(), recipient, mint, 1000) + tx := newTestTransaction(t, payer, transfer) + + details := paycore.MethodDetails{ + Network: "mainnet-beta", + Decimals: u8p(6), + Splits: []paycore.Split{ + {Recipient: splitRecipient.String(), Amount: "1", AtaCreationRequired: boolp(true)}, + }, + } + // Currency is the symbol "USDC", not the mint address: must reject. + err := verifyTransfersAgainstChallenge(tx, 1000, "USDC", recipient, "", details) + if err == nil || !strings.Contains(err.Error(), "SPL token mint address") { + t.Fatalf("expected symbol-currency rejection, got %v", err) + } +} + +// Finding #18 (issuance side): ChargeWithOptions rejects an +// ataCreationRequired split when the configured currency is SOL. +func TestChargeRejectsATARequiredOnSOL(t *testing.T) { + m := &Mpp{currency: "SOL", network: "mainnet-beta"} + err := m.validateChargeOptions(ChargeOptions{ + Splits: []paycore.Split{{Recipient: "x", Amount: "1", AtaCreationRequired: boolp(true)}}, + }) + if err == nil || !strings.Contains(err.Error(), "SPL token currency") { + t.Fatalf("expected SOL ataCreationRequired rejection, got %v", err) + } +} + +// Finding #18 (issuance side): ChargeWithOptions rejects an +// ataCreationRequired split when the currency is a stablecoin symbol. +func TestChargeRejectsATARequiredOnSymbolCurrency(t *testing.T) { + m := &Mpp{currency: "USDC", network: "mainnet-beta"} + err := m.validateChargeOptions(ChargeOptions{ + Splits: []paycore.Split{{Recipient: "x", Amount: "1", AtaCreationRequired: boolp(true)}}, + }) + if err == nil || !strings.Contains(err.Error(), "SPL token mint address") { + t.Fatalf("expected symbol ataCreationRequired rejection, got %v", err) + } +} + +// Finding #18 (issuance side, positive): a raw mint-address currency is +// accepted for ataCreationRequired splits. +func TestChargeAcceptsATARequiredOnMintCurrency(t *testing.T) { + m := &Mpp{currency: paycore.USDCMainnetMint, network: "mainnet-beta"} + err := m.validateChargeOptions(ChargeOptions{ + Splits: []paycore.Split{{Recipient: testutil.NewPrivateKey().PublicKey().String(), Amount: "1", AtaCreationRequired: boolp(true)}}, + }) + if err != nil { + t.Fatalf("expected mint-address ataCreationRequired to pass, got %v", err) + } +} + +// errCode extracts the *core.Error code for assertions. +func errCode(t *testing.T, err error) core.ErrorCode { + t.Helper() + sdkErr, ok := err.(*core.Error) + if !ok { + t.Fatalf("expected *core.Error, got %T (%v)", err, err) + } + return sdkErr.Code +} + +// Finding #18 (issuance side): the rejection carries the invalid-payload code. +func TestChargeATARejectionCode(t *testing.T) { + m := &Mpp{currency: "SOL", network: "mainnet-beta"} + err := m.validateChargeOptions(ChargeOptions{ + Splits: []paycore.Split{{Recipient: "x", Amount: "1", AtaCreationRequired: boolp(true)}}, + }) + if code := errCode(t, err); code != core.ErrCodeInvalidPayload { + t.Fatalf("code = %q, want %q", code, core.ErrCodeInvalidPayload) + } +} + +// Finding #19: push mode (signature credential) verifies on-chain BEFORE +// consuming the replay marker, and never burns the marker on a verify +// failure. Mirrors rust verify_push -> consume_signature +// (charge.rs:563-595). Before the fix the marker was consumed first and +// deleted on failure; after the fix a failed verify leaves the marker +// untouched, so a later legitimate settlement of the same signature can +// still proceed. +func TestPushModeVerifyBeforeConsumeLeavesMarkerOnFailure(t *testing.T) { + handler, rpc, _ := newTestMpp(t) + // Force the on-chain fetch to fail so push-mode verification errors. + rpc.GetTxErr = errTxNotFound + + sig := testutil.NewPrivateKey().PublicKey().String() // any base58 32-byte value + cred := core.PaymentCredential{Challenge: core.ChallengeEcho{ID: "challenge-1"}} + request := intents.ChargeRequest{Amount: "1000", Currency: "sol", Recipient: handler.recipient.String()} + payload := paycore.CredentialPayload{Type: "signature", Signature: sig} + + _, err := handler.verifySignature(context.Background(), cred, request, paycore.MethodDetails{}, payload) + if err == nil { + t.Fatal("expected push-mode verify to fail when the tx is not found") + } + + // The marker must NOT have been consumed: PutIfAbsent inserts, proving + // the key was absent after the failed verify. + inserted, err := handler.store.PutIfAbsent(context.Background(), consumedPrefix+sig, true) + if err != nil { + t.Fatalf("store: %v", err) + } + if !inserted { + t.Fatal("failed push-mode verify must not consume the replay marker") + } +} + +// Finding #20: a v0 transaction carrying address lookup tables is +// rejected with a structured error, matching rust +// reject_address_lookup_tables (charge.rs:1213-1225). v0 static-key +// transactions remain accepted (decoded by the underlying library). +func TestVerifyTransactionRejectsAddressLookupTables(t *testing.T) { + handler, _, _ := newTestMpp(t) + + payer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + transfer, err := system.NewTransferInstruction(1000, payer.PublicKey(), recipient).ValidateAndBuild() + if err != nil { + t.Fatalf("build transfer: %v", err) + } + tx := newTestTransaction(t, payer, transfer) + // Promote to v0 with a non-empty address lookup table. + tx.Message.SetAddressTableLookups([]solana.MessageAddressTableLookup{ + {AccountKey: testutil.NewPrivateKey().PublicKey(), WritableIndexes: []uint8{0}}, + }) + encoded, err := solanatx.EncodeTransactionBase64(tx) + if err != nil { + t.Fatalf("encode: %v", err) + } + + cred := core.PaymentCredential{Challenge: core.ChallengeEcho{ID: "challenge-1"}} + request := intents.ChargeRequest{Amount: "1000", Currency: "sol", Recipient: handler.recipient.String()} + payload := paycore.CredentialPayload{Type: "transaction", Transaction: encoded} + + _, err = handler.verifyTransaction(context.Background(), cred, request, paycore.MethodDetails{}, payload) + if err == nil || !strings.Contains(err.Error(), "address lookup tables") { + t.Fatalf("expected ALT rejection, got %v", err) + } +} diff --git a/go/protocols/mpp/server/server.go b/go/protocols/mpp/server/server.go index aa8d451bb..a274aa79a 100644 --- a/go/protocols/mpp/server/server.go +++ b/go/protocols/mpp/server/server.go @@ -140,8 +140,41 @@ func (m *Mpp) Charge(ctx context.Context, amount string) (core.PaymentChallenge, return m.ChargeWithOptions(ctx, amount, ChargeOptions{}) } +// validateChargeOptions rejects an ataCreationRequired split when the +// configured currency is SOL or is a stablecoin symbol rather than a raw +// SPL mint address, matching rust validate_charge_options +// (charge.rs:307-335). Idempotent ATA creation is only meaningful for an +// SPL token whose mint is known to the verifier. +func (m *Mpp) validateChargeOptions(options ChargeOptions) error { + hasATACreation := false + for _, split := range options.Splits { + if split.AtaCreationRequired != nil && *split.AtaCreationRequired { + hasATACreation = true + break + } + } + if !hasATACreation { + return nil + } + if isNativeSOL(m.currency) { + return core.NewError(core.ErrCodeInvalidPayload, "ataCreationRequired requires an SPL token currency") + } + // resolve_stablecoin_mint(currency) == currency means the currency is + // already a raw mint address (symbols resolve to a different mint). + if paycore.ResolveMint(m.currency, m.network) != m.currency { + return core.NewError(core.ErrCodeInvalidPayload, "ataCreationRequired requires currency to be an SPL token mint address") + } + if _, err := solana.PublicKeyFromBase58(m.currency); err != nil { + return core.NewError(core.ErrCodeInvalidPayload, fmt.Sprintf("ataCreationRequired requires a valid SPL token mint address: %v", err)) + } + return nil +} + // ChargeWithOptions creates a challenge with optional fields. func (m *Mpp) ChargeWithOptions(ctx context.Context, amount string, options ChargeOptions) (core.PaymentChallenge, error) { + if err := m.validateChargeOptions(options); err != nil { + return core.PaymentChallenge{}, err + } baseUnits, err := intents.ParseUnits(amount, m.decimals) if err != nil { return core.PaymentChallenge{}, err @@ -389,6 +422,15 @@ func (m *Mpp) verifyTransaction( if err != nil { return core.Receipt{}, err } + // Accept legacy and v0 transactions with only static account keys, but + // reject a v0 message carrying address lookup tables: the verifier + // cannot resolve ALT-referenced accounts locally, so a transfer hidden + // behind a lookup table could not be checked. Mirrors rust + // reject_address_lookup_tables (charge.rs:1213-1225), called from the + // pre-broadcast verification path. + if len(tx.Message.AddressTableLookups) > 0 { + return core.Receipt{}, core.NewError(core.ErrCodeInvalidPayload, "v0 transactions with address lookup tables are not supported") + } if err := validateComputeBudgetInstructions(tx); err != nil { return core.Receipt{}, err } @@ -444,13 +486,20 @@ func (m *Mpp) verifyTransaction( if err != nil { return core.Receipt{}, core.WrapError(core.ErrCodeRPC, "send transaction", err) } + // The RPC accepted the broadcast. From here the transaction may land + // on-chain even if confirmation polling or the on-chain re-verification + // below times out. Pin the replay marker NOW so a confirmation/verify + // timeout does not let the deferred rollback delete it and reopen the + // same credential for a second submission while the original lands and + // double-pays. Mirrors the rust reference, which consumes the signature + // after broadcast and never deletes it on a confirmation timeout. + cleanupConsumed = false if err := solanatx.WaitForConfirmation(ctx, m.rpc, signature); err != nil { return core.Receipt{}, core.WrapError(core.ErrCodeTransactionFailed, "confirm transaction", err) } if err := m.verifyOnChain(ctx, signature, request, details); err != nil { return core.Receipt{}, err } - cleanupConsumed = false return successReceipt(signature.String(), credential.Challenge.ID, request.ExternalID), nil } @@ -464,21 +513,25 @@ func (m *Mpp) verifySignature( if payload.Signature == "" { return core.Receipt{}, core.NewError(core.ErrCodeMissingSignature, "missing signature in credential payload") } - inserted, err := m.store.PutIfAbsent(ctx, consumedPrefix+payload.Signature, true) + signature, err := solana.SignatureFromBase58(payload.Signature) if err != nil { return core.Receipt{}, err } - if !inserted { - return core.Receipt{}, core.NewError(core.ErrCodeSignatureConsumed, "transaction signature already consumed") + // Push mode references an already-landed transaction, so verify it + // on-chain BEFORE consuming the replay marker, and never delete the + // marker once consumed. Mirrors rust verify_push -> consume_signature + // (charge.rs:563-595): a verify failure must not burn the marker + // (nothing was committed), and a successful verify must consume it + // durably so the same landed signature cannot be replayed. + if err := m.verifyOnChain(ctx, signature, request, details); err != nil { + return core.Receipt{}, err } - signature, err := solana.SignatureFromBase58(payload.Signature) + inserted, err := m.store.PutIfAbsent(ctx, consumedPrefix+payload.Signature, true) if err != nil { - _ = m.store.Delete(context.WithoutCancel(ctx), consumedPrefix+payload.Signature) return core.Receipt{}, err } - if err := m.verifyOnChain(ctx, signature, request, details); err != nil { - _ = m.store.Delete(context.WithoutCancel(ctx), consumedPrefix+payload.Signature) - return core.Receipt{}, err + if !inserted { + return core.Receipt{}, core.NewError(core.ErrCodeSignatureConsumed, "transaction signature already consumed") } return successReceipt(payload.Signature, credential.Challenge.ID, request.ExternalID), nil } @@ -531,6 +584,12 @@ func verifyTransfersAgainstChallenge(tx *solana.Transaction, amount uint64, curr continue } if transfer.GetRecipientAccount().PublicKey.Equals(want.recipient) && *transfer.Lamports == want.amount { + // The configured fee payer must not fund the SOL payment + // transfer, matching rust verify_sol_transfer_instructions + // (charge.rs:1525-1528). Hard reject, not skip. + if fp := feePayerKey(details); fp != nil && transfer.GetFundingAccount().PublicKey.Equals(*fp) { + return core.NewError(core.ErrCodeInvalidPayload, "fee payer cannot fund the SOL payment transfer") + } matched[index] = true found = true break @@ -540,9 +599,25 @@ func verifyTransfersAgainstChallenge(tx *solana.Transaction, amount uint64, curr return core.NewError(core.ErrCodeNoTransfer, fmt.Sprintf("no matching SOL transfer for %s", want.recipient)) } } - return verifyMemoInstructions(tx, matched, externalID, details.Splits) + if err := verifyMemoInstructions(tx, matched, externalID, details.Splits); err != nil { + return err + } + // Native SOL payments never carry a token mint, so ATA-create + // instructions are not allowed and there is no token program to pin. + return validateInstructionAllowlist(tx, matched, allowlistParams{}) } resolvedMint := paycore.ResolveMint(currency, details.Network) + // ataCreationRequired splits demand a raw SPL mint-address currency: + // when any split required an ATA-create but the currency resolved to a + // different mint (i.e. it was a symbol), reject, matching the rust + // verify guard (charge.rs:1120-1124). + requiredOwners, err := requiredATAOwners(details.Splits) + if err != nil { + return err + } + if len(requiredOwners) > 0 && currency != resolvedMint { + return core.NewError(core.ErrCodeInvalidPayload, "ataCreationRequired requires currency to be an SPL token mint address") + } mint := solana.MustPublicKeyFromBase58(resolvedMint) expectedProgram := solana.TokenProgramID tokenProgram := details.TokenProgram @@ -586,6 +661,14 @@ func verifyTransfersAgainstChallenge(tx *solana.Transaction, amount uint64, curr if err != nil { return err } + var ( + transferAmount uint64 + transferMint solana.PublicKey + transferDest solana.PublicKey + transferSource solana.PublicKey + transferAuth solana.PublicKey + transferDec *uint8 + ) if expectedProgram.Equals(solana.TokenProgramID) { decoded, err := token.DecodeInstruction(accounts, []byte(compiled.Data)) if err != nil { @@ -595,28 +678,54 @@ func verifyTransfersAgainstChallenge(tx *solana.Transaction, amount uint64, curr if !ok || transfer.Amount == nil { continue } - if !transfer.GetMintAccount().PublicKey.Equals(mint) { + transferAmount = *transfer.Amount + transferMint = transfer.GetMintAccount().PublicKey + transferDest = transfer.GetDestinationAccount().PublicKey + transferSource = transfer.GetSourceAccount().PublicKey + transferAuth = transfer.GetOwnerAccount().PublicKey + transferDec = transfer.Decimals + } else { + decoded, err := token2022.DecodeInstruction(accounts, []byte(compiled.Data)) + if err != nil { continue } - if transfer.GetDestinationAccount().PublicKey.Equals(want.ata) && *transfer.Amount == want.amount { - matched[index] = true - found = true - break + transfer, ok := decoded.Impl.(*token2022.TransferChecked) + if !ok || transfer.Amount == nil { + continue } - continue + transferAmount = *transfer.Amount + transferMint = transfer.GetMintAccount().PublicKey + transferDest = transfer.GetDestinationAccount().PublicKey + transferSource = transfer.GetSourceAccount().PublicKey + transferAuth = transfer.GetOwnerAccount().PublicKey + transferDec = transfer.Decimals } - decoded, err := token2022.DecodeInstruction(accounts, []byte(compiled.Data)) - if err != nil { + if !transferMint.Equals(mint) || transferAmount != want.amount { continue } - transfer, ok := decoded.Impl.(*token2022.TransferChecked) - if !ok || transfer.Amount == nil { + // transferChecked decimals byte must match the challenge-pinned + // decimals, matching rust ix.data[9] guard (charge.rs:1623-1624). + if details.Decimals != nil && (transferDec == nil || *transferDec != *details.Decimals) { continue } - if !transfer.GetMintAccount().PublicKey.Equals(mint) { - continue + // The configured fee payer must not authorize or fund the + // payment transfer, matching rust verify_spl_transfer_instructions + // (charge.rs:1642-1658). Both are hard rejects, not skips: a tx + // that routes payment authority/funding through the fee payer is + // malicious regardless of any other matching transfer. + if fp := feePayerKey(details); fp != nil { + if transferAuth.Equals(*fp) { + return core.NewError(core.ErrCodeInvalidPayload, "fee payer cannot authorize the SPL payment transfer") + } + feePayerATA, err := solanatx.FindAssociatedTokenAddressWithProgram(*fp, mint, expectedProgram) + if err != nil { + return err + } + if transferSource.Equals(feePayerATA) { + return core.NewError(core.ErrCodeInvalidPayload, "fee payer token account cannot fund the SPL payment transfer") + } } - if transfer.GetDestinationAccount().PublicKey.Equals(want.ata) && *transfer.Amount == want.amount { + if transferDest.Equals(want.ata) { matched[index] = true found = true break @@ -626,7 +735,56 @@ func verifyTransfersAgainstChallenge(tx *solana.Transaction, amount uint64, curr return core.NewError(core.ErrCodeNoTransfer, fmt.Sprintf("no matching token transfer for %s", want.recipient)) } } - return verifyMemoInstructions(tx, matched, externalID, details.Splits) + if err := verifyMemoInstructions(tx, matched, externalID, details.Splits); err != nil { + return err + } + allowedOwners := make([]solana.PublicKey, 0, len(tokenExpected)) + for _, want := range tokenExpected { + allowedOwners = append(allowedOwners, want.recipient) + } + return validateInstructionAllowlist(tx, matched, allowlistParams{ + expectedMint: &mint, + expectedTokenProgram: &expectedProgram, + allowedATAOwners: allowedOwners, + feePayer: feePayerKey(details), + requiredATAOwners: requiredOwners, + }) +} + +// requiredATAOwners returns the split recipients whose challenge pinned +// ataCreationRequired=true. These owners must each have an idempotent +// ATA-create in the settled transaction. Mirrors the rust reference's +// expected_ata_creation_policy required_owners set: only split recipients +// (never the primary recipient) can be required, and only when the split +// explicitly opted in via ataCreationRequired. +func requiredATAOwners(splits []paycore.Split) ([]solana.PublicKey, error) { + owners := make([]solana.PublicKey, 0, len(splits)) + for _, split := range splits { + if split.AtaCreationRequired == nil || !*split.AtaCreationRequired { + continue + } + owner, err := solana.PublicKeyFromBase58(split.Recipient) + if err != nil { + return nil, err + } + owners = append(owners, owner) + } + return owners, nil +} + +// feePayerKey returns the configured fee payer pubkey when the challenge +// pinned one (fee-payer sponsorship flow), else nil. Used to enforce that +// an ATA-create instruction is funded by the fee payer, mirroring the rust +// reference (validate_instruction_allowlist's expected_ata_payer). +func feePayerKey(details paycore.MethodDetails) *solana.PublicKey { + if details.FeePayerKey == "" { + return nil + } + key, err := solana.PublicKeyFromBase58(details.FeePayerKey) + if err != nil { + return nil + } + return &key } type expectedMemo struct { @@ -691,6 +849,204 @@ func verifyMemoInstructions(tx *solana.Transaction, matched []bool, externalID s return nil } +// allowlistParams carries the pinned context the strict instruction +// allowlist validates ATA-create instructions against. All fields are +// optional: for native SOL payments they are zero, which forbids ATA +// creation entirely. +type allowlistParams struct { + // expectedMint is the charge currency mint; ATA-create instructions are + // rejected when nil (native SOL). + expectedMint *solana.PublicKey + // expectedTokenProgram pins the token program an ATA-create must use. + expectedTokenProgram *solana.PublicKey + // allowedATAOwners are the payment recipients (primary + splits) an + // ATA-create instruction may create an account for. + allowedATAOwners []solana.PublicKey + // feePayer, when non-nil, is the configured sponsor; an ATA-create must + // be funded by it. When nil the transaction fee payer (first account + // key) is used as the expected ATA payer. + feePayer *solana.PublicKey + // requiredATAOwners are split recipients whose challenge pinned + // ataCreationRequired=true. The transaction MUST contain an idempotent + // ATA-create for each of them; a missing one is rejected. Mirrors the + // rust reference required_ata_owners invariant. + requiredATAOwners []solana.PublicKey +} + +// validateInstructionAllowlist is the strict post-match gate. After the +// expected transfer and memo instructions have been matched, every +// remaining instruction is checked against a closed allowlist: +// +// - ComputeBudget: allowed; caps are enforced earlier in +// validateComputeBudgetInstructions. +// - Matched payment/memo instructions: already accounted for via `matched`. +// - Associated Token Program: only an idempotent ATA-create for an allowed +// owner/mint/token-program funded by the expected payer is permitted. +// - Everything else (System, Token, Token-2022, ATA non-idempotent, +// unknown programs): rejected. +// +// Without this gate a fee-payer route would co-sign and broadcast extra +// System/Token/ATA/other-program instructions appended after the expected +// payment. Mirrors the rust reference validate_instruction_allowlist in +// rust/crates/mpp/src/server/charge.rs. +func validateInstructionAllowlist(tx *solana.Transaction, matched []bool, params allowlistParams) error { + memoProgram := solana.MustPublicKeyFromBase58(paycore.MemoProgram) + systemProgram := solana.MustPublicKeyFromBase58(paycore.SystemProgram) + tokenProgram := solana.MustPublicKeyFromBase58(paycore.TokenProgram) + token2022Program := solana.MustPublicKeyFromBase58(paycore.Token2022Program) + ataProgram := solana.MustPublicKeyFromBase58(paycore.AssociatedTokenProgram) + + if len(tx.Message.AccountKeys) == 0 { + return core.NewError(core.ErrCodeInvalidPayload, "transaction has no fee payer") + } + expectedATAPayer := tx.Message.AccountKeys[0] + if params.feePayer != nil { + expectedATAPayer = *params.feePayer + } + createdATAOwners := make([]solana.PublicKey, 0, len(params.requiredATAOwners)) + + for index, compiled := range tx.Message.Instructions { + programID, err := resolveProgramID(tx, compiled.ProgramIDIndex) + if err != nil { + return err + } + switch { + case programID.Equals(computeBudgetProgramID): + // Caps already enforced in validateComputeBudgetInstructions; + // allow it through unconditionally here. + continue + case programID.Equals(memoProgram): + if matched[index] { + continue + } + return core.NewError(core.ErrCodeInvalidPayload, "unexpected Memo Program instruction in payment transaction") + case programID.Equals(systemProgram): + if matched[index] { + continue + } + return core.NewError(core.ErrCodeInvalidPayload, "unexpected System Program instruction in payment transaction") + case programID.Equals(tokenProgram) || programID.Equals(token2022Program): + if matched[index] { + continue + } + return core.NewError(core.ErrCodeInvalidPayload, "unexpected Token Program instruction in payment transaction") + case programID.Equals(ataProgram): + owner, err := validateCreateATAIdempotentInstruction(tx, compiled, params, expectedATAPayer) + if err != nil { + return err + } + createdATAOwners = append(createdATAOwners, owner) + continue + default: + return core.NewError(core.ErrCodeInvalidPayload, + fmt.Sprintf("unexpected program instruction in payment transaction: %s", programID)) + } + } + + // Every owner the challenge pinned as ataCreationRequired must have a + // matching idempotent ATA-create in the transaction. Mirrors the rust + // reference's required_ata_owners check; without it a split recipient + // whose ATA does not yet exist would have its transfer fail on-chain + // (or, on the pull path, force a doomed broadcast). + for _, owner := range params.requiredATAOwners { + if !ownerAllowed(owner, createdATAOwners) { + return core.NewError(core.ErrCodeInvalidPayload, + fmt.Sprintf("missing required ATA creation instruction for split recipient %s", owner)) + } + } + return nil +} + +// validateCreateATAIdempotentInstruction enforces that an Associated Token +// Program instruction is a CreateIdempotent (data == [1]) that targets an +// allowed owner, the expected mint and token program, and is funded by the +// expected payer. Mirrors the rust reference +// validate_create_ata_idempotent_instruction. +func validateCreateATAIdempotentInstruction( + tx *solana.Transaction, + ix solana.CompiledInstruction, + params allowlistParams, + expectedPayer solana.PublicKey, +) (solana.PublicKey, error) { + if params.expectedMint == nil { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, "ATA creation is not allowed for native SOL payments") + } + data := []byte(ix.Data) + if len(data) != 1 || data[0] != 1 { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, "only idempotent ATA creation is allowed") + } + if len(ix.Accounts) != 6 { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, "unexpected ATA creation account layout") + } + accountAt := func(pos int, label string) (solana.PublicKey, error) { + idx := int(ix.Accounts[pos]) + if idx < 0 || idx >= len(tx.Message.AccountKeys) { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, + fmt.Sprintf("invalid %s account index", label)) + } + return tx.Message.AccountKeys[idx], nil + } + payer, err := accountAt(0, "ATA payer") + if err != nil { + return solana.PublicKey{}, err + } + ata, err := accountAt(1, "ATA address") + if err != nil { + return solana.PublicKey{}, err + } + owner, err := accountAt(2, "ATA owner") + if err != nil { + return solana.PublicKey{}, err + } + mint, err := accountAt(3, "ATA mint") + if err != nil { + return solana.PublicKey{}, err + } + systemProgram, err := accountAt(4, "ATA system program") + if err != nil { + return solana.PublicKey{}, err + } + tokenProgram, err := accountAt(5, "ATA token program") + if err != nil { + return solana.PublicKey{}, err + } + if !payer.Equals(expectedPayer) { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, "ATA payer must match the transaction fee payer") + } + if !mint.Equals(*params.expectedMint) { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, "ATA creation mint does not match the charge currency") + } + if !ownerAllowed(owner, params.allowedATAOwners) { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, "ATA creation owner is not authorized by the challenge") + } + if !systemProgram.Equals(solana.MustPublicKeyFromBase58(paycore.SystemProgram)) { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, "ATA creation must reference the System Program") + } + if tokenProgram.String() != paycore.TokenProgram && tokenProgram.String() != paycore.Token2022Program { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, "ATA creation uses an unsupported token program") + } + if params.expectedTokenProgram != nil && !tokenProgram.Equals(*params.expectedTokenProgram) { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, "ATA creation token program does not match methodDetails.tokenProgram") + } + expectedATA, err := solanatx.FindAssociatedTokenAddressWithProgram(owner, mint, tokenProgram) + if err != nil { + return solana.PublicKey{}, err + } + if !ata.Equals(expectedATA) { + return solana.PublicKey{}, core.NewError(core.ErrCodeInvalidPayload, "ATA creation address does not match owner/mint/token program") + } + return owner, nil +} + +func ownerAllowed(owner solana.PublicKey, allowed []solana.PublicKey) bool { + for _, candidate := range allowed { + if candidate.Equals(owner) { + return true + } + } + return false +} + type expectedTransfer struct { recipient solana.PublicKey amount uint64 diff --git a/go/protocols/mpp/server/server_allowlist_test.go b/go/protocols/mpp/server/server_allowlist_test.go new file mode 100644 index 000000000..1964dbb95 --- /dev/null +++ b/go/protocols/mpp/server/server_allowlist_test.go @@ -0,0 +1,582 @@ +package server + +import ( + "strings" + "testing" + + solana "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gagliardetto/solana-go/programs/token" + + "github.com/solana-foundation/pay-kit/go/internal/testutil" + "github.com/solana-foundation/pay-kit/go/paycore" + "github.com/solana-foundation/pay-kit/go/paycore/solanatx" +) + +// buildSPLTransferIx is a small helper that mirrors the way the client builds +// the canonical transferChecked instruction for these allowlist tests. +func buildSPLTransferIx(t *testing.T, payer, recipient, mint solana.PublicKey, amount uint64) solana.Instruction { + t.Helper() + sourceATA, err := solanatx.FindAssociatedTokenAddressWithProgram(payer, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("find source ata: %v", err) + } + recipientATA, err := solanatx.FindAssociatedTokenAddressWithProgram(recipient, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("find recipient ata: %v", err) + } + ix, err := token.NewTransferCheckedInstruction(amount, 6, sourceATA, mint, recipientATA, payer, nil).ValidateAndBuild() + if err != nil { + t.Fatalf("build transfer: %v", err) + } + return ix +} + +// TestVerifyTransfersRejectsExtraSystemInstruction is the core GO-4 regression: +// a transaction whose expected SPL transfer matches but which smuggles an +// extra System Program transfer must be rejected by the post-match allowlist. +// Before the allowlist, the extra System instruction passed silently because +// only leftover Memo Program instructions were rejected. +func TestVerifyTransfersRejectsExtraSystemInstruction(t *testing.T) { + payer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, payer.PublicKey(), recipient, mint, 1000) + // An unrelated System transfer the verifier never expected. + sneaky, err := system.NewTransferInstruction(42, payer.PublicKey(), testutil.NewPrivateKey().PublicKey()).ValidateAndBuild() + if err != nil { + t.Fatalf("build system transfer: %v", err) + } + tx := newTestTransaction(t, payer, transfer, sneaky) + + err = verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{}) + if err == nil { + t.Fatal("expected extra System Program instruction to be rejected") + } + if !strings.Contains(err.Error(), "System Program") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersRejectsExtraTokenInstruction proves an unmatched extra +// Token Program transfer (e.g. draining a different ATA) is rejected. +func TestVerifyTransfersRejectsExtraTokenInstruction(t *testing.T) { + payer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + attacker := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, payer.PublicKey(), recipient, mint, 1000) + // A second token transfer to an attacker-controlled recipient. + extra := buildSPLTransferIx(t, payer.PublicKey(), attacker, mint, 5000) + tx := newTestTransaction(t, payer, transfer, extra) + + err := verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{}) + if err == nil { + t.Fatal("expected extra Token Program instruction to be rejected") + } + if !strings.Contains(err.Error(), "Token Program") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersRejectsUnknownProgramInstruction proves an instruction +// whose program is neither compute budget, memo, system, token, token-2022, +// nor the associated token program is rejected. +func TestVerifyTransfersRejectsUnknownProgramInstruction(t *testing.T) { + payer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, payer.PublicKey(), recipient, mint, 1000) + unknownProgram := testutil.NewPrivateKey().PublicKey() + unknown := solana.NewInstruction(unknownProgram, solana.AccountMetaSlice{ + solana.Meta(payer.PublicKey()).SIGNER(), + }, []byte{9, 9, 9}) + tx := newTestTransaction(t, payer, transfer, unknown) + + err := verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{}) + if err == nil { + t.Fatal("expected unknown program instruction to be rejected") + } + if !strings.Contains(err.Error(), "unexpected program instruction") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersAcceptsIdempotentATACreateForRecipient proves a valid +// idempotent ATA-create funded by the fee payer for an authorized owner is +// allowed (mirrors rust validate_create_ata_idempotent_instruction). This is +// the fee-payer sponsorship flow where methodDetails.feePayerKey is pinned. +func TestVerifyTransfersAcceptsIdempotentATACreateForRecipient(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() // transfer authority / source owner + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + createATA, err := solanatx.BuildCreateAssociatedTokenAccount(feePayer.PublicKey(), recipient, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("build create ata: %v", err) + } + transfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 1000) + // Fee payer is the transaction payer (account 0) for sponsorship. + tx := newTestTransaction(t, feePayer, createATA, transfer) + + err = verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + }) + if err != nil { + t.Fatalf("expected idempotent ATA-create to be accepted: %v", err) + } +} + +// TestVerifyTransfersRejectsATACreateForNativeSOL proves an ATA-create is +// rejected entirely on a native SOL payment. +func TestVerifyTransfersRejectsATACreateForNativeSOL(t *testing.T) { + payer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + solTransfer, err := system.NewTransferInstruction(1000, payer.PublicKey(), recipient).ValidateAndBuild() + if err != nil { + t.Fatalf("build sol transfer: %v", err) + } + createATA, err := solanatx.BuildCreateAssociatedTokenAccount(payer.PublicKey(), recipient, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("build create ata: %v", err) + } + tx := newTestTransaction(t, payer, solTransfer, createATA) + + err = verifyTransfersAgainstChallenge(tx, 1000, "sol", recipient, "", paycore.MethodDetails{}) + if err == nil { + t.Fatal("expected ATA-create on native SOL to be rejected") + } + if !strings.Contains(err.Error(), "native SOL") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersRejectsATACreateForUnauthorizedOwner proves an +// idempotent ATA-create targeting an owner who is not a payment recipient is +// rejected. +func TestVerifyTransfersRejectsATACreateForUnauthorizedOwner(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + stranger := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 1000) + createATA, err := solanatx.BuildCreateAssociatedTokenAccount(feePayer.PublicKey(), stranger, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("build create ata: %v", err) + } + tx := newTestTransaction(t, feePayer, transfer, createATA) + + err = verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + }) + if err == nil { + t.Fatal("expected ATA-create for unauthorized owner to be rejected") + } + if !strings.Contains(err.Error(), "not authorized") { + t.Fatalf("unexpected error: %v", err) + } +} + +// boolPtr is a tiny helper for the *bool ataCreationRequired field. +func boolPtr(b bool) *bool { return &b } + +// TestVerifyTransfersRejectsMissingRequiredATACreation is the required-ATA +// regression: a fee-payer-sponsored charge with a split recipient pinned as +// ataCreationRequired=true must contain an idempotent ATA-create for that +// recipient. A transaction that carries both transfers but omits the required +// ATA-create must be rejected, mirroring the rust reference check +// "missing required ATA creation instruction for split recipient". +// +// Before the required/created tracking was ported, this transaction passed +// because the allowlist only validated ATA-creates that were PRESENT and never +// enforced that required ones existed. +func TestVerifyTransfersRejectsMissingRequiredATACreation(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() // transfer authority / source owner + recipient := testutil.NewPrivateKey().PublicKey() + splitRecipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + // total 1000 = primary 700 + split 300. + primaryTransfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 700) + splitTransfer := buildSPLTransferIx(t, signer.PublicKey(), splitRecipient, mint, 300) + // No ATA-create for the split recipient even though it is required. + tx := newTestTransaction(t, feePayer, primaryTransfer, splitTransfer) + + details := paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + Splits: []paycore.Split{ + { + Recipient: splitRecipient.String(), + Amount: "300", + AtaCreationRequired: boolPtr(true), + }, + }, + } + err := verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", details) + if err == nil { + t.Fatal("expected missing required ATA creation to be rejected") + } + if !strings.Contains(err.Error(), "missing required ATA creation instruction for split recipient") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(err.Error(), splitRecipient.String()) { + t.Fatalf("error should name the split recipient: %v", err) + } +} + +// TestVerifyTransfersAcceptsRequiredATACreation is the pass-after companion: +// the same fee-payer-sponsored split charge succeeds once the required +// idempotent ATA-create for the split recipient is present. +func TestVerifyTransfersAcceptsRequiredATACreation(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + splitRecipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + primaryTransfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 700) + createSplitATA, err := solanatx.BuildCreateAssociatedTokenAccount(feePayer.PublicKey(), splitRecipient, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("build create ata: %v", err) + } + splitTransfer := buildSPLTransferIx(t, signer.PublicKey(), splitRecipient, mint, 300) + tx := newTestTransaction(t, feePayer, primaryTransfer, createSplitATA, splitTransfer) + + details := paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + Splits: []paycore.Split{ + { + Recipient: splitRecipient.String(), + Amount: "300", + AtaCreationRequired: boolPtr(true), + }, + }, + } + if err := verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", details); err != nil { + t.Fatalf("expected required ATA-create charge to be accepted: %v", err) + } +} + +// buildATACreateIx assembles an Associated Token Program instruction with +// caller-controlled accounts and discriminator so the allowlist rejection +// branches can be exercised individually. A valid idempotent create has the +// six accounts [payer, ata, owner, mint, systemProgram, tokenProgram] and +// data == [1]. +func buildATACreateIx(accounts solana.AccountMetaSlice, data []byte) solana.Instruction { + return solana.NewInstruction( + solana.MustPublicKeyFromBase58(paycore.AssociatedTokenProgram), + accounts, + data, + ) +} + +// TestVerifyTransfersRejectsATACreateWrongAccountLayout proves an ATA-create +// instruction that does not carry exactly six accounts is rejected. +func TestVerifyTransfersRejectsATACreateWrongAccountLayout(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 1000) + ata, err := solanatx.FindAssociatedTokenAddressWithProgram(recipient, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("find ata: %v", err) + } + // Five accounts instead of the required six. + bad := buildATACreateIx(solana.AccountMetaSlice{ + solana.Meta(feePayer.PublicKey()).WRITE().SIGNER(), + solana.Meta(ata).WRITE(), + solana.Meta(recipient), + solana.Meta(mint), + solana.Meta(solana.SystemProgramID), + }, []byte{1}) + tx := newTestTransaction(t, feePayer, transfer, bad) + + err = verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + }) + if err == nil { + t.Fatal("expected wrong ATA account layout to be rejected") + } + if !strings.Contains(err.Error(), "account layout") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersRejectsATACreatePayerMismatch proves an ATA-create whose +// funding payer is not the transaction fee payer is rejected. +func TestVerifyTransfersRejectsATACreatePayerMismatch(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 1000) + ata, err := solanatx.FindAssociatedTokenAddressWithProgram(recipient, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("find ata: %v", err) + } + // Account 0 (payer) is the signer, but the expected payer is the fee payer. + bad := buildATACreateIx(solana.AccountMetaSlice{ + solana.Meta(signer.PublicKey()).WRITE().SIGNER(), + solana.Meta(ata).WRITE(), + solana.Meta(recipient), + solana.Meta(mint), + solana.Meta(solana.SystemProgramID), + solana.Meta(solana.TokenProgramID), + }, []byte{1}) + tx := newTestTransaction(t, feePayer, transfer, bad) + + err = verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + }) + if err == nil { + t.Fatal("expected ATA-create payer mismatch to be rejected") + } + if !strings.Contains(err.Error(), "ATA payer must match") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersRejectsATACreateMintMismatch proves an ATA-create whose +// mint differs from the charge currency is rejected. +func TestVerifyTransfersRejectsATACreateMintMismatch(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + otherMint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 1000) + ata, err := solanatx.FindAssociatedTokenAddressWithProgram(recipient, otherMint, solana.TokenProgramID) + if err != nil { + t.Fatalf("find ata: %v", err) + } + bad := buildATACreateIx(solana.AccountMetaSlice{ + solana.Meta(feePayer.PublicKey()).WRITE().SIGNER(), + solana.Meta(ata).WRITE(), + solana.Meta(recipient), + solana.Meta(otherMint), + solana.Meta(solana.SystemProgramID), + solana.Meta(solana.TokenProgramID), + }, []byte{1}) + tx := newTestTransaction(t, feePayer, transfer, bad) + + err = verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + }) + if err == nil { + t.Fatal("expected ATA-create mint mismatch to be rejected") + } + if !strings.Contains(err.Error(), "mint does not match") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersRejectsATACreateWrongSystemProgram proves an ATA-create +// whose system-program account is not the System Program is rejected. +func TestVerifyTransfersRejectsATACreateWrongSystemProgram(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 1000) + ata, err := solanatx.FindAssociatedTokenAddressWithProgram(recipient, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("find ata: %v", err) + } + bad := buildATACreateIx(solana.AccountMetaSlice{ + solana.Meta(feePayer.PublicKey()).WRITE().SIGNER(), + solana.Meta(ata).WRITE(), + solana.Meta(recipient), + solana.Meta(mint), + solana.Meta(testutil.NewPrivateKey().PublicKey()), // not the System Program + solana.Meta(solana.TokenProgramID), + }, []byte{1}) + tx := newTestTransaction(t, feePayer, transfer, bad) + + err = verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + }) + if err == nil { + t.Fatal("expected ATA-create wrong system program to be rejected") + } + if !strings.Contains(err.Error(), "System Program") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersRejectsATACreateUnsupportedTokenProgram proves an +// ATA-create whose token-program account is neither Token nor Token-2022 is +// rejected. +func TestVerifyTransfersRejectsATACreateUnsupportedTokenProgram(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 1000) + ata, err := solanatx.FindAssociatedTokenAddressWithProgram(recipient, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("find ata: %v", err) + } + bad := buildATACreateIx(solana.AccountMetaSlice{ + solana.Meta(feePayer.PublicKey()).WRITE().SIGNER(), + solana.Meta(ata).WRITE(), + solana.Meta(recipient), + solana.Meta(mint), + solana.Meta(solana.SystemProgramID), + solana.Meta(testutil.NewPrivateKey().PublicKey()), // not a token program + }, []byte{1}) + tx := newTestTransaction(t, feePayer, transfer, bad) + + err = verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + }) + if err == nil { + t.Fatal("expected ATA-create unsupported token program to be rejected") + } + if !strings.Contains(err.Error(), "unsupported token program") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersRejectsATACreateAddressMismatch proves an ATA-create +// whose target address is not the canonical ATA derived from +// owner/mint/token program is rejected. +func TestVerifyTransfersRejectsATACreateAddressMismatch(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 1000) + // A bogus ATA address rather than the canonical derivation. + bad := buildATACreateIx(solana.AccountMetaSlice{ + solana.Meta(feePayer.PublicKey()).WRITE().SIGNER(), + solana.Meta(testutil.NewPrivateKey().PublicKey()).WRITE(), + solana.Meta(recipient), + solana.Meta(mint), + solana.Meta(solana.SystemProgramID), + solana.Meta(solana.TokenProgramID), + }, []byte{1}) + tx := newTestTransaction(t, feePayer, transfer, bad) + + err := verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + }) + if err == nil { + t.Fatal("expected ATA-create address mismatch to be rejected") + } + if !strings.Contains(err.Error(), "address does not match") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersRejectsATACreateTokenProgramMismatch proves that an +// idempotent ATA-create whose token program is a valid supported program (e.g. +// Token-2022) but does not match the token program pinned by methodDetails is +// rejected. This exercises the branch: +// +// if params.expectedTokenProgram != nil && !tokenProgram.Equals(*params.expectedTokenProgram) +// +// which validateCreateATAIdempotentInstruction evaluates after confirming the +// token program is supported but before deriving the canonical ATA address. +func TestVerifyTransfersRejectsATACreateTokenProgramMismatch(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + // The expected transfer uses Token (spl-token), but the ATA-create uses + // Token-2022 — a supported program that is not the expected one. + transfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 1000) + + // Build an ATA-create that uses Token-2022 as its token program, while + // the transfer (and therefore methodDetails) pins Token. + ataToken2022, err := solanatx.FindAssociatedTokenAddressWithProgram(recipient, mint, solana.Token2022ProgramID) + if err != nil { + t.Fatalf("find ata (token-2022): %v", err) + } + wrongProgram := buildATACreateIx(solana.AccountMetaSlice{ + solana.Meta(feePayer.PublicKey()).WRITE().SIGNER(), + solana.Meta(ataToken2022).WRITE(), + solana.Meta(recipient), + solana.Meta(mint), + solana.Meta(solana.SystemProgramID), + solana.Meta(solana.Token2022ProgramID), // valid but mismatches expected Token + }, []byte{1}) + tx := newTestTransaction(t, feePayer, transfer, wrongProgram) + + // methodDetails.tokenProgram is always filled from the RPC-observed mint + // owner by verifyTransfersAgainstChallenge, so the pinned-mismatch branch + // is not reachable through it. Drive the branch by calling + // validateCreateATAIdempotentInstruction directly with a pinned + // expectedTokenProgram that differs from the ATA-create's token program. + expectedProgram := solana.TokenProgramID + compiled := tx.Message.Instructions[1] // the ATA-create + params := allowlistParams{ + expectedMint: &mint, + expectedTokenProgram: &expectedProgram, + allowedATAOwners: []solana.PublicKey{recipient}, + feePayer: &[]solana.PublicKey{feePayer.PublicKey()}[0], + } + _, err = validateCreateATAIdempotentInstruction(tx, compiled, params, feePayer.PublicKey()) + if err == nil { + t.Fatal("expected token program mismatch to be rejected") + } + if !strings.Contains(err.Error(), "token program does not match") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestVerifyTransfersRejectsNonIdempotentATACreate proves a Create (data [0]) +// rather than CreateIdempotent (data [1]) ATA instruction is rejected. +func TestVerifyTransfersRejectsNonIdempotentATACreate(t *testing.T) { + feePayer := testutil.NewPrivateKey() + signer := testutil.NewPrivateKey() + recipient := testutil.NewPrivateKey().PublicKey() + mint := testutil.NewPrivateKey().PublicKey() + + transfer := buildSPLTransferIx(t, signer.PublicKey(), recipient, mint, 1000) + ata, err := solanatx.FindAssociatedTokenAddressWithProgram(recipient, mint, solana.TokenProgramID) + if err != nil { + t.Fatalf("find ata: %v", err) + } + // Same accounts as an idempotent create, but the non-idempotent + // discriminator (data == [0]). + nonIdempotent := solana.NewInstruction( + solana.MustPublicKeyFromBase58(paycore.AssociatedTokenProgram), + solana.AccountMetaSlice{ + solana.Meta(feePayer.PublicKey()).WRITE().SIGNER(), + solana.Meta(ata).WRITE(), + solana.Meta(recipient), + solana.Meta(mint), + solana.Meta(solana.SystemProgramID), + solana.Meta(solana.TokenProgramID), + }, + []byte{0}, + ) + tx := newTestTransaction(t, feePayer, transfer, nonIdempotent) + + err = verifyTransfersAgainstChallenge(tx, 1000, mint.String(), recipient, "", paycore.MethodDetails{ + FeePayerKey: feePayer.PublicKey().String(), + }) + if err == nil { + t.Fatal("expected non-idempotent ATA-create to be rejected") + } + if !strings.Contains(err.Error(), "idempotent") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/go/protocols/mpp/server/server_replay_durability_test.go b/go/protocols/mpp/server/server_replay_durability_test.go new file mode 100644 index 000000000..e18d63455 --- /dev/null +++ b/go/protocols/mpp/server/server_replay_durability_test.go @@ -0,0 +1,97 @@ +package server + +import ( + "context" + "errors" + "testing" + "time" + + solana "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + + "github.com/solana-foundation/pay-kit/go/internal/testutil" + "github.com/solana-foundation/pay-kit/go/protocols/mpp/client" + core "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" +) + +// confirmTimeoutRPC wraps a FakeRPC so that Simulate and Send succeed (the +// broadcast is accepted by the RPC) but GetSignatureStatuses always errors, +// so WaitForConfirmation never confirms and the caller's context deadline is +// what ultimately ends the poll. This models the dangerous case: the RPC +// accepted the transaction (it may still land on-chain) but the server times +// out waiting for confirmation. +type confirmTimeoutRPC struct { + *testutil.FakeRPC +} + +func (c *confirmTimeoutRPC) GetSignatureStatuses(_ context.Context, _ bool, _ ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + return nil, errors.New("rpc unavailable: confirmation status temporarily unknown") +} + +// TestReplayMarkerRetainedOnConfirmationTimeout is the GO-5 regression. After +// SendTransaction returns ok the consumed marker must survive a confirmation +// timeout: the transaction may still land on-chain, so re-submitting the same +// credential must be rejected as already-consumed rather than reopening the +// credential for a double-pay. +func TestReplayMarkerRetainedOnConfirmationTimeout(t *testing.T) { + fake := testutil.NewFakeRPC() + rpcClient := &confirmTimeoutRPC{FakeRPC: fake} + recipientSigner := testutil.NewPrivateKey() + clientSigner := testutil.NewPrivateKey() + + handler, err := New(Config{ + Recipient: recipientSigner.PublicKey().String(), + Currency: "sol", + Decimals: 9, + Network: "localnet", + SecretKey: "test-secret", + RPC: rpcClient, + Store: core.NewMemoryStore(), + }) + if err != nil { + t.Fatalf("new mpp failed: %v", err) + } + + challenge, err := handler.Charge(context.Background(), "0.001") + if err != nil { + t.Fatalf("charge failed: %v", err) + } + authHeader, err := client.BuildCredentialHeader(context.Background(), clientSigner, rpcClient, challenge) + if err != nil { + t.Fatalf("build credential failed: %v", err) + } + credential, err := core.ParseAuthorization(authHeader) + if err != nil { + t.Fatalf("parse authorization failed: %v", err) + } + + // First attempt: broadcast succeeds, confirmation times out. + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + if _, err := handler.VerifyCredential(ctx, credential); err == nil { + t.Fatal("expected confirmation timeout to surface an error") + } + + // The transaction was broadcast and accepted by the RPC. + if len(fake.Sent) == 0 { + t.Fatal("expected the transaction to have been broadcast") + } + + // Second attempt with the SAME credential MUST be rejected: the marker + // must still be reserved because the original broadcast may land. + _, err = handler.VerifyCredential(context.Background(), credential) + if err == nil { + t.Fatal("expected re-submission after broadcast+timeout to be rejected as consumed") + } + if !isConsumedError(err) { + t.Fatalf("expected signature-consumed rejection, got: %v", err) + } +} + +func isConsumedError(err error) bool { + var coreErr *core.Error + if errors.As(err, &coreErr) { + return coreErr.Code == core.ErrCodeSignatureConsumed + } + return false +} diff --git a/go/protocols/mpp/wire/cover_test.go b/go/protocols/mpp/wire/cover_test.go index fc548de13..f0f08c372 100644 --- a/go/protocols/mpp/wire/cover_test.go +++ b/go/protocols/mpp/wire/cover_test.go @@ -1,6 +1,85 @@ package wire -import "testing" +import ( + "strings" + "testing" +) + +// TestStripPaymentSchemeRejectsBadScheme proves that stripPaymentScheme returns +// (false) when the header does not start with the Payment keyword, covering the +// early-return branch at headers.go:290. +func TestStripPaymentSchemeRejectsBadScheme(t *testing.T) { + if _, ok := stripPaymentScheme("Bearer abc123"); ok { + t.Error("expected false for non-Payment scheme") + } + if _, ok := stripPaymentScheme(""); ok { + t.Error("expected false for empty header") + } +} + +// TestIsPaymentSchemeStartRejectsNoTrailingSpace proves that +// isPaymentSchemeStart returns false when "Payment" appears in the header +// but is not followed by a space or tab, covering the branch at headers.go:277. +func TestIsPaymentSchemeStartRejectsNoTrailingSpace(t *testing.T) { + // "Payment" at position 0 but immediately followed by '=' (no space). + header := "Payment=abc" + if isPaymentSchemeStart(header, 0) { + t.Error("expected false when Payment is not followed by space") + } +} + +// TestIsAuthSchemeStartRejectsPastEnd proves that isAuthSchemeStart returns +// false when the supplied index is beyond the header length, covering the +// early-return branch at headers.go:252. +func TestIsAuthSchemeStartRejectsPastEnd(t *testing.T) { + header := "Payment abc" + if isAuthSchemeStart(header, len(header)+5) { + t.Error("expected false for index past end of header") + } +} + +// TestIsAuthSchemeStartRejectsTokenRunningToEnd proves that +// isAuthSchemeStart returns false when the token fills the rest of the +// header without a trailing space, covering the tokenEnd >= len(header) +// branch at headers.go:263. +func TestIsAuthSchemeStartRejectsTokenRunningToEnd(t *testing.T) { + // A bare word at the start with no space following it means it cannot + // be an auth scheme (e.g. "Bearer" with nothing after it). + header := "Bearer" + if isAuthSchemeStart(header, 0) { + t.Error("expected false for token with no trailing space") + } +} + +// TestParseAuthParamsTrailingCommaBreak proves the break-on-empty-input +// branch inside parseAuthParams fires when the input ends in trailing commas, +// preventing an infinite loop. This covers the branch at headers.go:311. +func TestParseAuthParamsTrailingCommaBreak(t *testing.T) { + // Trailing commas after the last param: TrimLeft(" \t,") returns "" + // after all entries are consumed, triggering the `break`. + input := `id="abc",,,` + params, err := parseAuthParams(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if params["id"] != "abc" { + t.Errorf("expected id=abc, got %v", params) + } +} + +// TestParseAuthParamsMissingEquals proves that parseAuthParams returns an +// error when a parameter name has no '=' separator at all, covering the +// eq<=0 branch at headers.go:315. +func TestParseAuthParamsMissingEquals(t *testing.T) { + // Input has no '=' character anywhere. + _, err := parseAuthParams("noequalsign") + if err == nil { + t.Fatal("expected error for param with no '=' separator") + } + if !strings.Contains(err.Error(), "invalid auth parameter") { + t.Fatalf("unexpected error: %v", err) + } +} func TestFormatReceiptRoundTrip(t *testing.T) { r := Receipt{ diff --git a/go/protocols/x402/binding_test.go b/go/protocols/x402/binding_test.go new file mode 100644 index 000000000..e917650c1 --- /dev/null +++ b/go/protocols/x402/binding_test.go @@ -0,0 +1,105 @@ +package x402 + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/solana-foundation/pay-kit/go/paykit" + "github.com/solana-foundation/pay-kit/go/signer" +) + +// Finding #11: the server enforces the credential's echoed `accepted` +// offer against the route's requirements before settlement, matching +// Rust verify_envelope_payload (server/exact.rs:490-541). A credential +// that lies about its accepted amount/recipient/network/currency is +// rejected. Before the fix the server ignored credential.Accepted, so a +// lying offer reached the structural/broadcast path. +func bindingAdapter(t *testing.T) *Adapter { + t.Helper() + op := signer.Generate() + return &Adapter{ + cfg: paykit.Config{ + Network: paykit.SolanaMainnet, + Stablecoins: []paykit.Stablecoin{paykit.USDC}, + Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, + X402: paykit.X402Config{Scheme: "exact"}, + }, + signer: op, + blockhashProvider: func() (string, error) { return "BH", nil }, + } +} + +func encodeCredential(t *testing.T, cred Credential) string { + t.Helper() + raw, err := json.Marshal(cred) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(raw) +} + +func TestVerifyRejectsLyingAcceptedAmount(t *testing.T) { + a := bindingAdapter(t) + gate := paykit.Gate{Amount: paykit.MustParseUSD("0.10")} + + // Build the route's honest accepted view, then tamper the amount. + route := a.routeAccepts(&gate) + tampered := route + tampered.Amount = "999999999" + tampered.MaxAmountRequired = "999999999" + + cred := Credential{ + X402Version: x402Version, + Payload: CredentialPayload{Transaction: base64.StdEncoding.EncodeToString([]byte("ignored"))}, + Accepted: &tampered, + } + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) || perr.Code != "charge_request_mismatch" { + t.Fatalf("expected charge_request_mismatch for lying amount, got %v", err) + } +} + +func TestVerifyRejectsLyingAcceptedRecipient(t *testing.T) { + a := bindingAdapter(t) + gate := paykit.Gate{Amount: paykit.MustParseUSD("0.10")} + + route := a.routeAccepts(&gate) + tampered := route + tampered.PayTo = string(signer.Generate().Pubkey()) + + cred := Credential{ + X402Version: x402Version, + Payload: CredentialPayload{Transaction: base64.StdEncoding.EncodeToString([]byte("ignored"))}, + Accepted: &tampered, + } + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) || perr.Code != "charge_request_mismatch" { + t.Fatalf("expected charge_request_mismatch for lying recipient, got %v", err) + } +} + +func TestVerifyAcceptsHonestAcceptedThenProceeds(t *testing.T) { + a := bindingAdapter(t) + gate := paykit.Gate{Amount: paykit.MustParseUSD("0.10")} + + // Honest accepted echo: binding passes, so the flow proceeds past the + // binding gate and fails later on the bogus transaction payload (not + // on charge_request_mismatch from the binding check). + honest := a.routeAccepts(&gate) + cred := Credential{ + X402Version: x402Version, + Payload: CredentialPayload{Transaction: base64.StdEncoding.EncodeToString([]byte("not-a-tx"))}, + Accepted: &honest, + } + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) { + t.Fatalf("expected a PaymentError, got %v", err) + } + if perr.Code == "charge_request_mismatch" { + t.Fatalf("honest accepted should pass the binding gate, got %v", err) + } +} diff --git a/go/protocols/x402/client/client.go b/go/protocols/x402/client/client.go index b48c260ab..6cee89c5e 100644 --- a/go/protocols/x402/client/client.go +++ b/go/protocols/x402/client/client.go @@ -13,7 +13,9 @@ package client import ( "bytes" "context" + "crypto/rand" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -28,6 +30,22 @@ import ( x402 "github.com/solana-foundation/pay-kit/go/protocols/x402" ) +// nonceBytes is the size of the random memo nonce the client appends when the +// offer does not pin an extra.memo. 16 bytes matches the x402 SVM spec +// minimum, and hex-encoding keeps the memo data valid UTF-8. +const nonceBytes = 16 + +// nonceSource produces the raw nonce bytes for the per-payment memo. It is a +// package var so deterministic/golden-vector tests can swap in a fixed nonce; +// production callers get a secure RNG via crypto/rand. +var nonceSource = func() ([]byte, error) { + buf := make([]byte, nonceBytes) + if _, err := rand.Read(buf); err != nil { + return nil, err + } + return buf, nil +} + const ( paymentRequiredHeader = "Payment-Required" paymentSignatureHeader = "Payment-Signature" @@ -38,6 +56,11 @@ const ( // caps (maxComputeUnitLimit / maxComputeUnitPriceMicroLamports). defaultComputeUnitLimit uint32 = 20_000 defaultComputeUnitPrice uint64 = 1 + + // maxMemoBytes is the x402-specific cap on a seller-pinned extra.memo, + // matching Rust MAX_MEMO_BYTES (types.rs:9). Wider than this the + // client rejects the offer before building the memo instruction. + maxMemoBytes = 256 ) // mintResolutionLabels are the network labels a preferred currency symbol @@ -47,6 +70,29 @@ const ( // the mainnet mint for unknown labels). var mintResolutionLabels = []string{"mainnet-beta", "devnet", "localnet"} +// solanaMainnetCAIP2 is the canonical mainnet chain id selectEntry +// defaults the preferred network to when the selection leaves it empty, +// matching the Rust select_requirement default (payment.rs:282-285). +const solanaMainnetCAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + +// normalizeNetworkSlug maps cluster slugs and aliases to their canonical +// CAIP-2 id so a "devnet"/"mainnet" preference compares against an +// offer's normalized network. Mirrors the Rust caip2_network_for_cluster +// path (payment.rs:282-298). The AcceptsEntry parser already normalizes +// the offer side, so only the selection's preferred network needs it. +func normalizeNetworkSlug(network string) string { + switch network { + case "", "mainnet", "mainnet-beta", "solana", "solana_mainnet": + return solanaMainnetCAIP2 + case "devnet", "solana-devnet", "localnet", "solana_devnet", "solana_localnet": + return "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + case "testnet", "solana-testnet": + return "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z" + default: + return network + } +} + // ChallengeSelection captures client-side preferences for picking one offer // from a server's accepts[] list. Mirrors the Rust ChallengeSelection. type ChallengeSelection struct { @@ -113,16 +159,17 @@ func selectEntry(accepts []x402.AcceptsEntry, sel ChallengeSelection) *x402.Acce return nil } - onNetwork := solana - if sel.Network != "" { - filtered := make([]x402.AcceptsEntry, 0, len(solana)) - for _, e := range solana { - if e.Network == sel.Network { - filtered = append(filtered, e) - } + // Rust defaults the preferred network to mainnet when the selection + // leaves it empty and normalizes cluster slugs to CAIP-2 on both + // sides before comparing (select_requirement, payment.rs:282-309). + preferred := normalizeNetworkSlug(sel.Network) + filtered := make([]x402.AcceptsEntry, 0, len(solana)) + for _, e := range solana { + if e.Network == preferred { + filtered = append(filtered, e) } - onNetwork = filtered } + onNetwork := filtered if len(sel.Currencies) > 0 { for _, currency := range sel.Currencies { @@ -142,13 +189,34 @@ func selectEntry(accepts []x402.AcceptsEntry, sel ChallengeSelection) *x402.Acce } // currencyMatches reports whether an offer's mint corresponds to a client -// currency preference, which may be a symbol ("USDC") or a mint address. +// currency preference. Both sides may be a symbol ("USDC") or a mint +// address; Rust currencies_match resolves BOTH through +// resolve_stablecoin_mint before comparing (payment.rs:344-348), so a +// symbol offer matches a mint-address preference and vice versa. func currencyMatches(offerMint, preferred string) bool { if offerMint == preferred { return true } for _, label := range mintResolutionLabels { - if paycore.ResolveMint(preferred, label) == offerMint { + offerResolved := paycore.ResolveMint(offerMint, label) + preferredResolved := paycore.ResolveMint(preferred, label) + if offerResolved == preferredResolved { + return true + } + } + return false +} + +// isNativeSOL reports whether an offer's asset is native SOL. An empty +// asset is native, and so is any currency that resolves to no mint +// (ResolveMint returns "" for "SOL"/"sol"), matching Rust resolve_mint +// -> None (payment.rs:60-73, solana.go:74-79). +func isNativeSOL(asset string) bool { + if asset == "" { + return true + } + for _, label := range mintResolutionLabels { + if paycore.ResolveMint(asset, label) == "" { return true } } @@ -231,9 +299,11 @@ func buildTransaction( } instructions := []solana.Instruction{limitIx, priceIx} - // Asset == "" is a native SOL offer (Rust resolve_mint -> None); - // otherwise it is an SPL mint and we transfer with transferChecked. - if entry.Asset == "" { + // Native SOL when the currency resolves to no mint, mirroring Rust + // resolve_mint -> None (payment.rs:60-73). ResolveMint returns "" for + // SOL and any currency that maps to native SOL, so a literal "SOL" + // asset routes to the system-transfer path, not transferChecked. + if isNativeSOL(entry.Asset) { transfer, err := solanatx.BuildSOLTransfer(signer.PublicKey(), recipient, amount) if err != nil { return "", err @@ -247,26 +317,49 @@ func buildTransaction( instructions = append(instructions, transfer) } - if entry.Extra.Memo != "" { - memoIx, err := solanatx.BuildMemoInstruction(entry.Extra.Memo) + // The x402 SVM spec requires the client to ALWAYS append exactly one Memo + // instruction so that otherwise-identical payments (same amount, mint, + // recipient, blockhash) stay unique on-chain. Use the seller-pinned + // extra.memo when present, otherwise a random >=16-byte nonce hex-encoded + // to UTF-8. + memoValue := entry.Extra.Memo + if memoValue != "" { + // Seller-pinned memo: reject when it exceeds the x402 256-byte + // cap, matching Rust memo_instruction (payment.rs:357-362, + // MAX_MEMO_BYTES=256). The shared BuildMemoInstruction 566-byte + // bound is a wider Solana limit; this is the x402-specific cap. + if len(memoValue) > maxMemoBytes { + return "", fmt.Errorf("x402 client: extra.memo exceeds maximum %d bytes", maxMemoBytes) + } + } else { + nonce, err := nonceSource() if err != nil { - return "", err + return "", fmt.Errorf("x402 client: generate memo nonce: %w", err) } - instructions = append(instructions, memoIx) + memoValue = hex.EncodeToString(nonce) } + memoIx, err := solanatx.BuildMemoInstruction(memoValue) + if err != nil { + return "", err + } + instructions = append(instructions, memoIx) blockhash, err := solanatx.ResolveRecentBlockhash(ctx, rpc, entry.Extra.RecentBlockhash) if err != nil { return "", fmt.Errorf("x402 client: recent blockhash: %w", err) } - // Fee payer is the server when it advertises one, so the server - // cosigns the empty slot; otherwise the local signer pays. + // Fee payer is the server when it advertises one AND opts in via the + // boolean toggle, so the server cosigns the empty slot; otherwise the + // local signer pays. Matches Rust use_fee_payer = + // fee_payer.unwrap_or(false) && fee_payer_key.is_some() + // (payment.rs:43-50): an explicit feePayer:false opts out even when a + // key is present. payer := signer.PublicKey() - if entry.Extra.FeePayer != "" { - payer, err = solana.PublicKeyFromBase58(entry.Extra.FeePayer) + if entry.Extra.FeePayer && entry.Extra.FeePayerKey != "" { + payer, err = solana.PublicKeyFromBase58(entry.Extra.FeePayerKey) if err != nil { - return "", fmt.Errorf("x402 client: fee payer %q: %w", entry.Extra.FeePayer, err) + return "", fmt.Errorf("x402 client: fee payer %q: %w", entry.Extra.FeePayerKey, err) } } @@ -290,9 +383,26 @@ func buildSPLTransfer( if err != nil { return nil, fmt.Errorf("x402 client: mint %q: %w", entry.Asset, err) } - tokenProgram, err := solana.PublicKeyFromBase58(entry.Extra.TokenProgram) + // Default the token program to the per-currency default when the + // offer omits extra.tokenProgram, matching Rust + // token_program.unwrap_or_else(default_token_program_for_currency) + // (payment.rs:445-452). Resolved against every cluster label so a + // Token-2022 mint (PYUSD/USDG/CASH) picks the right program. + tokenProgramStr := entry.Extra.TokenProgram + if tokenProgramStr == "" { + for _, label := range mintResolutionLabels { + if tp := paycore.DefaultTokenProgramForCurrency(entry.Asset, label); paycore.StablecoinSymbol(paycore.ResolveMint(entry.Asset, label)) != "" { + tokenProgramStr = tp + break + } + } + if tokenProgramStr == "" { + tokenProgramStr = paycore.DefaultTokenProgramForCurrency(entry.Asset, "mainnet-beta") + } + } + tokenProgram, err := solana.PublicKeyFromBase58(tokenProgramStr) if err != nil { - return nil, fmt.Errorf("x402 client: token program %q: %w", entry.Extra.TokenProgram, err) + return nil, fmt.Errorf("x402 client: token program %q: %w", tokenProgramStr, err) } sourceATA, err := solanatx.FindAssociatedTokenAddressWithProgram(signer.PublicKey(), mint, tokenProgram) if err != nil { diff --git a/go/protocols/x402/client/client_test.go b/go/protocols/x402/client/client_test.go index 98d4131e9..adb65e3de 100644 --- a/go/protocols/x402/client/client_test.go +++ b/go/protocols/x402/client/client_test.go @@ -3,7 +3,9 @@ package client import ( "context" "encoding/base64" + "encoding/hex" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -35,8 +37,11 @@ func entry(asset, amount, network string) x402.AcceptsEntry { Amount: amount, PayTo: testutil.NewPrivateKey().PublicKey().String(), Extra: x402.Extra{ - FeePayer: testutil.NewPrivateKey().PublicKey().String(), + FeePayer: true, + FeePayerSet: true, + FeePayerKey: testutil.NewPrivateKey().PublicKey().String(), Decimals: 6, + DecimalsSet: true, TokenProgram: solana.TokenProgramID.String(), Memo: "test", RecentBlockhash: blockhash(), @@ -132,7 +137,7 @@ func TestBuildPaymentHeaderSPL(t *testing.T) { } // Fee payer (account 0) is the server's advertised fee payer, left // unsigned for the server to cosign. - feePayer := solana.MustPublicKeyFromBase58(e.Extra.FeePayer) + feePayer := solana.MustPublicKeyFromBase58(e.Extra.FeePayerKey) if !tx.Message.AccountKeys[0].Equals(feePayer) { t.Errorf("fee payer: got %s want %s", tx.Message.AccountKeys[0], feePayer) } @@ -147,7 +152,8 @@ func TestBuildPaymentHeaderSPL(t *testing.T) { func TestBuildPaymentHeaderSOL(t *testing.T) { signer := testutil.NewPrivateKey() e := entry("", "1000000", mainnetCAIP2) // empty Asset => native SOL - e.Extra.FeePayer = "" // self-paid + e.Extra.FeePayer = false // self-paid + e.Extra.FeePayerKey = "" header, err := BuildPaymentHeader(context.Background(), signer, testutil.NewFakeRPC(), &e) if err != nil { @@ -279,7 +285,7 @@ func TestBuildTransactionErrorPaths(t *testing.T) { "bad recipient": func(e *x402.AcceptsEntry) { e.PayTo = "!!!" }, "bad mint": func(e *x402.AcceptsEntry) { e.Asset = "!!!" }, "bad token program": func(e *x402.AcceptsEntry) { e.Extra.TokenProgram = "!!!" }, - "bad fee payer": func(e *x402.AcceptsEntry) { e.Extra.FeePayer = "!!!" }, + "bad fee payer": func(e *x402.AcceptsEntry) { e.Extra.FeePayerKey = "!!!" }, } for name, mutate := range cases { e := entry(mint, "100000", mainnetCAIP2) @@ -344,6 +350,111 @@ func TestParseChallengeInvalidJSON(t *testing.T) { } } +// memoData returns the data of the single Memo Program instruction in the +// transaction, failing the test if there is not exactly one. +func memoData(t *testing.T, tx *solana.Transaction) string { + t.Helper() + memoProgram := solana.MustPublicKeyFromBase58(paycore.MemoProgram) + var found []string + for _, ix := range tx.Message.Instructions { + idx := int(ix.ProgramIDIndex) + if idx < 0 || idx >= len(tx.Message.AccountKeys) { + t.Fatalf("instruction program index %d out of range", idx) + } + if tx.Message.AccountKeys[idx].Equals(memoProgram) { + found = append(found, string(ix.Data)) + } + } + if len(found) != 1 { + t.Fatalf("expected exactly one memo instruction, found %d", len(found)) + } + return found[0] +} + +// TestBuildPaymentHeaderAppendsNonceMemoWhenNoExtraMemo proves the client +// always appends a Memo instruction even when the offer carries no +// extra.memo, using a random >=16-byte hex nonce. This guarantees uniqueness +// of otherwise-identical payments. Regression for Decision 2. +func TestBuildPaymentHeaderAppendsNonceMemoWhenNoExtraMemo(t *testing.T) { + signer := testutil.NewPrivateKey() + e := entry(testutil.NewPrivateKey().PublicKey().String(), "100000", mainnetCAIP2) + e.Extra.Memo = "" // no seller-pinned memo + + header, err := BuildPaymentHeader(context.Background(), signer, testutil.NewFakeRPC(), &e) + if err != nil { + t.Fatal(err) + } + tx := decodeCredentialTx(t, header) + // compute limit, compute price, transferChecked, nonce memo. + if len(tx.Message.Instructions) != 4 { + t.Fatalf("instruction count: got %d want 4", len(tx.Message.Instructions)) + } + memo := memoData(t, tx) + raw, err := hex.DecodeString(memo) + if err != nil { + t.Fatalf("nonce memo %q is not hex-encoded: %v", memo, err) + } + if len(raw) < 16 { + t.Fatalf("nonce memo decodes to %d bytes, want >= 16", len(raw)) + } +} + +// TestBuildPaymentHeaderNonceMemoIsUnique proves two payments built from the +// same offer carry distinct nonce memos so the transactions are unique. +func TestBuildPaymentHeaderNonceMemoIsUnique(t *testing.T) { + signer := testutil.NewPrivateKey() + e := entry(testutil.NewPrivateKey().PublicKey().String(), "100000", mainnetCAIP2) + e.Extra.Memo = "" + + build := func() string { + header, err := BuildPaymentHeader(context.Background(), signer, testutil.NewFakeRPC(), &e) + if err != nil { + t.Fatal(err) + } + return memoData(t, decodeCredentialTx(t, header)) + } + if a, b := build(), build(); a == b { + t.Fatalf("two payments produced identical nonce memos %q", a) + } +} + +// TestBuildPaymentHeaderUsesExtraMemoWhenPresent proves the seller-pinned +// extra.memo wins over a generated nonce. +func TestBuildPaymentHeaderUsesExtraMemoWhenPresent(t *testing.T) { + signer := testutil.NewPrivateKey() + e := entry(testutil.NewPrivateKey().PublicKey().String(), "100000", mainnetCAIP2) + e.Extra.Memo = "pi_invoice_42" + + header, err := BuildPaymentHeader(context.Background(), signer, testutil.NewFakeRPC(), &e) + if err != nil { + t.Fatal(err) + } + if got := memoData(t, decodeCredentialTx(t, header)); got != "pi_invoice_42" { + t.Fatalf("memo: got %q want %q", got, "pi_invoice_42") + } +} + +// TestNonceSourceIsInjectable proves the nonce source can be overridden so +// deterministic/golden-vector tests get a fixed nonce. +func TestNonceSourceIsInjectable(t *testing.T) { + signer := testutil.NewPrivateKey() + e := entry(testutil.NewPrivateKey().PublicKey().String(), "100000", mainnetCAIP2) + e.Extra.Memo = "" + + fixed := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + orig := nonceSource + nonceSource = func() ([]byte, error) { return fixed, nil } + defer func() { nonceSource = orig }() + + header, err := BuildPaymentHeader(context.Background(), signer, testutil.NewFakeRPC(), &e) + if err != nil { + t.Fatal(err) + } + if got := memoData(t, decodeCredentialTx(t, header)); got != hex.EncodeToString(fixed) { + t.Fatalf("memo: got %q want %q", got, hex.EncodeToString(fixed)) + } +} + func TestCheapestSkipsUnparseableAmount(t *testing.T) { mint := testutil.NewPrivateKey().PublicKey().String() junk := entry(mint, "notanumber", mainnetCAIP2) @@ -382,3 +493,23 @@ func TestPaymentTransportReturnsBuildError(t *testing.T) { t.Error("expected the transport to surface the build error for an unbuildable offer") } } + +// TestBuildTransactionNonceSourceError proves that a failing nonce source +// propagates as an error from buildTransaction (the branch at client.go:276). +func TestBuildTransactionNonceSourceError(t *testing.T) { + signer := testutil.NewPrivateKey() + e := entry(testutil.NewPrivateKey().PublicKey().String(), "100000", mainnetCAIP2) + e.Extra.Memo = "" // force the nonce-source path + + orig := nonceSource + nonceSource = func() ([]byte, error) { return nil, fmt.Errorf("entropy exhausted") } + defer func() { nonceSource = orig }() + + _, err := BuildPaymentHeader(context.Background(), signer, testutil.NewFakeRPC(), &e) + if err == nil { + t.Fatal("expected error when nonce source fails") + } + if !strings.Contains(err.Error(), "generate memo nonce") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/go/protocols/x402/client/parity_test.go b/go/protocols/x402/client/parity_test.go new file mode 100644 index 000000000..6bd6f8f8d --- /dev/null +++ b/go/protocols/x402/client/parity_test.go @@ -0,0 +1,114 @@ +package client + +import ( + "context" + "strings" + "testing" + + solana "github.com/gagliardetto/solana-go" + "github.com/solana-foundation/pay-kit/go/internal/testutil" + "github.com/solana-foundation/pay-kit/go/paycore" + x402 "github.com/solana-foundation/pay-kit/go/protocols/x402" +) + +// parseEntry runs the canonical AcceptsEntry parse so these tests see the +// same resolved view of an offer that the production client does. +func parseEntry(t *testing.T, raw string) x402.AcceptsEntry { + t.Helper() + var e x402.AcceptsEntry + if err := (&e).UnmarshalJSON([]byte(raw)); err != nil { + t.Fatalf("parse entry: %v", err) + } + return e +} + +// Finding #3: the client defaults the token program to the per-currency +// default when the offer omits extra.tokenProgram, matching Rust +// token_program.unwrap_or_else(default_token_program_for_currency). A +// USDC offer with no tokenProgram must still build a valid transferChecked. +func TestBuildSPLDefaultsTokenProgramForCurrency(t *testing.T) { + signer := testutil.NewPrivateKey() + e := parseEntry(t, `{"protocol":"x402","scheme":"exact","network":"`+mainnetCAIP2+`","asset":"`+paycore.USDCMainnetMint+`","amount":"100000","payTo":"`+testutil.NewPrivateKey().PublicKey().String()+`","extra":{}}`) + + header, err := BuildPaymentHeader(context.Background(), signer, testutil.NewFakeRPC(), &e) + if err != nil { + t.Fatalf("build with defaulted token program: %v", err) + } + tx := decodeCredentialTx(t, header) + // The transferChecked must target the canonical SPL token program. + found := false + for _, k := range tx.Message.AccountKeys { + if k.String() == paycore.TokenProgram { + found = true + } + } + if !found { + t.Error("expected default token program in account keys") + } +} + +// Finding #5: a literal "SOL" asset is native SOL and routes to a system +// transfer, not transferChecked, matching Rust resolve_mint -> None. +func TestBuildLiteralSOLAssetIsNative(t *testing.T) { + signer := testutil.NewPrivateKey() + e := parseEntry(t, `{"protocol":"x402","scheme":"exact","network":"`+mainnetCAIP2+`","asset":"SOL","amount":"5000","payTo":"`+testutil.NewPrivateKey().PublicKey().String()+`"}`) + + header, err := BuildPaymentHeader(context.Background(), signer, testutil.NewFakeRPC(), &e) + if err != nil { + t.Fatalf("build native SOL: %v", err) + } + tx := decodeCredentialTx(t, header) + for _, k := range tx.Message.AccountKeys { + if k.String() == paycore.TokenProgram || k.String() == paycore.Token2022Program { + t.Error("literal SOL offer must not produce an SPL token transfer") + } + } + // system transfer present. + hasSystem := false + for _, k := range tx.Message.AccountKeys { + if k.Equals(solana.SystemProgramID) { + hasSystem = true + } + } + if !hasSystem { + t.Error("native SOL offer must produce a system transfer") + } +} + +// Finding #7: a seller-pinned extra.memo over 256 bytes is rejected at +// build time, matching Rust memo_instruction MAX_MEMO_BYTES. +func TestBuildRejectsOversizedSellerMemo(t *testing.T) { + signer := testutil.NewPrivateKey() + e := parseEntry(t, `{"protocol":"x402","scheme":"exact","network":"`+mainnetCAIP2+`","asset":"`+paycore.USDCMainnetMint+`","amount":"1","payTo":"`+testutil.NewPrivateKey().PublicKey().String()+`","extra":{"memo":"`+strings.Repeat("x", 257)+`","tokenProgram":"`+paycore.TokenProgram+`","decimals":6}}`) + + _, err := BuildPaymentHeader(context.Background(), signer, testutil.NewFakeRPC(), &e) + if err == nil || !strings.Contains(err.Error(), "extra.memo exceeds maximum") { + t.Errorf("expected oversized memo rejection, got %v", err) + } +} + +// Finding #8: currency matching resolves BOTH the offer's asset and the +// client's preference, so a symbol offer matches a mint-address +// preference and vice versa. +func TestCurrencyMatchesBothSidesResolved(t *testing.T) { + // Offer side is the symbol, preference side is the mint address. + if !currencyMatches("USDC", paycore.USDCMainnetMint) { + t.Error("symbol offer should match mint-address preference") + } + // Offer side is the mint, preference side is the symbol. + if !currencyMatches(paycore.USDCMainnetMint, "USDC") { + t.Error("mint offer should match symbol preference") + } +} + +// Finding #9: an empty preferred network defaults to mainnet, so a +// mainnet offer is selected over a devnet one rather than the cheapest +// across all networks. +func TestSelectDefaultsPreferredNetworkToMainnet(t *testing.T) { + mainnet := entry(testutil.NewPrivateKey().PublicKey().String(), "999999", mainnetCAIP2) + devnet := entry(testutil.NewPrivateKey().PublicKey().String(), "1", devnetCAIP2) + got := selectEntry([]x402.AcceptsEntry{devnet, mainnet}, ChallengeSelection{}) + if got == nil || got.Network != mainnetCAIP2 { + t.Fatalf("expected mainnet default, got %+v", got) + } +} diff --git a/go/protocols/x402/parity_test.go b/go/protocols/x402/parity_test.go new file mode 100644 index 000000000..c0962a18c --- /dev/null +++ b/go/protocols/x402/parity_test.go @@ -0,0 +1,142 @@ +package x402 + +import ( + "encoding/json" + "testing" +) + +// These tests pin the canonical-wire parse precedence to the Rust spine +// (rust/crates/x402/src/protocol/schemes/exact/types.rs Deserialize and +// client/exact/payment.rs build_payment). Each one fails before the +// AcceptsEntry.UnmarshalJSON precedence/default fix and passes after. + +// Finding #1: amount falls back to maxAmountRequired when amount absent. +func TestParseAmountFallsBackToMaxAmountRequired(t *testing.T) { + raw := []byte(`{"protocol":"x402","scheme":"exact","network":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp","asset":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","maxAmountRequired":"123456","payTo":"abc"}`) + var e AcceptsEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + if e.Amount != "123456" { + t.Errorf("amount fallback: got %q want 123456", e.Amount) + } +} + +// Finding #2: decimals default to 6 when absent (rust unwrap_or(6)). +func TestParseDecimalsDefaultsToSix(t *testing.T) { + raw := []byte(`{"protocol":"x402","scheme":"exact","network":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp","asset":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":"1","payTo":"abc"}`) + var e AcceptsEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + if e.Extra.Decimals != 6 { + t.Errorf("decimals default: got %d want 6", e.Extra.Decimals) + } +} + +// Finding #4: explicit feePayer:false opts out even with a key present +// (rust use_fee_payer = fee_payer.unwrap_or(false) && key.is_some()). +func TestParseFeePayerExplicitFalseOptsOut(t *testing.T) { + raw := []byte(`{"protocol":"x402","scheme":"exact","network":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp","asset":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":"1","payTo":"abc","feePayer":false,"feePayerKey":"FEE"}`) + var e AcceptsEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + if e.Extra.FeePayer { + t.Error("explicit feePayer:false should opt out of server fee payer") + } + if e.Extra.FeePayerKey != "FEE" { + t.Errorf("feePayerKey: got %q want FEE", e.Extra.FeePayerKey) + } +} + +// Finding #4: extra.feePayer string is the key, fee_payer defaults true. +func TestParseFeePayerKeyFromExtraDefaultsTrue(t *testing.T) { + raw := []byte(`{"protocol":"x402","scheme":"exact","network":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp","asset":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":"1","payTo":"abc","extra":{"feePayer":"FEEKEY"}}`) + var e AcceptsEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + if !e.Extra.FeePayer || e.Extra.FeePayerKey != "FEEKEY" { + t.Errorf("extra.feePayer: feePayer=%v key=%q want true/FEEKEY", e.Extra.FeePayer, e.Extra.FeePayerKey) + } +} + +// Finding #6: top-level canonical fields win over extra.* mirrors, and +// payTo falls back to recipient, asset to currency. +func TestParseTopLevelPrecedence(t *testing.T) { + raw := []byte(`{"protocol":"x402","scheme":"exact","network":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp","currency":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":"7","recipient":"RCPT","tokenProgram":"TopLvlTP","decimals":9,"extra":{"tokenProgram":"ExtraTP","decimals":2}}`) + var e AcceptsEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + if e.Asset != "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" { + t.Errorf("asset from currency: got %q", e.Asset) + } + if e.PayTo != "RCPT" { + t.Errorf("payTo from recipient: got %q", e.PayTo) + } + if e.Extra.TokenProgram != "TopLvlTP" { + t.Errorf("tokenProgram top-level precedence: got %q want TopLvlTP", e.Extra.TokenProgram) + } + if e.Extra.Decimals != 9 { + t.Errorf("decimals top-level precedence: got %d want 9", e.Extra.Decimals) + } +} + +// Finding #9: cluster-slug network normalizes to its canonical CAIP-2. +func TestParseNetworkNormalization(t *testing.T) { + cases := map[string]string{ + "mainnet": solanaMainnetCAIP2, + "mainnet-beta": solanaMainnetCAIP2, + "devnet": solanaDevnetCAIP2, + "solana-devnet": solanaDevnetCAIP2, + "localnet": solanaDevnetCAIP2, + "testnet": solanaTestnetCAIP2, + } + for slug, want := range cases { + raw := []byte(`{"protocol":"x402","scheme":"exact","network":"` + slug + `","asset":"A","amount":"1","payTo":"abc"}`) + var e AcceptsEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + if e.Network != want { + t.Errorf("network %q normalized to %q, want %q", slug, e.Network, want) + } + } +} + +// Finding #12: the parsed offer retains its verbatim bytes so the client +// can echo accepted without dropping unknown keys, and MarshalJSON emits +// them verbatim. +func TestParsedAcceptedEchoesVerbatim(t *testing.T) { + raw := []byte(`{"protocol":"x402","scheme":"exact","network":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp","asset":"A","amount":"1","payTo":"abc","futureUnknownKey":{"nested":true}}`) + var e AcceptsEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + out, err := json.Marshal(&e) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatal(err) + } + if _, ok := got["futureUnknownKey"]; !ok { + t.Errorf("verbatim echo dropped unknown key: %s", out) + } +} + +// Finding #13: maxTimeoutSeconds defaults to 300 when absent (rust +// max_age.unwrap_or(300)). +func TestParseMaxTimeoutDefaultsTo300(t *testing.T) { + raw := []byte(`{"protocol":"x402","scheme":"exact","network":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp","asset":"A","amount":"1","payTo":"abc"}`) + var e AcceptsEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + if e.MaxTimeoutSeconds != 300 { + t.Errorf("maxTimeoutSeconds default: got %d want 300", e.MaxTimeoutSeconds) + } +} diff --git a/go/protocols/x402/verify.go b/go/protocols/x402/verify.go index dfd379314..75b2d8962 100644 --- a/go/protocols/x402/verify.go +++ b/go/protocols/x402/verify.go @@ -2,7 +2,6 @@ package x402 import ( "encoding/binary" - "errors" "fmt" solana "github.com/gagliardetto/solana-go" @@ -21,6 +20,26 @@ const ( // transaction may carry. Matches the Rust verifier's bound. const maxComputeUnitPriceMicroLamports uint64 = 5_000_000 +// verifyError carries a canonical x402 reason code plus a human +// message. The settle path surfaces Code verbatim as the +// PaymentError.Code, matching the Rust verifier's specific +// invalid_exact_svm_payload_* reasons (verify.rs:235-418) instead of +// collapsing every structural failure to charge_request_mismatch. +type verifyError struct { + Code string + msg string +} + +func (e *verifyError) Error() string { + if e.msg != "" { + return "x402: " + e.msg + } + return "x402: " + e.Code +} + +// verifyFail builds a verifyError with a canonical code and message. +func verifyFail(code, msg string) error { return &verifyError{Code: code, msg: msg} } + // transferRequirements is the subset of the advertised accept entry the // structural verifier checks the on-wire transaction against. type transferRequirements struct { @@ -29,6 +48,10 @@ type transferRequirements struct { tokenProgram solana.PublicKey amount uint64 feePayer solana.PublicKey // the operator; must not be the transfer authority + // expectedMemo, when non-empty, is the advertised extra.memo. The x402 + // SVM spec requires the verifier to confirm exactly one Memo instruction + // whose data equals it. + expectedMemo string } // verifyExactTransaction runs the canonical x402 "exact" structural @@ -49,7 +72,8 @@ func verifyExactTransaction(tx *solana.Transaction, req transferRequirements) er msg := &tx.Message ixs := msg.Instructions if len(ixs) < 3 || len(ixs) > 6 { - return fmt.Errorf("x402: instruction count %d outside [3,6]", len(ixs)) + return verifyFail("invalid_exact_svm_payload_transaction_instructions_length", + fmt.Sprintf("instruction count %d outside [3,6]", len(ixs))) } keys := msg.AccountKeys @@ -62,17 +86,53 @@ func verifyExactTransaction(tx *solana.Transaction, req transferRequirements) er if err := verifyTransfer(ixs[2], keys, req); err != nil { return err } - // Optional trailing instructions: memo / lighthouse only. + // Optional trailing instructions: memo / lighthouse only. Wallets inject + // Lighthouse guard instructions (Phantom 1, Solflare 2) so those MUST be + // allowed; everything else (System / Token / ATA-create / unknown) is + // rejected, which keeps a Create-ATA out of the optional slots per spec. + // Canonical reason codes are positional (fourth/fifth/sixth), matching + // invalid_reason_by_index (verify.rs:257-274). + invalidReasonByIndex := []string{ + "invalid_exact_svm_payload_unknown_fourth_instruction", + "invalid_exact_svm_payload_unknown_fifth_instruction", + "invalid_exact_svm_payload_unknown_sixth_instruction", + } + memoCount := 0 for i := 3; i < len(ixs); i++ { prog, err := programIDForIx(ixs[i], keys) if err != nil { return err } switch prog.String() { - case paycore.MemoProgram, lighthouseProgram: + case paycore.MemoProgram: + memoCount++ + continue + case lighthouseProgram: continue default: - return fmt.Errorf("x402: unexpected instruction %d program %s", i, prog) + code := "invalid_exact_svm_payload_unknown_optional_instruction" + if idx := i - 3; idx < len(invalidReasonByIndex) { + code = invalidReasonByIndex[idx] + } + return verifyFail(code, fmt.Sprintf("unexpected instruction %d program %s", i, prog)) + } + } + // When the offer pins extra.memo, exactly one matching Memo instruction + // must be present and its data must equal the pinned value. + if req.expectedMemo != "" { + if memoCount != 1 { + return verifyFail("invalid_exact_svm_payload_memo_count", + fmt.Sprintf("expected exactly one memo matching extra.memo, found %d", memoCount)) + } + for i := 3; i < len(ixs); i++ { + prog, err := programIDForIx(ixs[i], keys) + if err != nil { + return err + } + if prog.String() == paycore.MemoProgram && string(ixs[i].Data) != req.expectedMemo { + return verifyFail("invalid_exact_svm_payload_memo_mismatch", + fmt.Sprintf("memo instruction %d does not match extra.memo", i)) + } } } return nil @@ -84,7 +144,8 @@ func verifyComputeLimit(ix solana.CompiledInstruction, keys solana.PublicKeySlic return err } if prog.String() != computeBudgetProgram || len(ix.Data) != 5 || ix.Data[0] != 2 { - return errors.New("x402: ix[0] is not a ComputeBudget SetComputeUnitLimit") + return verifyFail("invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", + "ix[0] is not a ComputeBudget SetComputeUnitLimit") } return nil } @@ -95,11 +156,13 @@ func verifyComputePrice(ix solana.CompiledInstruction, keys solana.PublicKeySlic return err } if prog.String() != computeBudgetProgram || len(ix.Data) != 9 || ix.Data[0] != 3 { - return errors.New("x402: ix[1] is not a ComputeBudget SetComputeUnitPrice") + return verifyFail("invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", + "ix[1] is not a ComputeBudget SetComputeUnitPrice") } microLamports := binary.LittleEndian.Uint64(ix.Data[1:9]) if microLamports > maxComputeUnitPriceMicroLamports { - return fmt.Errorf("x402: compute unit price %d exceeds cap %d", microLamports, maxComputeUnitPriceMicroLamports) + return verifyFail("invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high", + fmt.Sprintf("compute unit price %d exceeds cap %d", microLamports, maxComputeUnitPriceMicroLamports)) } return nil } @@ -111,11 +174,13 @@ func verifyTransfer(ix solana.CompiledInstruction, keys solana.PublicKeySlice, r } progStr := prog.String() if progStr != paycore.TokenProgram && progStr != paycore.Token2022Program { - return errors.New("x402: ix[2] is not an SPL token transfer") + return verifyFail("invalid_exact_svm_payload_no_transfer_instruction", + "ix[2] is not an SPL token transfer") } // transferChecked: discriminator 12, then u64 amount, then u8 decimals. if len(ix.Accounts) < 4 || len(ix.Data) != 10 || ix.Data[0] != 12 { - return errors.New("x402: ix[2] is not a transferChecked") + return verifyFail("invalid_exact_svm_payload_no_transfer_instruction", + "ix[2] is not a transferChecked") } mint, err := keyForIndex(ix.Accounts[1], keys) if err != nil { @@ -132,21 +197,25 @@ func verifyTransfer(ix solana.CompiledInstruction, keys solana.PublicKeySlice, r // The fee-payer (operator) must not be the one moving the customer's // funds — that would let a malicious server drain the operator. if authority.Equals(req.feePayer) { - return errors.New("x402: transfer authority is the fee-payer") + return verifyFail("invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", + "transfer authority is the fee-payer") } if !mint.Equals(req.mint) { - return fmt.Errorf("x402: mint mismatch: got %s want %s", mint, req.mint) + return verifyFail("invalid_exact_svm_payload_mint_mismatch", + fmt.Sprintf("mint mismatch: got %s want %s", mint, req.mint)) } expectedDest, err := solanatx.FindAssociatedTokenAddressWithProgram(req.payTo, req.mint, req.tokenProgram) if err != nil { return fmt.Errorf("x402: derive recipient ATA: %w", err) } if !destination.Equals(expectedDest) { - return fmt.Errorf("x402: recipient ATA mismatch: got %s want %s", destination, expectedDest) + return verifyFail("invalid_exact_svm_payload_recipient_mismatch", + fmt.Sprintf("recipient ATA mismatch: got %s want %s", destination, expectedDest)) } amount := binary.LittleEndian.Uint64(ix.Data[1:9]) if amount != req.amount { - return fmt.Errorf("x402: amount mismatch: got %d want %d", amount, req.amount) + return verifyFail("invalid_exact_svm_payload_amount_mismatch", + fmt.Sprintf("amount mismatch: got %d want %d", amount, req.amount)) } return nil } diff --git a/go/protocols/x402/verify_test.go b/go/protocols/x402/verify_test.go index cd0883a42..2c119bf11 100644 --- a/go/protocols/x402/verify_test.go +++ b/go/protocols/x402/verify_test.go @@ -108,6 +108,69 @@ func TestVerifyAcceptsTrailingMemo(t *testing.T) { } } +// TestVerifyAcceptsTrailingLighthouse proves a wallet-injected Lighthouse +// guard instruction in an optional slot is allowed (Phantom injects 1, +// Solflare 2), per the x402 SVM spec. +func TestVerifyAcceptsTrailingLighthouse(t *testing.T) { + f := newFixture(t) + lhIdx := uint16(len(f.keys)) + f.keys = append(f.keys, solana.MustPublicKeyFromBase58(lighthouseProgram)) + lh := solana.CompiledInstruction{ProgramIDIndex: lhIdx, Data: []byte{0x01}} + // Two Lighthouse instructions (Solflare-style) must also pass. + if err := verifyExactTransaction(f.tx(lh, lh), f.req); err != nil { + t.Fatalf("expected trailing Lighthouse instructions to pass, got %v", err) + } +} + +// TestVerifyRejectsTrailingATACreate proves a Create-ATA (Associated Token +// Program) instruction in an optional slot is rejected: the x402 SVM spec +// requires the destination ATA to pre-exist. +func TestVerifyRejectsTrailingATACreate(t *testing.T) { + f := newFixture(t) + ataIdx := uint16(len(f.keys)) + f.keys = append(f.keys, solana.MustPublicKeyFromBase58(paycore.AssociatedTokenProgram)) + ata := solana.CompiledInstruction{ProgramIDIndex: ataIdx, Data: []byte{1}} + if err := verifyExactTransaction(f.tx(ata), f.req); err == nil { + t.Error("expected rejection for trailing ATA-create instruction") + } +} + +// TestVerifyEnforcesExpectedMemoMatch proves that when the offer pins +// extra.memo the verifier requires exactly one Memo whose data equals it. +func TestVerifyEnforcesExpectedMemoMatch(t *testing.T) { + mkMemo := func(t *testing.T, f *fixture, data string) solana.CompiledInstruction { + idx := uint16(len(f.keys)) + f.keys = append(f.keys, solana.MustPublicKeyFromBase58(paycore.MemoProgram)) + return solana.CompiledInstruction{ProgramIDIndex: idx, Data: []byte(data)} + } + + t.Run("matching memo passes", func(t *testing.T) { + f := newFixture(t) + f.req.expectedMemo = "pi_invoice_42" + memo := mkMemo(t, &f, "pi_invoice_42") + if err := verifyExactTransaction(f.tx(memo), f.req); err != nil { + t.Fatalf("expected matching memo to pass, got %v", err) + } + }) + + t.Run("wrong memo rejected", func(t *testing.T) { + f := newFixture(t) + f.req.expectedMemo = "pi_invoice_42" + memo := mkMemo(t, &f, "different") + if err := verifyExactTransaction(f.tx(memo), f.req); err == nil { + t.Error("expected rejection for memo not matching extra.memo") + } + }) + + t.Run("missing memo rejected", func(t *testing.T) { + f := newFixture(t) + f.req.expectedMemo = "pi_invoice_42" + if err := verifyExactTransaction(f.tx(), f.req); err == nil { + t.Error("expected rejection when extra.memo set but no memo present") + } + }) +} + func TestVerifyRejectsTooFewInstructions(t *testing.T) { f := newFixture(t) tx := &solana.Transaction{ @@ -389,8 +452,8 @@ func TestVerifyAndSettleRejectsTransactionThatDoesNotPayGate(t *testing.T) { gate := paykit.Gate{Amount: paykit.MustParseUSD("0.001")} _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: base64.StdEncoding.EncodeToString(credJSON)}) var perr *paykit.PaymentError - if !errorsAs(err, &perr) || perr.Code != "charge_request_mismatch" { - t.Errorf("expected charge_request_mismatch, got %v", err) + if !errorsAs(err, &perr) || perr.Code != "invalid_exact_svm_payload_recipient_mismatch" { + t.Errorf("expected invalid_exact_svm_payload_recipient_mismatch, got %v", err) } } diff --git a/go/protocols/x402/x402.go b/go/protocols/x402/x402.go index 90b999105..350d9635d 100644 --- a/go/protocols/x402/x402.go +++ b/go/protocols/x402/x402.go @@ -8,6 +8,7 @@ package x402 import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -35,6 +36,26 @@ const ( // PYUSD, CASH) uses 6 decimals on Solana; revisit if a non-6 asset is // ever added (it would need a getMint lookup instead of a constant). stablecoinDecimals = 6 + + // defaultDecimals is the transferChecked decimals the client assumes + // when an offer omits both top-level and extra.decimals, matching the + // Rust spine requirements.decimals.unwrap_or(6) (payment.rs:453). + defaultDecimals = 6 + + // defaultMaxTimeoutSeconds is the advertised credential lifetime the + // challenge emits and the client assumes when an offer omits + // maxTimeoutSeconds/maxAge, matching to_accepted_value's + // max_age.unwrap_or(300) (types.rs:247). + defaultMaxTimeoutSeconds = 300 + + // solanaNetworkCAIP2 family identifiers mirror the Rust spine + // (types.rs SOLANA_MAINNET/DEVNET/TESTNET, constants.rs SOLANA_NETWORK) + // so cluster-slug offers normalize to the same CAIP-2 the client + // compares preferences against. + solanaNetworkCAIP2 = "solana" + solanaMainnetCAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + solanaDevnetCAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + solanaTestnetCAIP2 = "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z" ) // rpcClient is the narrow Solana RPC surface the x402 settle path uses. @@ -83,6 +104,15 @@ func (a *Adapter) Scheme() paykit.Scheme { return paykit.X402 } // AcceptsEntry is the typed JSON shape x402-exact emits into the 402 // body's `accepts[]` array. +// +// On parse the canonical-wire precedence from the Rust spine +// (rust/crates/x402/src/protocol/schemes/exact/types.rs Deserialize) +// is applied: top-level canonical fields win over their extra.* +// mirrors, `amount` falls back to `maxAmountRequired`, `payTo` to +// `recipient`, `asset` to `currency`, decimals defaults to 6, and +// tokenProgram defaults to the per-currency default. The raw bytes are +// captured so the client can echo the selected offer verbatim +// (to_accepted_value's value.clone()). type AcceptsEntry struct { Protocol string `json:"protocol"` Scheme string `json:"scheme"` @@ -93,6 +123,12 @@ type AcceptsEntry struct { PayTo string `json:"payTo"` MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` Extra Extra `json:"extra"` + + // raw is the verbatim JSON object this entry was parsed from, used + // to echo the selected offer back in the credential's `accepted` + // field without dropping unknown keys. Empty for server-constructed + // entries (which marshal from the typed fields). + raw json.RawMessage } // Extra carries x402's optional metadata. RecentBlockhash is the @@ -100,14 +136,206 @@ type AcceptsEntry struct { // so the pay-kit Rust client pins to it when building the tx // against a surfpool / forked-mainnet ledger the public RPC has // never seen. +// +// FeePayerSet records whether the wire carried an explicit boolean +// `feePayer` toggle so the client can honour an explicit `false` +// opt-out the way the Rust spine does (use_fee_payer = +// fee_payer.unwrap_or(false) && fee_payer_key.is_some()). type Extra struct { - FeePayer string `json:"feePayer"` + FeePayer bool `json:"-"` + FeePayerSet bool `json:"-"` + FeePayerKey string `json:"-"` Decimals int `json:"decimals"` + DecimalsSet bool `json:"-"` TokenProgram string `json:"tokenProgram"` Memo string `json:"memo"` RecentBlockhash string `json:"recentBlockhash,omitempty"` } +// rawAcceptsEntry is the literal JSON shape used for parsing, before the +// canonical-wire precedence rules collapse it into AcceptsEntry. Every +// canonical field exists both top-level and under extra so the +// top-level-first precedence can be applied. +type rawAcceptsEntry struct { + Protocol string `json:"protocol"` + Scheme string `json:"scheme"` + Network string `json:"network"` + Asset string `json:"asset"` + Currency string `json:"currency"` + Amount string `json:"amount"` + MaxAmountRequired string `json:"maxAmountRequired"` + PayTo string `json:"payTo"` + Recipient string `json:"recipient"` + MaxTimeoutSeconds *int `json:"maxTimeoutSeconds"` + MaxAge *int `json:"maxAge"` + Decimals *int `json:"decimals"` + TokenProgram string `json:"tokenProgram"` + RecentBlockhash string `json:"recentBlockhash"` + FeePayer *bool `json:"feePayer"` + FeePayerKey string `json:"feePayerKey"` + Extra *rawExtra `json:"extra"` +} + +// rawExtra is the literal extra.* object. feePayer is decoded into a +// json.RawMessage because the Rust wire allows it to be either a boolean +// toggle (top-level) or a string key (extra.feePayer); here under extra +// it is the fee-payer key string. +type rawExtra struct { + FeePayer string `json:"feePayer"` + Decimals *int `json:"decimals"` + TokenProgram string `json:"tokenProgram"` + Memo string `json:"memo"` + RecentBlockhash string `json:"recentBlockhash"` +} + +// UnmarshalJSON applies the Rust spine's canonical-wire precedence so a +// client parsing a server offer matches build_payment's view of it: +// top-level canonical fields win over extra.* mirrors, amount falls +// back to maxAmountRequired, payTo to recipient, asset to currency, +// decimals defaults to 6, and the fee-payer toggle honours an explicit +// boolean. The raw bytes are retained for verbatim accepted echo. +func (e *AcceptsEntry) UnmarshalJSON(data []byte) error { + var r rawAcceptsEntry + if err := json.Unmarshal(data, &r); err != nil { + return err + } + if r.Extra == nil { + r.Extra = &rawExtra{} + } + + e.raw = append(json.RawMessage(nil), data...) + e.Protocol = r.Protocol + e.Scheme = r.Scheme + e.Network = normalizeNetwork(r.Network) + + // asset := asset || currency (top-level currency, then offered asset). + e.Asset = firstNonEmpty(r.Asset, r.Currency) + // amount := amount || maxAmountRequired. + e.Amount = firstNonEmpty(r.Amount, r.MaxAmountRequired) + e.MaxAmountRequired = firstNonEmpty(r.MaxAmountRequired, r.Amount) + // recipient := recipient || payTo. (Rust reads recipient first.) + e.PayTo = firstNonEmpty(r.Recipient, r.PayTo) + + switch { + case r.MaxTimeoutSeconds != nil: + e.MaxTimeoutSeconds = *r.MaxTimeoutSeconds + case r.MaxAge != nil: + e.MaxTimeoutSeconds = *r.MaxAge + default: + e.MaxTimeoutSeconds = defaultMaxTimeoutSeconds + } + + // Top-level field wins over extra.* mirror for each optional field. + e.Extra.RecentBlockhash = firstNonEmpty(r.RecentBlockhash, r.Extra.RecentBlockhash) + e.Extra.TokenProgram = firstNonEmpty(r.TokenProgram, r.Extra.TokenProgram) + e.Extra.Memo = r.Extra.Memo + + // decimals: top-level then extra.decimals; default to 6 when absent. + switch { + case r.Decimals != nil: + e.Extra.Decimals, e.Extra.DecimalsSet = *r.Decimals, true + case r.Extra.Decimals != nil: + e.Extra.Decimals, e.Extra.DecimalsSet = *r.Extra.Decimals, true + default: + e.Extra.Decimals, e.Extra.DecimalsSet = defaultDecimals, false + } + + // fee_payer_key := feePayerKey (top-level) || extra.feePayer (string). + e.Extra.FeePayerKey = firstNonEmpty(r.FeePayerKey, r.Extra.FeePayer) + // fee_payer := bool feePayer, else true when a key is present. + switch { + case r.FeePayer != nil: + e.Extra.FeePayer, e.Extra.FeePayerSet = *r.FeePayer, true + case e.Extra.FeePayerKey != "": + e.Extra.FeePayer, e.Extra.FeePayerSet = true, true + default: + e.Extra.FeePayer, e.Extra.FeePayerSet = false, false + } + return nil +} + +// MarshalJSON keeps the server-emitted wire shape stable: the typed +// fields (protocol/scheme/network/asset/amount/maxAmountRequired/payTo/ +// maxTimeoutSeconds/extra) with extra.feePayer rendered as the key +// string the client expects. When the entry was parsed from a server +// offer (raw is populated) the verbatim bytes are echoed instead so the +// credential's `accepted` field preserves unknown keys, matching the +// Rust to_accepted_value value.clone() path (types.rs:236-239). +func (e AcceptsEntry) MarshalJSON() ([]byte, error) { + if len(e.raw) > 0 { + return e.raw, nil + } + type wireExtra struct { + FeePayer string `json:"feePayer,omitempty"` + Decimals int `json:"decimals"` + TokenProgram string `json:"tokenProgram"` + Memo string `json:"memo"` + RecentBlockhash string `json:"recentBlockhash,omitempty"` + } + type wire struct { + Protocol string `json:"protocol"` + Scheme string `json:"scheme"` + Network string `json:"network"` + Asset string `json:"asset"` + Amount string `json:"amount"` + MaxAmountRequired string `json:"maxAmountRequired"` + PayTo string `json:"payTo"` + MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` + Extra wireExtra `json:"extra"` + } + return json.Marshal(wire{ + Protocol: e.Protocol, + Scheme: e.Scheme, + Network: e.Network, + Asset: e.Asset, + Amount: e.Amount, + MaxAmountRequired: e.MaxAmountRequired, + PayTo: e.PayTo, + MaxTimeoutSeconds: e.MaxTimeoutSeconds, + Extra: wireExtra{ + FeePayer: e.Extra.FeePayerKey, + Decimals: e.Extra.Decimals, + TokenProgram: e.Extra.TokenProgram, + Memo: e.Extra.Memo, + RecentBlockhash: e.Extra.RecentBlockhash, + }, + }) +} + +// RawAccepted returns the verbatim JSON the offer was parsed from, or +// nil for a server-constructed entry. The client echoes this in the +// credential's `accepted` field so unknown keys survive the round-trip. +func (e AcceptsEntry) RawAccepted() json.RawMessage { return e.raw } + +// firstNonEmpty returns the first non-empty string argument. +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +// normalizeNetwork maps cluster slugs and aliases to their canonical +// CAIP-2 identifier, mirroring the Rust normalize_network_identifier so +// a "mainnet"/"devnet"/"testnet" offer is comparable to a CAIP-2 +// preference. CAIP-2 ids and unknown values pass through unchanged. +func normalizeNetwork(network string) string { + switch network { + case "": + return "" + case solanaNetworkCAIP2, "mainnet", "mainnet-beta": + return solanaMainnetCAIP2 + case "solana-devnet", "devnet", "localnet": + return solanaDevnetCAIP2 + case "solana-testnet", "testnet": + return solanaTestnetCAIP2 + default: + return network + } +} + // AcceptsProtocol satisfies [paykit.AcceptsEntry]. func (e AcceptsEntry) AcceptsProtocol() paykit.Scheme { return paykit.X402 } @@ -146,8 +374,11 @@ func (a *Adapter) AcceptsEntry(gate *paykit.Gate) paykit.AcceptsEntry { amount := a.totalUnits(gate, coin) payTo := a.payTo(gate) extra := Extra{ - FeePayer: string(a.signer.Pubkey()), + FeePayer: true, + FeePayerSet: true, + FeePayerKey: string(a.signer.Pubkey()), Decimals: stablecoinDecimals, + DecimalsSet: true, TokenProgram: paycore.DefaultTokenProgramForCurrency(coin, a.cfg.Network.MintsLabel()), Memo: gate.Desc, } @@ -162,7 +393,7 @@ func (a *Adapter) AcceptsEntry(gate *paykit.Gate) paykit.AcceptsEntry { Amount: amount, MaxAmountRequired: amount, PayTo: string(payTo), - MaxTimeoutSeconds: 60, + MaxTimeoutSeconds: defaultMaxTimeoutSeconds, Extra: extra, } } @@ -213,6 +444,21 @@ func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, if credential.X402Version != x402Version { return nil, &paykit.PaymentError{Code: "version_mismatch", Err: fmt.Errorf("unsupported x402Version %d", credential.X402Version), Gate: req.Gate} } + + // Echoed-accepted binding: when the credential carries an `accepted` + // object it is the requirements the client claims to be paying for. + // Compare it against the ROUTE's requirements (never the other way), + // so a credential that lies about its accepted offer is rejected + // before any transaction processing or settlement. Targeted field + // checks first, then a structural backstop over the canonical + // accepted shape. Mirrors Rust verify_envelope_payload + // (server/exact.rs:490-541). + if credential.Accepted != nil { + if err := a.verifyAcceptedBinding(req.Gate, credential.Accepted); err != nil { + return nil, &paykit.PaymentError{Code: "charge_request_mismatch", Err: err, Gate: req.Gate} + } + } + txBase64 := credential.Payload.Transaction if txBase64 == "" { return nil, &paykit.PaymentError{Code: "invalid_payload", Err: errors.New("missing transaction payload"), Gate: req.Gate} @@ -239,7 +485,16 @@ func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, return nil, &paykit.PaymentError{Code: "invalid_gate", Err: err, Gate: req.Gate} } if err := verifyExactTransaction(tx, reqs); err != nil { - return nil, &paykit.PaymentError{Code: "charge_request_mismatch", Err: err, Gate: req.Gate} + // Surface the canonical invalid_exact_svm_payload_* reason from the + // structural verifier rather than collapsing every failure to + // charge_request_mismatch, matching the Rust verifier's specific + // reasons (verify.rs:235-418). + code := "charge_request_mismatch" + var ve *verifyError + if errors.As(err, &ve) { + code = ve.Code + } + return nil, &paykit.PaymentError{Code: code, Err: err, Gate: req.Gate} } // Replay reservation, keyed on the client signature (slot 0). Rolled @@ -294,6 +549,81 @@ func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, }, nil } +// verifyAcceptedBinding rejects a credential whose echoed `accepted` +// offer does not match this route's advertised requirements. Targeted +// network/amount/recipient/currency checks give actionable errors; a +// canonical structural compare backstops drift on any remaining field. +// Mirrors Rust verify_envelope_payload (server/exact.rs:490-541). +func (a *Adapter) verifyAcceptedBinding(gate *paykit.Gate, accepted *AcceptsEntry) error { + route := a.routeAccepts(gate) + if accepted.Network != route.Network { + return fmt.Errorf("network mismatch: expected %s, got %s", route.Network, accepted.Network) + } + if accepted.Amount != route.Amount { + return fmt.Errorf("amount mismatch: expected %s, got %s", route.Amount, accepted.Amount) + } + if accepted.PayTo != route.PayTo { + return errors.New("recipient mismatch: credential claims a different recipient") + } + if accepted.Asset != route.Asset { + return fmt.Errorf("currency mismatch: expected %s, got %s", route.Asset, accepted.Asset) + } + // Structural backstop over the canonical typed shape. Compared via the + // canonical marshal (not the verbatim raw) so a credential that + // reorders keys or pins a divergent extra field (decimals, + // tokenProgram, memo, fee payer, maxTimeoutSeconds) is still caught. + acceptedJSON, err := canonicalAccepted(accepted) + if err != nil { + return err + } + routeJSON, err := canonicalAccepted(&route) + if err != nil { + return err + } + if !bytes.Equal(acceptedJSON, routeJSON) { + return errors.New("credential's accepted requirements do not structurally match this route's expected requirements") + } + return nil +} + +// routeAccepts builds the route's advertised accept entry without the +// RPC-backed recentBlockhash stamp, so the echoed-accepted comparison is +// deterministic and offline. recentBlockhash is a client-build hint, not +// part of the binding identity, so it is excluded from both sides. +func (a *Adapter) routeAccepts(gate *paykit.Gate) AcceptsEntry { + coin := a.settlementCoin(gate) + label := a.cfg.Network.MintsLabel() + return 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), + PayTo: string(a.payTo(gate)), + MaxTimeoutSeconds: defaultMaxTimeoutSeconds, + Extra: Extra{ + FeePayer: true, + FeePayerSet: true, + FeePayerKey: string(a.signer.Pubkey()), + Decimals: stablecoinDecimals, + DecimalsSet: true, + TokenProgram: paycore.DefaultTokenProgramForCurrency(coin, label), + Memo: gate.Desc, + }, + } +} + +// canonicalAccepted serializes an accept entry through the typed wire +// shape (ignoring any verbatim raw bytes and the recentBlockhash hint) +// so two entries compare equal iff their binding-relevant fields match. +func canonicalAccepted(e *AcceptsEntry) ([]byte, error) { + clone := *e + clone.raw = nil + clone.Extra.RecentBlockhash = "" + return json.Marshal(clone) +} + // transferRequirements derives the structural-verification target from // the gate + config: recipient, mint pubkey, token program, and the // amount in base units. @@ -328,6 +658,10 @@ func (a *Adapter) transferRequirements(gate *paykit.Gate) (transferRequirements, tokenProgram: tokenProgram, amount: amount, feePayer: feePayer, + // extra.memo is advertised as the gate description. When set, the + // spec requires the verifier to confirm exactly one Memo instruction + // whose data equals it (payment-reference binding). + expectedMemo: gate.Desc, }, nil } diff --git a/go/signer/signer_test.go b/go/signer/signer_test.go index a7a0adfb2..5cd5be43c 100644 --- a/go/signer/signer_test.go +++ b/go/signer/signer_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/solana-foundation/pay-kit/go/signer" @@ -250,6 +251,26 @@ func TestFromEnvAutoDetectsHex(t *testing.T) { } } +func TestFromEnvAutoDetectsUppercaseHex(t *testing.T) { + sk := testSecret(t) + ref, err := signer.FromBytes(sk) + if err != nil { + t.Fatal(err) + } + const name = "PAY_KIT_TEST_SIGNER_HEX_UPPER" + // hex.EncodeToString returns lowercase; convert to uppercase to exercise + // the 'A'-'F' branch of isHex. + upper := strings.ToUpper(hex.EncodeToString(sk)) + t.Setenv(name, upper) + rebuilt, err := signer.FromEnv(name) + if err != nil || rebuilt == nil { + t.Fatalf("uppercase hex: err=%v signer=%v", err, rebuilt) + } + if rebuilt.Pubkey() != ref.Pubkey() { + t.Errorf("env uppercase hex pubkey mismatch") + } +} + func TestMustFromEnvOrDemoFallsBackToDemoWhenUnset(t *testing.T) { _ = os.Unsetenv("PAY_KIT_TEST_X_UNSET") s := signer.MustFromEnvOrDemo("PAY_KIT_TEST_X_UNSET") diff --git a/lua/Justfile b/lua/Justfile index 110a2dcda..c62d9b273 100644 --- a/lua/Justfile +++ b/lua/Justfile @@ -23,11 +23,12 @@ install: test: eval "$(luarocks --lua-version=5.1 --tree lua_modules path)" && luajit tests/run.lua -# Lint the SDK source tree. Pre-existing tech debt in `mpp/server/html.lua` -# and `mpp/server/init.lua` raises 5 warnings; the recipe fails only on -# errors, not warnings, until those are tracked separately. +# Lint the SDK source tree. The framework wrappers live under `plugins/` +# (resty / kong / apisix), not at the repo root. The recipe fails only on +# errors, not warnings, while any residual style warnings are tracked +# separately. lint: - eval "$(luarocks --lua-version=5.1 --tree lua_modules path)" && luacheck mpp/ resty/ kong/ apisix/ tests/ + eval "$(luarocks --lua-version=5.1 --tree lua_modules path)" && luacheck pay_kit/ mpp/ plugins/ tests/ # Run the suite under luacov and enforce the >=90% line-coverage gate. test-cover: @@ -39,7 +40,7 @@ test-cover: # Audit-equivalent: run luarocks lint over the rockspec. This is the closest # analogue to `bundle audit` / `composer audit` in the Lua ecosystem. audit: - luarocks --lua-version=5.1 lint mpp-dev-1.rockspec + luarocks --lua-version=5.1 lint pay-kit-dev-1.rockspec # Format check (no auto-fix; luacheck doubles as a lightweight style gate # until a Lua formatter is selected). diff --git a/lua/README.md b/lua/README.md index fb1ea5516..90f32647c 100644 --- a/lua/README.md +++ b/lua/README.md @@ -514,7 +514,7 @@ lua/ │ ├── kong/plugins/pay-kit/ # Kong plugin (loader path-pinned) │ └── apisix/plugins/pay-kit.lua # APISIX plugin (loader path-pinned) ├── examples/openresty/ # runnable PayKit demo -└── tests/ # luaunit suite + luacov gate +└── tests/ # hand-rolled spec runner (tests/run.lua) + luacov gate ``` ## Coding convention diff --git a/lua/pay_kit/internal/config.lua b/lua/pay_kit/internal/config.lua index 5dacd698c..93e2d251c 100644 --- a/lua/pay_kit/internal/config.lua +++ b/lua/pay_kit/internal/config.lua @@ -44,10 +44,18 @@ local VALID_NETWORKS = { solana_mainnet = true, solana_devnet = true, solana_loc local VALID_ACCEPT_SCHEMES = { x402 = true, mpp = true } local VALID_X402_SCHEMES = { exact = true } +-- Default per-network RPC endpoints used when the caller does not pass an +-- explicit `rpc_url`. The localnet default points at the hosted Surfpool +-- clone of mainnet state (https://402.surfnet.dev:8899) so +-- `configure { network = 'solana_localnet' }` boots against something +-- reachable without the developer running a local validator. This matches +-- the Ruby/`pay_kit.solana.mints` localnet default and the preflight +-- auto-bootstrap path; the previous `http://localhost:8899` only worked +-- when a validator happened to be running locally. local PUBLIC_RPC_URLS = { solana_mainnet = 'https://api.mainnet-beta.solana.com', solana_devnet = 'https://api.devnet.solana.com', - solana_localnet = 'http://localhost:8899', + solana_localnet = 'https://402.surfnet.dev:8899', } local current_config @@ -179,18 +187,42 @@ function M.configure(opts) local mpp = opts.mpp or {} local mpp_realm = mpp.realm or 'App' local mpp_secret = mpp.challenge_binding_secret - local mpp_expires_in = mpp.expires_in or 300 + -- expires_in defaults to a short 300s TTL so issued challenges are not + -- valid indefinitely (parity with Python/Rust/Ruby short-TTL defaults + -- and the PHP/Lua expiry-wiring fix). `expires_in = false` is the + -- explicit development opt-out: challenges are then issued with no + -- expiry. Any non-positive number is rejected so `0` is not silently + -- treated as "never expires". + local mpp_expires_in = mpp.expires_in + if mpp_expires_in == nil then + mpp_expires_in = 300 + end if mpp_secret ~= nil and type(mpp_secret) ~= 'string' then return nil, 'pay_kit: mpp.challenge_binding_secret must be a string or nil' end - if type(mpp_expires_in) ~= 'number' or mpp_expires_in <= 0 then - return nil, 'pay_kit: mpp.expires_in must be a positive number' + if mpp_expires_in ~= false then + if type(mpp_expires_in) ~= 'number' or mpp_expires_in <= 0 then + return nil, 'pay_kit: mpp.expires_in must be a positive number or false (dev opt-out)' + end end -- Preflight opt-out: default true, opt out via opts.preflight = false. local preflight_enabled = opts.preflight if preflight_enabled == nil then preflight_enabled = true end + -- Preserve every caller-supplied mpp.* field (notably mpp.replay_store, + -- the shared atomic store operators inject to satisfy the multi-worker + -- replay-protection warning) while overlaying the normalized realm / + -- secret / expires_in. Rebuilding mpp from only the three normalized + -- fields silently dropped replay_store and any future mpp option. + local mpp_config = {} + for k, v in pairs(mpp) do + mpp_config[k] = v + end + mpp_config.realm = mpp_realm + mpp_config.challenge_binding_secret = mpp_secret + mpp_config.expires_in = mpp_expires_in + current_config = { network = network, accept = accept, @@ -206,11 +238,7 @@ function M.configure(opts) signer_override = x402_signer_override, delegated = x402_facilitator_url ~= nil and x402_facilitator_url ~= '', }, - mpp = { - realm = mpp_realm, - challenge_binding_secret = mpp_secret, - expires_in = mpp_expires_in, - }, + mpp = mpp_config, } -- Convenience accessors on the resolved config table. @@ -226,6 +254,22 @@ function M.configure(opts) -- surfnet cheatcodes. Opt-out via opts.preflight=false or -- PAY_KIT_DISABLE_PREFLIGHT=1. local preflight = require('pay_kit.preflight') + + -- Resolve the MPP challenge-binding secret regardless of whether the + -- (RPC-touching) preflight checks run. When the caller did not pass + -- `mpp.challenge_binding_secret`, this reads + -- PAY_KIT_MPP_CHALLENGE_BINDING_SECRET, then ./.env, then generates a + -- CSPRNG secret and persists it (Ruby preflight.rb parity). Without + -- this the MPP adapter would either crash on a nil secret or rotate a + -- per-process random one on every boot, invalidating challenges. + if mpp_secret == nil or mpp_secret == '' then + local ok_secret, secret_err = pcall(preflight.ensure_challenge_binding_secret, current_config) + if not ok_secret then + current_config = nil + return nil, tostring(secret_err) + end + end + if preflight.should_run(current_config) then local ok, err = pcall(preflight.run, current_config) if not ok then diff --git a/lua/pay_kit/internal/dispatcher.lua b/lua/pay_kit/internal/dispatcher.lua index 1da34ef08..9b6fccc70 100644 --- a/lua/pay_kit/internal/dispatcher.lua +++ b/lua/pay_kit/internal/dispatcher.lua @@ -85,7 +85,11 @@ end -- carries one entry per accepted scheme. local function build_402(d, gate, request) local accepts = {} - local headers = {} + -- A 402 carries a fresh, single-use signed challenge (per-request + -- nonce / blockhash / expiry). It must never be cached or reused by an + -- intermediary, so pin `Cache-Control: no-store`. Mirrors PHP + -- ChargeServer::paymentRequiredResponse and the Ruby/x402 402 helpers. + local headers = { ['cache-control'] = 'no-store' } if gate:x402_accepted() then accepts[#accepts + 1] = d.x402:accepts_entry(gate, request) local h = d.x402:challenge_headers(gate, request) or {} diff --git a/lua/pay_kit/preflight.lua b/lua/pay_kit/preflight.lua index cb8922f38..232ce9684 100644 --- a/lua/pay_kit/preflight.lua +++ b/lua/pay_kit/preflight.lua @@ -27,6 +27,11 @@ local solana_mod = require('pay_kit.solana.mints') local M = {} +-- Env var name + on-disk key for the MPP challenge-binding secret. Matches +-- the Ruby preflight convention (`PAY_KIT_MPP_CHALLENGE_BINDING_SECRET`) +-- so the same orchestrator-supplied env var feeds every server-side port. +M.MPP_SECRET_ENV_VAR = 'PAY_KIT_MPP_CHALLENGE_BINDING_SECRET' + -- 0.001 SOL: ~200 settlement txs at 5000 lamports each. M.MIN_FEE_PAYER_LAMPORTS = 1000000 -- Generous local sandbox budget so a developer can poke the example @@ -90,7 +95,7 @@ end local function check_recipient_ata(config, rpc, coin, network_label, autofix) local mint = solana_mod.resolve_mint(coin, network_label) -- Native SOL: no ATA to check. - if not mint or mint == coin and coin:upper() == 'SOL' then return end + if not mint or (mint == coin and coin:upper() == 'SOL') then return end local token_program = solana_mod.default_token_program_for_currency(coin, network_label) local recipient = config.operator:effective_recipient() local ata = ata_mod.derive(recipient, mint, token_program) @@ -115,7 +120,178 @@ local function check_recipient_ata(config, rpc, coin, network_label, autofix) end end +local function dotenv_path() + return (os.getenv('PWD') or '.') .. '/.env' +end + +-- Lock a file to owner read/write only (0600). Prefers luaposix's chmod +-- (no subprocess, no shell quoting concerns); falls back to `chmod 600` +-- via os.execute. Best effort: a failure to tighten permissions is logged +-- but does not abort persistence (the secret is still written). Returns +-- true if the mode was applied, false otherwise. +local function restrict_file_permissions(path) + local ok_posix, posix = pcall(require, 'posix') + if ok_posix and type(posix) == 'table' then + local chmod = posix.chmod + if type(chmod) ~= 'function' and type(posix.sys) == 'table' + and type(posix.sys.stat) == 'table' then + chmod = posix.sys.stat.chmod + end + if type(chmod) == 'function' then + local ok = pcall(chmod, path, 'rw-------') + if ok then return true end + -- Some bindings expect an octal number rather than a symbolic string. + if pcall(chmod, path, tonumber('600', 8)) then return true end + end + end + -- Fallback: shell out. Single-quote the path and escape embedded quotes + -- so a path with spaces or quotes cannot break out of the argument. + local quoted = "'" .. tostring(path):gsub("'", "'\\''") .. "'" + local ok = os.execute('chmod 600 ' .. quoted) + return ok == true or ok == 0 +end + +-- Read a single key from `./.env`. Returns nil if the file does not exist, +-- the key is absent, or the line is malformed. Tolerant parser: ignores +-- blank lines and `#` comments and supports `KEY=value`, `KEY="value"`, +-- and `KEY='value'`. No external dotenv dependency (parity with Ruby's +-- Preflight.read_dotenv_value). +function M.read_dotenv_value(key, path) + path = path or dotenv_path() + local fh = io.open(path, 'r') + if not fh then return nil end + local value + for line in fh:lines() do + local stripped = line:gsub('^%s+', ''):gsub('%s+$', '') + if stripped ~= '' and stripped:sub(1, 1) ~= '#' then + local name, raw = stripped:match('^([^=]+)=(.*)$') + if name and name:gsub('%s+$', '') == key then + value = raw:gsub('^%s+', ''):gsub('%s+$', '') + if (value:sub(1, 1) == '"' and value:sub(-1) == '"') + or (value:sub(1, 1) == "'" and value:sub(-1) == "'") then + value = value:sub(2, -2) + end + break + end + end + end + fh:close() + return value +end + +-- Append a `KEY="value"` line to `./.env`, creating the file if absent. +-- Returns true on success, false if the directory is unwritable (parity +-- with Ruby's Preflight.persist_dotenv_value). +function M.persist_dotenv_value(key, value, path) + path = path or dotenv_path() + local existing = io.open(path, 'r') + local need_newline = false + local file_pre_existed = existing ~= nil + if existing then + local body = existing:read('*a') + existing:close() + need_newline = body ~= '' and body:sub(-1) ~= '\n' + end + local fh = io.open(path, 'a') + if not fh then return false end + if need_newline then fh:write('\n') end + fh:write(string.format('%s="%s"\n', key, value)) + fh:close() + -- A freshly created .env holds the CSPRNG challenge-binding secret, which + -- must not be world-readable (default umask 022 leaves it 644). Lock it to + -- owner read/write only. We only touch newly created files so we never + -- relax or override permissions an operator set on a pre-existing .env. + if not file_pre_existed then + restrict_file_permissions(path) + end + return true +end + +-- Cryptographically secure 32-byte hex secret. Prefers luasodium's +-- randombytes_buf, then /dev/urandom, then a last-resort os.time-seeded +-- math.random (logged as weak). 64 hex chars == 32 bytes, matching Ruby's +-- SecureRandom.hex(32). +function M.secure_random_hex(num_bytes) + num_bytes = num_bytes or 32 + local hex_chars = '0123456789abcdef' + + local ok_sodium, sodium = pcall(require, 'luasodium') + if ok_sodium and type(sodium.randombytes_buf) == 'function' then + local raw = sodium.randombytes_buf(num_bytes) + if type(raw) == 'string' and #raw == num_bytes then + return (raw:gsub('.', function(c) return string.format('%02x', string.byte(c)) end)) + end + end + + local fh = io.open('/dev/urandom', 'rb') + if fh then + local raw = fh:read(num_bytes) + fh:close() + if type(raw) == 'string' and #raw == num_bytes then + return (raw:gsub('.', function(c) return string.format('%02x', string.byte(c)) end)) + end + end + + log_warn('no CSPRNG available (luasodium/dev-urandom); falling back to a ' .. + 'weak math.random secret. Set ' .. M.MPP_SECRET_ENV_VAR .. + ' explicitly in production.') + math.randomseed(os.time() + os.clock() * 1000000) + local out = {} + for i = 1, num_bytes * 2 do + local idx = math.random(1, 16) + out[i] = hex_chars:sub(idx, idx) + end + return table.concat(out) +end + +-- Resolve `config.mpp.challenge_binding_secret` when the caller did not set +-- it explicitly. Resolution order, first hit wins (parity with Ruby +-- Preflight.ensure_challenge_binding_secret!): +-- +-- 1. `os.getenv(PAY_KIT_MPP_CHALLENGE_BINDING_SECRET)` — the production +-- pattern (orchestrator-supplied env var). +-- 2. `./.env` in the current working directory — sticky across restarts. +-- 3. A freshly generated CSPRNG secret, persisted to `./.env` so +-- subsequent boots reuse it. If `./.env` is unwritable the secret +-- rotates per boot (logged), which invalidates in-flight challenges. +function M.ensure_challenge_binding_secret(config) + if config.mpp.challenge_binding_secret and config.mpp.challenge_binding_secret ~= '' then + return config.mpp.challenge_binding_secret + end + + local from_env = os.getenv(M.MPP_SECRET_ENV_VAR) + if from_env and from_env ~= '' then + config.mpp.challenge_binding_secret = from_env + return from_env + end + + local from_dotenv = M.read_dotenv_value(M.MPP_SECRET_ENV_VAR) + if from_dotenv and from_dotenv ~= '' then + config.mpp.challenge_binding_secret = from_dotenv + return from_dotenv + end + + local generated = M.secure_random_hex(32) + local persisted = M.persist_dotenv_value(M.MPP_SECRET_ENV_VAR, generated) + if persisted then + log_info('generated ' .. M.MPP_SECRET_ENV_VAR .. ' and wrote it to ./.env. ' .. + 'Add `.env` to .gitignore and override via your orchestrator in production.') + else + log_warn('generated ' .. M.MPP_SECRET_ENV_VAR .. ' but could not persist to ./.env; ' .. + 'the secret will rotate on every boot, invalidating in-flight challenges. ' .. + 'Set ' .. M.MPP_SECRET_ENV_VAR .. ' explicitly to make it sticky.') + end + config.mpp.challenge_binding_secret = generated + return generated +end + function M.run(config) + -- Secret resolution runs first: it needs no RPC and must not be skipped + -- when the network is unreachable. + if config.mpp then + M.ensure_challenge_binding_secret(config) + end + local rpc_url = config.rpc_url or solana_mod.default_rpc_url( pay_kit_network_label(config.network)) local rpc = rpc_mod.new({url = rpc_url, transport = rpc_transport.new()}) diff --git a/lua/pay_kit/protocols/mpp/expires.lua b/lua/pay_kit/protocols/mpp/expires.lua index 975d1651e..49cf0b5d5 100644 --- a/lua/pay_kit/protocols/mpp/expires.lua +++ b/lua/pay_kit/protocols/mpp/expires.lua @@ -96,6 +96,14 @@ function M.parse_rfc3339(value) return ((days * 24 + hour) * 60 + min) * 60 + sec + offset_secs end +-- Format a UTC epoch second as a strict RFC 3339 / ISO 8601 timestamp +-- (`YYYY-MM-DDTHH:MM:SSZ`), the wire form the `expires` challenge field +-- and `parse_rfc3339` round-trip on. Used by the MPP adapter to turn a +-- config `expires_in` (seconds-from-now) into an absolute expiry. +function M.format_rfc3339(epoch) + return os.date('!%Y-%m-%dT%H:%M:%SZ', epoch) +end + function M.is_expired(value, now_epoch) if value == nil or value == '' then return false diff --git a/lua/pay_kit/protocols/mpp/init.lua b/lua/pay_kit/protocols/mpp/init.lua index adefc82d1..f04365ace 100644 --- a/lua/pay_kit/protocols/mpp/init.lua +++ b/lua/pay_kit/protocols/mpp/init.lua @@ -24,12 +24,36 @@ dispatcher (P6) owns the across-request cache. local mpp_server = require('pay_kit.protocols.mpp.server') local mpp_intents = require('pay_kit.protocols.mpp.charge') local mpp_protocol = require('pay_kit.solana.mints') +local expires_mod = require('pay_kit.protocols.mpp.expires') local error_codes = require('pay_kit.protocol.core.error_codes') local M = {} local Adapter = {} Adapter.__index = Adapter +-- Emit a one-shot warning when the MPP replay store falls back to the +-- volatile in-memory default. Localnet is exempt (single-worker dev is the +-- expected shape there); mainnet/devnet warn so an operator who forgot to +-- wire a shared store is told at first server build rather than after a +-- cross-worker double-spend. +local _warned_volatile_replay_store = false +local function warn_volatile_replay_store(network) + if network == 'localnet' then return end + if _warned_volatile_replay_store then return end + _warned_volatile_replay_store = true + local msg = 'pay_kit: MPP replay protection is using the default in-memory ' .. + 'store, which is process-local and lost on restart. On a multi-worker or ' .. + 'multi-node deploy a settled signature can be replayed against another ' .. + 'worker. Supply config.mpp.replay_store with a shared (ngx.shared.dict / ' .. + 'Redis-backed) store in production.' + local ngx_ref = rawget(_G, 'ngx') + if ngx_ref and ngx_ref.log and ngx_ref.WARN then + ngx_ref.log(ngx_ref.WARN, msg) + else + io.stderr:write('[pay_kit] WARN: ' .. msg .. '\n') + end +end + local function map_pay_kit_network(network) -- The legacy mpp.server.new accepts "mainnet" / "devnet" / "localnet"; -- pay_kit config uses solana_* prefixes. @@ -74,10 +98,24 @@ local function build_mpp_server(config, gate, store) -- store. The `store` argument from the dispatcher is reserved for -- the x402 adapter. local _ = store + -- Replay store. The default `store.memory()` is process-local and lost + -- on worker restart, so it only protects against replays seen by the + -- SAME worker since boot - acceptable for single-worker dev, NOT for a + -- multi-worker / multi-node production deploy where a replay reservation + -- must be visible across all settlers. Callers wire a shared store + -- (e.g. an ngx.shared.dict / Redis-backed adapter) via + -- `config.mpp.replay_store`; when none is supplied we fall back to the + -- volatile in-memory store and warn once so the dev-only nature is + -- explicit. Mirrors the Ruby/PHP "default volatile replay store" caveat. + local replay_store = config.mpp and config.mpp.replay_store + if not replay_store then + replay_store = store_mod.memory() + warn_volatile_replay_store(network) + end local handler = charge_handler.new({ rpc = rpc, network = network, - replay_store = store_mod.memory(), + replay_store = replay_store, transaction_verifier = verifier_bundle.transaction_verifier, pull_transaction_signer = verifier_bundle.pull_transaction_signer, pull_blockhash_extractor = verifier_bundle.pull_blockhash_extractor, @@ -196,11 +234,22 @@ end -- amount (e.g. "0.001") and multiplies by 10^decimals internally; -- the gate.amount's `amount_string()` already carries that form. function Adapter:challenge_headers(gate, _req) - local server, _config = self:_server_for(gate) + local server, config = self:_server_for(gate) local display_amount = gate:amount():amount_string() local options = {} local splits = splits_for(gate) if splits then options.splits = splits end + -- Wire the configured challenge TTL into issuance so signed challenges + -- are not valid indefinitely. `config.mpp.expires_in` is seconds-from-now + -- (default 300); `false` is the explicit development opt-out that leaves + -- the challenge without an expiry. Mirrors PHP/Ruby/Python which seed a + -- short TTL at challenge construction rather than relying on every caller + -- to pass one. `verify_credential_with_expected` enforces the expiry via + -- `challenge_value:is_expired`. + local expires_in = config.mpp.expires_in + if type(expires_in) == 'number' and expires_in > 0 then + options.expires = expires_mod.format_rfc3339(os.time() + expires_in) + end local ok, challenge = pcall(server.charge_with_options, server, display_amount, options) if not ok then -- Server-side rejection at challenge time (e.g. splits > 8 or @@ -235,10 +284,44 @@ function Adapter:verify_and_settle(gate, req) return nil, 'pay_kit: invalid proof: ' .. tostring(parse_err) end + -- The route-expected request must carry the FULL on-chain shape the + -- challenge was issued with, not just amount/currency/recipient. + -- `verify_credential_with_expected` now binds methodDetails + externalId + -- (stripping only recentBlockhash) and settles from `expected`, so a + -- credential issued for a different shape (different splits, fee payer, + -- or token program) on the same price/recipient is rejected. Reconstruct + -- the same methodDetails `charge_with_options` builds inside + -- `build_mpp_server` (network, decimals/tokenProgram for SPL, splits, + -- feePayer/feePayerKey). Mirrors PHP Adapter::chargeRequestFor and Ruby + -- Mpp::Server::Charge#charge which both pass the route's full request + -- into verification. + local currency = gate:amount():primary_coin() + local is_native_sol = string.lower(currency or '') == 'sol' + local expected_method_details = { + network = server.network, + } + if not is_native_sol then + expected_method_details.decimals = server.decimals + if mpp_protocol.stablecoin_symbol(currency) then + expected_method_details.tokenProgram = + mpp_protocol.default_token_program_for_currency(currency, server.network) + end + end + local expected_splits = splits_for(gate) + if expected_splits then + expected_method_details.splits = expected_splits + end + if server.fee_payer then + expected_method_details.feePayer = true + if server.fee_payer_key then + expected_method_details.feePayerKey = server.fee_payer_key + end + end local expected = { - amount = tostring(gate:total_units()), - currency = gate:amount():primary_coin(), - recipient = gate:pay_to(), + amount = tostring(gate:total_units()), + currency = currency, + recipient = gate:pay_to(), + methodDetails = expected_method_details, } local ok, result_or_err = pcall(function() return server:verify_credential_with_expected(credential, expected) diff --git a/lua/pay_kit/protocols/mpp/server/init.lua b/lua/pay_kit/protocols/mpp/server/init.lua index 53224bb5b..0e762bea8 100644 --- a/lua/pay_kit/protocols/mpp/server/init.lua +++ b/lua/pay_kit/protocols/mpp/server/init.lua @@ -1,3 +1,4 @@ +local canonical_json = require('pay_kit.util.json') local challenge = require('pay_kit.protocol.core.challenge') local error_codes = require('pay_kit.protocol.core.error_codes') local html_module = require('pay_kit.protocols.mpp.server.html') @@ -27,6 +28,31 @@ local function bool_or_nil(value) return value and true or false end +-- Return a copy of `method_details` with the per-request freshness field +-- `recentBlockhash` removed, so two requests that differ only in the +-- blockhash compare equal. Mirrors PHP ChargeServer::comparableRequest +-- (strips methodDetails.recentBlockhash) and Ruby +-- ChallengeStore#comparable_method_details (`(details || {}).except(...)`). +local function comparable_method_details(method_details) + local out = {} + if type(method_details) == 'table' then + for k, v in pairs(method_details) do + if k ~= 'recentBlockhash' then + out[k] = v + end + end + end + return out +end + +-- Canonical (RFC 8785) JSON serialization is used for the methodDetails +-- and externalId comparison so field ordering and nested split tables +-- compare structurally, not by Lua table identity. This is the Lua +-- analogue of PHP's Base64Url::encodeJson(canonicalizeArray(...)). +local function canonical(value) + return canonical_json.encode(value) +end + function M.new(config) if type(config) ~= 'table' then error('config table is required') @@ -189,18 +215,75 @@ function Server:verify_credential_with_expected(credential_value, expected, now_ 'recipient mismatch: credential was issued for a different recipient') end - -- Settlement runs against a hybrid request: the pinned route fields - -- come from `expected` (so a credential issued for a cheaper route - -- cannot settle here), but the on-chain shape parameters - -- (`methodDetails`: splits, feePayer, decimals, tokenProgram, etc.) and - -- the externalId come from the credential. The credential's HMAC and - -- pinned-field checks above already authenticate those secondary fields. + -- Full route binding. amount/currency/recipient alone are NOT enough + -- WHEN the route pins an on-chain shape: a credential issued for the same + -- price and recipient but a different on-chain shape (splits, + -- feePayer/feePayerKey, tokenProgram, decimals) or a different externalId + -- must not settle against a route that pins those. Compare the FULL + -- methodDetails (stripping only the per-request freshness field + -- recentBlockhash) and the externalId against the route's expected + -- request. Mirrors PHP ChargeServer::matchesExpectedRequest (canonical + -- compare after removing methodDetails.recentBlockhash) and Ruby + -- ChallengeStore#verify_expected (amount/currency/recipient + + -- comparable_method_details). + -- + -- The adapter / route-binding path supplies expected.methodDetails (and + -- optionally expected.externalId), so the full-compare security check + -- runs there. The documented minimal expected form + -- {amount, currency, recipient} (methodDetails omitted, externalId + -- omitted) does NOT pin an on-chain shape, so there is nothing to bind + -- against: the three pinned fields above are the whole contract. Only + -- run the methodDetails / externalId binding when the caller actually + -- supplied them. When omitted, the credential's own methodDetails / + -- externalId become the settlement defaults (derived below), so settling + -- from `expected` does not widen the on-chain contract. + local expected_has_method_details = type(expected.methodDetails) == 'table' + if expected_has_method_details then + if canonical(comparable_method_details(cred_request.methodDetails)) ~= + canonical(comparable_method_details(expected.methodDetails)) then + error_codes.raise(error_codes.CHARGE_REQUEST_MISMATCH, + 'method details mismatch: credential method details do not match this route') + end + end + if expected.externalId ~= nil then + if (cred_request.externalId or '') ~= (expected.externalId or '') then + error_codes.raise(error_codes.CHARGE_REQUEST_MISMATCH, + 'externalId mismatch: credential was issued for a different externalId') + end + end + + -- Settlement runs against the ROUTE-expected request, not the + -- credential's claims. When the route pinned methodDetails, the binding + -- check above proved the credential's methodDetails equal the route's + -- (modulo recentBlockhash), so settling from `expected.methodDetails` + -- cannot widen the on-chain contract. When the caller used the minimal + -- expected form (methodDetails / externalId omitted), there was nothing + -- to widen: the credential's own methodDetails / externalId ARE the + -- contract, so we carry them forward as the settlement defaults. Either + -- way we keep the credential's recentBlockhash (a freshness value the + -- client already committed to and the route does not pin) and the + -- credential description for the receipt. + local settlement_method_details = {} + local method_details_source = + (type(expected.methodDetails) == 'table' and expected.methodDetails) + or (type(cred_request.methodDetails) == 'table' and cred_request.methodDetails) + or nil + if method_details_source then + for k, v in pairs(method_details_source) do + settlement_method_details[k] = v + end + end + if settlement_method_details.recentBlockhash == nil + and type(cred_request.methodDetails) == 'table' + and cred_request.methodDetails.recentBlockhash ~= nil then + settlement_method_details.recentBlockhash = cred_request.methodDetails.recentBlockhash + end local settlement_request = { amount = expected.amount, currency = expected.currency, recipient = expected.recipient, - methodDetails = cred_request.methodDetails, - externalId = cred_request.externalId, + methodDetails = settlement_method_details, + externalId = expected.externalId or cred_request.externalId, description = cred_request.description, } return self:_finalize_verification(credential_value, settlement_request, payload) diff --git a/lua/pay_kit/protocols/mpp/server/solana_verify.lua b/lua/pay_kit/protocols/mpp/server/solana_verify.lua index 7785ad8dd..73b68affb 100644 --- a/lua/pay_kit/protocols/mpp/server/solana_verify.lua +++ b/lua/pay_kit/protocols/mpp/server/solana_verify.lua @@ -1,6 +1,7 @@ local uint = require('pay_kit.util.uint') local protocol = require('pay_kit.solana.mints') local error_codes = require('pay_kit.protocol.core.error_codes') +local ata = require('pay_kit.solana.ata') local M = {} @@ -128,6 +129,22 @@ local function verify_confirmed_transaction(reference, tx, request, method_detai end local instructions = tx.transaction and tx.transaction.message and tx.transaction.message.instructions or {} + -- Include inner (CPI) instructions from the confirmed transaction meta. + -- Mirrors the Rust spine extract_parsed_instructions (charge.rs:2218-2230): + -- transfers / splits emitted through a CPI live in + -- meta.innerInstructions[*].instructions and must be visible to the + -- transfer matchers and the allowlist, otherwise a settled transaction + -- whose payment was made via CPI would fail to match. + if tx.meta and type(tx.meta.innerInstructions) == 'table' then + local combined = {} + for _, ix in ipairs(instructions) do combined[#combined + 1] = ix end + for _, group in ipairs(tx.meta.innerInstructions) do + for _, ix in ipairs(group.instructions or {}) do + combined[#combined + 1] = ix + end + end + instructions = combined + end if is_native_sol(request.currency) then verify_sol_transfers(instructions, request) else @@ -179,6 +196,12 @@ function verify_spl_transfers(instructions, request, method_details, hooks) if program_id ~= TOKEN_PROGRAM and program_id ~= TOKEN_2022_PROGRAM then error_codes.raise(error_codes.PAYMENT_INVALID, 'unsupported token program: ' .. tostring(program_id)) end + -- Pin the transferChecked decimals byte to the challenge-declared + -- decimals. Mirrors the Rust spine (charge.rs:1623-1624): a + -- transferChecked whose decimals disagree with the expected token is + -- not a match. nil means the route did not pin decimals, so any value + -- is accepted (rust `expected_decimals.is_some_and`). + local expected_decimals = method_details.decimals local transfers = {} for _, ix in ipairs(instructions or {}) do if ix.parsed and ix.parsed.type == 'transferChecked' and normalize_program_id(ix) == program_id then @@ -189,7 +212,11 @@ function verify_spl_transfers(instructions, request, method_details, hooks) local found = false for idx, ix in ipairs(transfers) do local info = instruction_info(ix) - if info and info.mint == mint and uint.compare(info.tokenAmount.amount, want.amount) == 0 then + local decimals_ok = expected_decimals == nil + or (info and info.tokenAmount + and tonumber(info.tokenAmount.decimals) == tonumber(expected_decimals)) + if info and decimals_ok and info.mint == mint + and uint.compare(info.tokenAmount.amount, want.amount) == 0 then local account = hooks.fetch_token_account(info.destination) if account and account.owner == want.recipient and account.mint == mint then remove_at(transfers, idx) @@ -357,6 +384,24 @@ function verify_instruction_allowlist(instructions, request, method_details) and method_details.feePayerKey ~= '' then fee_payer_pubkey = method_details.feePayerKey end + -- When a fee payer is configured, derive its associated token account so + -- a transferChecked that SOURCES funds from the fee-payer's ATA (even + -- under a different authority) is rejected. Mirrors the Rust spine + -- (charge.rs:1649-1657 "Fee payer token account cannot fund the SPL + -- payment transfer"). The authority guard below catches the + -- authority==fee_payer shape; this catches the source-ATA shape. + local fee_payer_atas = nil + if fee_payer_pubkey ~= nil and request and request.currency + and not is_native_sol(request.currency) then + local mint = protocol.resolve_mint(request.currency, method_details.network) + if mint then + fee_payer_atas = {} + for _, prog in ipairs({TOKEN_PROGRAM, TOKEN_2022_PROGRAM}) do + local ok, derived = pcall(ata.derive, fee_payer_pubkey, mint, prog) + if ok and derived then fee_payer_atas[derived] = true end + end + end + end for _, ix in ipairs(instructions or {}) do local program = resolve_program(ix) if not allowed[program] then @@ -386,6 +431,12 @@ function verify_instruction_allowlist(instructions, request, method_details) if info.multisigAuthority == fee_payer_pubkey then error('payment_invalid: fee payer cannot authorize the SPL payment transfer') end + if fee_payer_atas and info.source and fee_payer_atas[info.source] then + -- Mirrors rust ``verify_spl_transfer_instructions`` source-ATA + -- guard: the fee-payer's own token account cannot fund the + -- payment, even when the transfer authority is some other key. + error('payment_invalid: fee payer token account cannot fund the SPL payment transfer') + end end end end diff --git a/lua/pay_kit/protocols/x402/exact/verify.lua b/lua/pay_kit/protocols/x402/exact/verify.lua index fd641ec9a..31765fcb2 100644 --- a/lua/pay_kit/protocols/x402/exact/verify.lua +++ b/lua/pay_kit/protocols/x402/exact/verify.lua @@ -16,7 +16,7 @@ Rules: 6. Mint match (verify.rs:395-400) 7. Destination ATA match (re-derive) (verify.rs:402-405) 8. Amount match (verify.rs:407-410) - 9. ix[3..6] in allowlist (memo + lighthouse + optional ATA-create) + 9. ix[3..6] in allowlist (Memo + Lighthouse ONLY; ATA-create rejected) 10. Memo binding (exactly one if extra.memo set) 11. Token program strict bind to extra.tokenProgram @@ -31,27 +31,60 @@ local base58 = require('pay_kit.solana.base58') local tx_mod = require('pay_kit.solana.transaction') local ata = require('pay_kit.solana.ata') local ed25519 = require('pay_kit.util.ed25519') +local uint = require('pay_kit.util.uint') local M = {} local COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111' local MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr' -local LIGHTHOUSE_PROGRAM = 'L1TEVtgA75k273wWz1s6XMmDhQY5i3MwcvKb4VbZzfK' -local ASSOCIATED_TOKEN_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' +-- Official x402 SVM exact Lighthouse program id (specs/schemes/exact/ +-- scheme_exact_svm.md), matching the PHP (Verifier::LIGHTHOUSE_PROGRAM) +-- and Go (lighthouseProgram) verifiers. The prior `L1TEVtgA75k...` value +-- was wrong and would have rejected wallet-injected Lighthouse guards. +local LIGHTHOUSE_PROGRAM = 'L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95' +local TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' local TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb' -local MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 50000 +-- Mirrors the Rust spine constant +-- (rust/crates/x402/src/protocol/schemes/exact/verify.rs:17). The prior +-- 50_000 value rejected canonical wallet transactions whose compute-unit +-- price legitimately sits above 50k but under the protocol cap. +local MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5000000 + +-- Multiply an unsigned-decimal string by a small integer (< 2^31). +local function mul_small(decimal, factor) + local carry, out = 0, {} + for i = #decimal, 1, -1 do + local product = tonumber(decimal:sub(i, i)) * factor + carry + out[#out + 1] = tostring(product % 10) + carry = math.floor(product / 10) + end + while carry > 0 do + out[#out + 1] = tostring(carry % 10) + carry = math.floor(carry / 10) + end + local chars = {} + for idx = #out, 1, -1 do chars[#chars + 1] = out[idx] end + local text = table.concat(chars):gsub('^0+', '') + return text == '' and '0' or text +end --- Read a little-endian u64 from a binary string at offset 1-based. +-- Read a little-endian u64 from a binary string at offset 1-based and +-- return it as an exact decimal string. Mirrors the Rust spine's +-- `u64::from_le_bytes` (verify.rs:405-409 / :350-354): a float-based +-- reconstruction loses precision above 2^53, so a malicious amount or +-- compute-unit price in the high u64 range would round to a different +-- value than the one signed on-chain. Decode byte-wise so the full u64 +-- range is exact. local function read_u64_le(s, start) if not s or #s < start + 7 then error('invalid_exact_svm_payload_no_transfer_instruction') end - local b1, b2, b3, b4, b5, b6, b7, b8 = s:byte(start, start + 7) - -- Use multiplication so we stay within LuaJIT 53-bit int range; SPL - -- amounts and compute-unit prices never exceed it in practice. - return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216 + - b5 * 4294967296 + b6 * 1099511627776 + b7 * 281474976710656 + - b8 * 72057594037927936 + -- Accumulate big-endian: total = total * 256 + byte, from MSB to LSB. + local total = '0' + for offset = 7, 0, -1 do + total = uint.add(mul_small(total, 256), tostring(s:byte(start + offset))) + end + return total end -- Look up the program id (base58 string) for an instruction. @@ -81,7 +114,7 @@ local function verify_compute_price(ix, account_keys) error('invalid_exact_svm_payload_transaction_instructions_compute_price_instruction') end local micro = read_u64_le(data, 2) - if micro > MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS then + if uint.compare(micro, tostring(MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS)) > 0 then error('invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high') end end @@ -108,8 +141,13 @@ end -- instruction. local function verify_transfer(ix, account_keys, requirement, managed_signers) local program = program_of(account_keys, ix) - local token_program_extra = string_extra(requirement, 'tokenProgram', true) - if program ~= token_program_extra and program ~= TOKEN_2022_PROGRAM then + -- Bind the transfer program to the canonical SPL token program set, + -- NOT to `extra.tokenProgram`. Mirrors the Rust spine + -- (verify.rs:373): the program id is accepted iff it is TOKEN_PROGRAM + -- or TOKEN_2022_PROGRAM, derived from the actual instruction. A + -- canonical offer may omit `extra.tokenProgram`, so requiring it would + -- reject a spec-valid credential the Rust verifier accepts. + if program ~= TOKEN_PROGRAM and program ~= TOKEN_2022_PROGRAM then error('invalid_exact_svm_payload_no_transfer_instruction') end local data = ix.data @@ -149,11 +187,18 @@ local function verify_transfer(ix, account_keys, requirement, managed_signers) error('invalid_exact_svm_payload_recipient_mismatch') end - -- Rule 8: amount match. + -- Rule 8: amount match. Compare as exact unsigned decimals so a + -- full-range u64 amount cannot collide with a different value through + -- float rounding (mirrors Rust's u64 equality, verify.rs:414). local amount = read_u64_le(data, 2) - local expected_amount = tonumber(b58_field(requirement, 'amount')) or - tonumber(requirement.maxAmountRequired or '') - if amount ~= expected_amount then + local expected_amount = requirement.amount + if expected_amount == nil or expected_amount == '' then + expected_amount = requirement.maxAmountRequired + end + if type(expected_amount) ~= 'string' or expected_amount == '' then + error('invalid_exact_svm_payload_amount_mismatch') + end + if uint.compare(amount, expected_amount) ~= 0 then error('invalid_exact_svm_payload_amount_mismatch') end @@ -167,26 +212,6 @@ local function verify_transfer(ix, account_keys, requirement, managed_signers) } end --- Optional ATA-create slot (intentional divergence from spine to --- allow buyer-funded destination ATA creation in slots 3-4). -local function valid_ata_create(ix, account_keys, requirement, transfer) - if program_of(account_keys, ix) ~= ASSOCIATED_TOKEN_PROGRAM then return false end - -- The ATA-create instruction's accounts (per the SPL Associated - -- Token Program): [payer, ata, owner, mint, system, token_program] - -- with discriminator 0 (Create) or 1 (CreateIdempotent) in data[0]. - local data = ix.data - if #data < 1 or (data:byte(1) ~= 0 and data:byte(1) ~= 1) then return false end - if #ix.accounts < 6 then return false end - local ata_account = account_at(account_keys, ix, 1) - local owner = account_at(account_keys, ix, 2) - local mint = account_at(account_keys, ix, 3) - local expected_owner = requirement.payTo - if owner ~= expected_owner then return false end - if mint ~= transfer.mint then return false end - if ata_account ~= transfer.destination then return false end - return true -end - local function find_memo_match(account_keys, instructions, expected_memo) local memo_count, last_memo_data = 0, nil for i = 4, #instructions do @@ -227,8 +252,12 @@ function M.verify(transaction_b64, requirement, managed_signers) local transfer = verify_transfer(instructions[3], parsed.message.account_keys, requirement, managed_signers) - -- Rule 9: slots 3..6 allowlist. - local destination_create_ata = false + -- Rule 9: ix[3..6] allowlist. Optional slots may carry ONLY Lighthouse + -- (wallet-injected guard) or SPL Memo. An Associated-Token-Program + -- ATA-create is NOT permitted: per the official x402 SVM exact contract + -- the destination ATA MUST pre-exist. Lighthouse is allowed in ANY + -- optional slot because wallets inject a variable number of guards + -- (Phantom 1, Solflare 2). Mirrors php Verifier and go verify.go. local reasons = { 'invalid_exact_svm_payload_unknown_fourth_instruction', 'invalid_exact_svm_payload_unknown_fifth_instruction', @@ -238,13 +267,7 @@ function M.verify(transaction_b64, requirement, managed_signers) local ix = instructions[i] local program = program_of(parsed.message.account_keys, ix) local slot_index = i - 4 -- 0-based offset within slots 3..5 - local allowed = (program == MEMO_PROGRAM) or - (slot_index < 2 and program == LIGHTHOUSE_PROGRAM) - if not allowed and slot_index < 2 and - valid_ata_create(ix, parsed.message.account_keys, requirement, transfer) then - destination_create_ata = true - allowed = true - end + local allowed = (program == MEMO_PROGRAM) or (program == LIGHTHOUSE_PROGRAM) if not allowed then error(reasons[slot_index + 1] or 'invalid_exact_svm_payload_unknown_optional_instruction') end @@ -256,7 +279,6 @@ function M.verify(transaction_b64, requirement, managed_signers) find_memo_match(parsed.message.account_keys, instructions, expected_memo) end - transfer.destination_create_ata = destination_create_ata return transfer end diff --git a/lua/tests/charge_handler_spec.lua b/lua/tests/charge_handler_spec.lua index 61c3f9cbb..e9ca4b631 100644 --- a/lua/tests/charge_handler_spec.lua +++ b/lua/tests/charge_handler_spec.lua @@ -511,6 +511,12 @@ t.test('Kong-style shared replay_store does not double-consume on first payment' }) local challenge = server:charge('1.00') + -- The route-expected request must carry the SAME methodDetails the + -- challenge was issued with; `verify_credential_with_expected` now binds + -- the full methodDetails (modulo recentBlockhash). Decode the issued + -- request so the expected shape matches exactly (a real route rebuilds + -- the same methodDetails it advertised). + local expected_request = challenge.request:decode() local function build_credential() return mpp.NewPaymentCredential(challenge:to_echo(), { type = 'transaction', @@ -521,11 +527,7 @@ t.test('Kong-style shared replay_store does not double-consume on first payment' -- First valid settlement must succeed; the shared store's consume -- happens once (inside settle_pull) and the outer finalize honors the -- `consumed` signal so it does not re-assert the same key. - local receipt = server:verify_credential_with_expected(build_credential(), { - amount = '1000000', - currency = 'USDC', - recipient = RECIPIENT, - }) + local receipt = server:verify_credential_with_expected(build_credential(), expected_request) t.assert_equal(receipt.reference, 'sig-kong-1') -- A second settlement with the same signature must hit the durable @@ -551,11 +553,7 @@ t.test('Kong-style shared replay_store does not double-consume on first payment' verify_payment = replay_handler:as_callback(), }) local ok, err = pcall(function() - replay_server:verify_credential_with_expected(build_credential(), { - amount = '1000000', - currency = 'USDC', - recipient = RECIPIENT, - }) + replay_server:verify_credential_with_expected(build_credential(), expected_request) end) t.assert_true(not ok, 'replay must be rejected') local message = type(err) == 'table' and err.message or tostring(err) diff --git a/lua/tests/cross_route_replay_spec.lua b/lua/tests/cross_route_replay_spec.lua index 5c4319478..f487740f5 100644 --- a/lua/tests/cross_route_replay_spec.lua +++ b/lua/tests/cross_route_replay_spec.lua @@ -128,3 +128,179 @@ t.test('with_expected accepts matching route', function() t.assert_equal(receipt.status, 'success') t.assert_equal(receipt.challengeId, challenge.id) end) + +-- ── LUA-6 route binding: methodDetails + externalId must be bound ────────── + +-- Issue a challenge with a given splits/fee-payer shape, then attempt to +-- settle it against a route whose expected request carries a DIFFERENT +-- shape. Pre-fix this passed (only amount/currency/recipient were +-- compared); post-fix it must raise charge_request_mismatch. + +t.test('with_expected rejects mismatched splits in methodDetails', function() + local server = new_server() + -- Credential issued for a route that splits 100 base-units to a fee + -- recipient. + local challenge = server:charge_with_options('1', { + splits = {{recipient = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ', amount = '100'}}, + }) + local credential = bogus_signature_credential(challenge:to_echo()) + + -- Route expects the SAME price/recipient but a different split amount. + local expected = challenge.request:decode() + expected.methodDetails.splits = { + {recipient = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ', amount = '500'}, + } + + t.assert_error(function() + server:verify_credential_with_expected(credential, expected, 1770000000) + end, 'method details') +end) + +-- Pull-mode (transaction-payload) credential so the push-mode + fee-payer +-- guard does not fire first; this isolates the methodDetails-binding +-- rejection on feePayerKey. +local function bogus_transaction_credential(echo) + return mpp.NewPaymentCredential(echo, { + type = 'transaction', + transaction = 'ZmFrZS10eC1iYXNlNjQ=', + }) +end + +t.test('with_expected rejects mismatched feePayerKey in methodDetails', function() + local server = new_server() + local challenge = server:charge_with_options('1', { + fee_payer = true, + fee_payer_key = 'FeePayerAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }) + local credential = bogus_transaction_credential(challenge:to_echo()) + + local expected = challenge.request:decode() + expected.methodDetails.feePayerKey = 'AttackerBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + + t.assert_error(function() + server:verify_credential_with_expected(credential, expected, 1770000000) + end, 'method details') +end) + +t.test('with_expected rejects mismatched externalId', function() + local server = new_server() + local challenge = server:charge_with_options('1', {external_id = 'order-123'}) + local credential = bogus_signature_credential(challenge:to_echo()) + + local expected = challenge.request:decode() + expected.externalId = 'order-999' + + t.assert_error(function() + server:verify_credential_with_expected(credential, expected, 1770000000) + end, 'externalId') +end) + +t.test('with_expected ignores recentBlockhash drift in methodDetails', function() + -- recentBlockhash is a per-request freshness field, not a route binding; + -- a credential carrying a blockhash must still settle against a route + -- whose expected request omits / differs on it. + local server = new_server() + local challenge = server:charge_with_options('1', { + recent_blockhash = 'BlockhashAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }) + local credential = bogus_signature_credential(challenge:to_echo()) + + local expected = challenge.request:decode() + expected.methodDetails.recentBlockhash = 'BlockhashZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ' + + local receipt = server:verify_credential_with_expected(credential, expected, 1770000000) + t.assert_equal(receipt.status, 'success') +end) + +t.test('with_expected settles from the route expected, not the credential', function() + -- A credential whose externalId/methodDetails MATCH the route (so binding + -- passes) still settles using the route's `expected` values. Capture the + -- request handed to verify_payment and assert it carries the route + -- externalId, proving settlement reads `expected`, not the credential. + local captured + local server = mpp.server.new({ + recipient = TEST_RECIPIENT, + currency = 'USDC', + decimals = 6, + network = 'localnet', + secret_key = TEST_SECRET, + store = mpp.store.memory(), + verify_payment = function(context) + captured = context.request + return { reference = context.payload.signature or context.payload.transaction } + end, + }) + local challenge = server:charge_with_options('1', {external_id = 'order-77'}) + local credential = bogus_signature_credential(challenge:to_echo()) + local expected = challenge.request:decode() + + server:verify_credential_with_expected(credential, expected, 1770000000) + t.assert_true(captured ~= nil, 'verify_payment must be called') + t.assert_equal(captured.externalId, 'order-77') + t.assert_equal(captured.amount, expected.amount) +end) + +-- ── Regression: minimal expected form must not be rejected ───────────────── + +-- The documented minimal expected request {amount, currency, recipient} +-- (methodDetails / externalId omitted) is what the nginx / simple-server / +-- Kong examples pass. A prior round-1 change made the methodDetails compare +-- UNCONDITIONAL, so the minimal form (expected.methodDetails == nil) +-- canonicalized to `{}` and never matched a credential carrying any +-- methodDetails, rejecting every example call with "method details +-- mismatch". This test fails pre-fix and passes post-fix. +t.test('with_expected accepts the minimal {amount,currency,recipient} form', function() + local server = new_server() + -- Credential carries a real methodDetails shape (splits + decimals), just + -- like a wire challenge would. + local challenge = server:charge_with_options('0.25', { + splits = {{recipient = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ', amount = '50'}}, + }) + local credential = bogus_signature_credential(challenge:to_echo()) + local request = challenge.request:decode() + + local receipt = server:verify_credential_with_expected(credential, { + amount = request.amount, + currency = request.currency, + recipient = request.recipient, + }, 1770000000) + t.assert_equal(receipt.status, 'success') + t.assert_equal(receipt.challengeId, challenge.id) +end) + +-- With the minimal form the credential's own methodDetails / externalId +-- become the settlement defaults, so the verifier still receives the full +-- on-chain shape (it is not silently dropped to `{}`). +t.test('with_expected minimal form carries credential methodDetails into settlement', function() + local captured + local server = mpp.server.new({ + recipient = TEST_RECIPIENT, + currency = 'USDC', + decimals = 6, + network = 'localnet', + secret_key = TEST_SECRET, + store = mpp.store.memory(), + verify_payment = function(context) + captured = context.request + return { reference = context.payload.signature or context.payload.transaction } + end, + }) + local challenge = server:charge_with_options('1', { + external_id = 'order-minimal', + splits = {{recipient = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ', amount = '100'}}, + }) + local credential = bogus_signature_credential(challenge:to_echo()) + local request = challenge.request:decode() + + server:verify_credential_with_expected(credential, { + amount = request.amount, + currency = request.currency, + recipient = request.recipient, + }, 1770000000) + t.assert_true(captured ~= nil, 'verify_payment must be called') + -- externalId falls back to the credential's value when expected omits it. + t.assert_equal(captured.externalId, 'order-minimal') + -- methodDetails.splits flowed through from the credential, not dropped. + t.assert_true(type(captured.methodDetails) == 'table', 'methodDetails carried') + t.assert_true(captured.methodDetails.splits ~= nil, 'splits carried into settlement') +end) diff --git a/lua/tests/mpp_rust_parity_spec.lua b/lua/tests/mpp_rust_parity_spec.lua new file mode 100644 index 000000000..9f44194c4 --- /dev/null +++ b/lua/tests/mpp_rust_parity_spec.lua @@ -0,0 +1,172 @@ +--[[ +Regression coverage for MPP charge verifier parity with the Rust spine +(rust/crates/mpp/src/server/charge.rs). + +Each test asserts rust-matching behaviour that the pre-fix Lua verifier +did not implement: + + * transferChecked decimals byte pinned to method_details.decimals + (charge.rs:1623-1624) + * fee-payer's own token account cannot fund the SPL payment, even under + a different authority (charge.rs:1649-1657) + * inner / CPI instructions from meta.innerInstructions are matched on + the confirmed-transaction path (charge.rs:2218-2230) +]] + +local t = require('tests.test_helper') +local verify = require('pay_kit.protocols.mpp.server.solana_verify') +local base58 = require('pay_kit.solana.base58') +local ata = require('pay_kit.solana.ata') + +local TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' + +local function key(byte) + return base58.encode(string.rep(string.char(byte), 32)) +end + +-- ── decimals pinning ────────────────────────────────────────────── + +t.test('parity: SPL transferChecked with wrong decimals does not match', function() + local context = { + payload = { type = 'signature', signature = 'sig-dec' }, + request = { + amount = '2500', currency = 'mint-1', recipient = 'recipient-1', + methodDetails = { tokenProgram = TOKEN_PROGRAM, decimals = 6 }, + }, + method_details = { tokenProgram = TOKEN_PROGRAM, decimals = 6 }, + } + local tx = { + meta = { err = nil }, + transaction = { message = { instructions = { + { + programId = TOKEN_PROGRAM, + parsed = { type = 'transferChecked', info = { + source = 'sender-ata', destination = 'token-account-1', + mint = 'mint-1', authority = 'sender-1', + tokenAmount = { amount = '2500', decimals = 9 }, -- wrong decimals + } }, + }, + } } }, + } + t.assert_error(function() + verify.verify_signature(context, { + fetch_transaction = function() return tx end, + fetch_token_account = function() + return { owner = 'recipient-1', mint = 'mint-1' } + end, + }) + end, 'no matching token transfer') +end) + +t.test('parity: SPL transferChecked with matching decimals is accepted', function() + local context = { + payload = { type = 'signature', signature = 'sig-dec-ok' }, + request = { + amount = '2500', currency = 'mint-1', recipient = 'recipient-1', + methodDetails = { tokenProgram = TOKEN_PROGRAM, decimals = 6 }, + }, + method_details = { tokenProgram = TOKEN_PROGRAM, decimals = 6 }, + } + local tx = { + meta = { err = nil }, + transaction = { message = { instructions = { + { + programId = TOKEN_PROGRAM, + parsed = { type = 'transferChecked', info = { + source = 'sender-ata', destination = 'token-account-1', + mint = 'mint-1', authority = 'sender-1', + tokenAmount = { amount = '2500', decimals = 6 }, + } }, + }, + } } }, + } + local result = verify.verify_signature(context, { + fetch_transaction = function() return tx end, + fetch_token_account = function() + return { owner = 'recipient-1', mint = 'mint-1' } + end, + }) + t.assert_equal(result.reference, 'sig-dec-ok') +end) + +-- ── fee-payer source-ATA guard ──────────────────────────────────── + +t.test('parity: fee-payer token account cannot fund the SPL transfer', function() + local fee_payer = key(7) + local mint = key(4) + local fee_payer_ata = ata.derive(fee_payer, mint, TOKEN_PROGRAM) + local context = { + payload = { type = 'transaction', transaction = 'base64-tx' }, + request = { + amount = '2500', currency = mint, recipient = 'recipient-1', + methodDetails = { + tokenProgram = TOKEN_PROGRAM, feePayer = true, feePayerKey = fee_payer, + }, + }, + method_details = { + tokenProgram = TOKEN_PROGRAM, feePayer = true, feePayerKey = fee_payer, + }, + } + -- Source is the fee-payer's ATA, authority is a DIFFERENT key — only the + -- source-ATA guard (not the authority guard) can catch this drain. + local drain_tx = { + meta = { err = nil }, + transaction = { message = { instructions = { + { + programId = TOKEN_PROGRAM, + parsed = { type = 'transferChecked', info = { + source = fee_payer_ata, destination = 'token-account-1', + mint = mint, authority = 'sender-1', + tokenAmount = { amount = '2500' }, + } }, + }, + } } }, + } + local send_calls = 0 + t.assert_error(function() + verify.verify_transaction(context, { + parse_transaction = function() return drain_tx.transaction end, + send_transaction = function() send_calls = send_calls + 1; return 'sig' end, + await_transaction = function() return drain_tx end, + fetch_token_account = function() + return { owner = 'recipient-1', mint = mint } + end, + }) + end, 'payment_invalid') + t.assert_equal(send_calls, 0, 'pre-broadcast policy must reject before send') +end) + +-- ── inner (CPI) instructions on the confirmed path ──────────────── + +t.test('parity: confirmed transaction matches payment emitted via inner CPI', function() + local context = { + payload = { type = 'signature', signature = 'sig-cpi' }, + request = { + amount = '1000', currency = 'sol', recipient = 'recipient-1', + methodDetails = {}, + }, + method_details = {}, + } + -- The top-level instruction list carries no payment; the SOL transfer is + -- emitted as an inner CPI. Pre-fix this failed to match. + local tx = { + meta = { + err = nil, + innerInstructions = { + { instructions = { + { + program = 'system', + parsed = { type = 'transfer', info = { + destination = 'recipient-1', lamports = '1000', + } }, + }, + } }, + }, + }, + transaction = { message = { instructions = {} } }, + } + local result = verify.verify_signature(context, { + fetch_transaction = function() return tx end, + }) + t.assert_equal(result.reference, 'sig-cpi') +end) diff --git a/lua/tests/pay_kit/config_spec.lua b/lua/tests/pay_kit/config_spec.lua index 5215fc4d9..9c09cb761 100644 --- a/lua/tests/pay_kit/config_spec.lua +++ b/lua/tests/pay_kit/config_spec.lua @@ -22,7 +22,9 @@ helper.test('configure() with no opts boots on demo signer + localnet', function helper.assert_equal(ok, true) local cfg = pay_kit.config() helper.assert_equal(cfg.network, 'solana_localnet') - helper.assert_equal(cfg.rpc_url, 'http://localhost:8899') + -- localnet defaults to the hosted Surfpool clone, not a bare local + -- validator the developer may not be running (LUA-3 / Ruby parity). + helper.assert_equal(cfg.rpc_url, 'https://402.surfnet.dev:8899') helper.assert_equal(cfg.operator:signer():demo(), true) helper.assert_equal(cfg.operator:fee_payer(), true) end) @@ -151,6 +153,28 @@ helper.test('configure() mpp.expires_in default + override', function() helper.assert_equal(pay_kit.config().mpp.expires_in, 60) end) +-- Regression: configure() must preserve a caller-supplied mpp.replay_store +-- (and any other mpp.* field) through to the stored config. A prior round-1 +-- change rebuilt current_config.mpp from only realm / secret / expires_in, +-- silently dropping the shared atomic replay store operators inject to +-- satisfy the multi-worker replay-protection warning. Pre-fix the assert on +-- replay_store fails (nil); post-fix it round-trips. +helper.test('configure() preserves mpp.replay_store and other mpp fields', function() + reset() + local sentinel_store = { put_if_absent = function() return true end } + assert(pay_kit.configure({mpp = { + replay_store = sentinel_store, + challenge_binding_secret = 'rotate-me', + expires_in = 90, + }})) + local cfg = pay_kit.config() + helper.assert_equal(cfg.mpp.replay_store, sentinel_store) + -- Normalized fields still applied alongside the preserved store. + helper.assert_equal(cfg.mpp.challenge_binding_secret, 'rotate-me') + helper.assert_equal(cfg.mpp.expires_in, 90) + helper.assert_equal(cfg.mpp.realm, 'App') +end) + helper.test('configure() refuses to be called twice', function() reset() assert(pay_kit.configure()) diff --git a/lua/tests/pay_kit/main_fixes_spec.lua b/lua/tests/pay_kit/main_fixes_spec.lua new file mode 100644 index 000000000..925b21b1b --- /dev/null +++ b/lua/tests/pay_kit/main_fixes_spec.lua @@ -0,0 +1,263 @@ +--[[ +Regression coverage for the main-branch Lua audit fixes: + + LUA-7 challenge expiry is wired from config.mpp.expires_in into the + 402 challenge the MPP adapter issues (and the `false` dev opt-out). + LUA-3 solana_localnet defaults to the hosted Surfpool RPC, not a bare + local validator. + LUA-MPP-SECRET challenge_binding_secret resolves from the env var, then + ./.env, then a freshly generated + persisted CSPRNG secret. + 402 no-store on the dispatcher 402 path. + expires.format_rfc3339 round-trips through parse_rfc3339. +]] + +local helper = require('tests.test_helper') +local pay_kit = require('pay_kit') +local headers = require('pay_kit.protocol.core.headers') +local expires = require('pay_kit.protocols.mpp.expires') +local preflight = require('pay_kit.preflight') +local canonical_json = require('pay_kit.util.json') + +local SELLER = 'SeLLeRWaLLeT111111111111111111111111111111' + +local function reset() + pay_kit._reset_for_tests() +end + +-- Parse the MPP challenge out of a 402 response's WWW-Authenticate header. +local function mpp_challenge_from_response(response) + local hdr = response.headers and response.headers['www-authenticate'] + helper.assert_true(hdr ~= nil, 'expected a www-authenticate header') + return headers.parse_www_authenticate(hdr) +end + +-- ── 402 no-store ────────────────────────────────────────────────────────── + +helper.test('402 response carries Cache-Control: no-store', function() + reset() + assert(pay_kit.configure({ + network = 'solana_devnet', + operator = {recipient = SELLER}, + mpp = {challenge_binding_secret = 'test-secret'}, + })) + assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) + local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) + helper.assert_equal(response.headers['cache-control'], 'no-store') +end) + +-- ── LUA-7 expiry wiring ───────────────────────────────────────────────────── + +helper.test('MPP 402 challenge carries an expiry from config.mpp.expires_in', function() + reset() + assert(pay_kit.configure({ + network = 'solana_devnet', + accept = {'mpp'}, + operator = {recipient = SELLER}, + mpp = {challenge_binding_secret = 'test-secret', expires_in = 120}, + })) + assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) + local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) + local challenge = mpp_challenge_from_response(response) + helper.assert_true(challenge.expires ~= nil and challenge.expires ~= '', + 'expected a non-empty expires on the issued challenge') + -- The expiry must be a future RFC3339 timestamp (not yet expired now). + helper.assert_equal(expires.is_expired(challenge.expires, os.time()), false) + -- And it must be expired well past the TTL window. + helper.assert_equal(expires.is_expired(challenge.expires, os.time() + 10000), true) +end) + +helper.test('MPP 402 challenge omits expiry when expires_in = false (dev opt-out)', function() + reset() + assert(pay_kit.configure({ + network = 'solana_devnet', + accept = {'mpp'}, + operator = {recipient = SELLER}, + mpp = {challenge_binding_secret = 'test-secret', expires_in = false}, + })) + assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) + local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) + local challenge = mpp_challenge_from_response(response) + helper.assert_true(challenge.expires == nil or challenge.expires == '', + 'dev opt-out must issue a challenge with no expiry') +end) + +helper.test('configure rejects a non-positive expires_in', function() + reset() + local _, err = pay_kit.configure({ + network = 'solana_devnet', + mpp = {challenge_binding_secret = 'test-secret', expires_in = 0}, + }) + helper.assert_true(err ~= nil and err:find('expires_in', 1, true) ~= nil, tostring(err)) +end) + +-- ── LUA-6 adapter supplies the full route methodDetails in `expected` ─────── +-- +-- The adapter's verify-time `expected` request must reconstruct the SAME +-- methodDetails the challenge was issued with (network, decimals, +-- tokenProgram, feePayer/feePayerKey). If it does not, every real MPP +-- credential would false-reject on the new methodDetails binding. Compare +-- the canonical methodDetails of the issued challenge against the adapter's +-- reconstruction (built the same way verify_and_settle builds `expected`). + +local function strip_blockhash(method_details) + local out = {} + for k, v in pairs(method_details or {}) do + if k ~= 'recentBlockhash' then out[k] = v end + end + return out +end + +helper.test('adapter expected methodDetails matches the issued challenge', function() + reset() + assert(pay_kit.configure({ + network = 'solana_devnet', + accept = {'mpp'}, + operator = {recipient = SELLER}, + mpp = {challenge_binding_secret = 'test-secret', expires_in = 120}, + })) + assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) + local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) + local challenge = mpp_challenge_from_response(response) + local issued = challenge.request:decode() + + -- Reconstruct expected methodDetails the way the MPP adapter does at + -- verify time (network + SPL decimals/tokenProgram + feePayer key). + local mints = require('pay_kit.solana.mints') + local currency = issued.currency + local network = issued.methodDetails.network + local expected_md = { network = network } + if string.lower(currency) ~= 'sol' then + expected_md.decimals = issued.methodDetails.decimals + if mints.stablecoin_symbol(currency) then + expected_md.tokenProgram = mints.default_token_program_for_currency(currency, network) + end + end + if issued.methodDetails.feePayer then + expected_md.feePayer = true + if issued.methodDetails.feePayerKey then + expected_md.feePayerKey = issued.methodDetails.feePayerKey + end + end + + helper.assert_equal( + canonical_json.encode(strip_blockhash(expected_md)), + canonical_json.encode(strip_blockhash(issued.methodDetails)) + ) +end) + +-- ── LUA-3 localnet RPC default ────────────────────────────────────────────── + +helper.test('solana_localnet defaults to the hosted Surfpool RPC', function() + reset() + assert(pay_kit.configure({mpp = {challenge_binding_secret = 'test-secret'}})) + helper.assert_equal(pay_kit.config().rpc_url, 'https://402.surfnet.dev:8899') +end) + +-- ── expires.format_rfc3339 ────────────────────────────────────────────────── + +helper.test('expires.format_rfc3339 round-trips through parse_rfc3339', function() + local epoch = 1893456000 -- 2030-01-01T00:00:00Z + local formatted = expires.format_rfc3339(epoch) + helper.assert_equal(formatted, '2030-01-01T00:00:00Z') + local parsed = expires.parse_rfc3339(formatted) + helper.assert_equal(parsed, epoch) +end) + +-- ── LUA-MPP-SECRET resolution (env / .env / CSPRNG) ───────────────────────── +-- +-- These tests restore the real os.getenv (the test_helper monkey-patch +-- otherwise forces a fixed secret env var) so the genuine resolution order +-- is exercised against a temp directory. + +local _patched_getenv = os.getenv +local _real_getenv = rawget(_G, '_PAY_KIT_REAL_GETENV') or os.getenv + +local function with_real_getenv(env_value, fn) + os.getenv = function(name) -- luacheck: ignore + if name == 'PAY_KIT_DISABLE_PREFLIGHT' then return '1' end + if name == 'PAY_KIT_MPP_CHALLENGE_BINDING_SECRET' then return env_value end + return _real_getenv(name) + end + local ok, err = pcall(fn) + os.getenv = _patched_getenv -- luacheck: ignore + if not ok then error(err) end +end + +helper.test('ensure_challenge_binding_secret reads the env var first', function() + with_real_getenv('env-supplied-secret', function() + local cfg = {mpp = {challenge_binding_secret = nil}} + local resolved = preflight.ensure_challenge_binding_secret(cfg) + helper.assert_equal(resolved, 'env-supplied-secret') + helper.assert_equal(cfg.mpp.challenge_binding_secret, 'env-supplied-secret') + end) +end) + +helper.test('ensure_challenge_binding_secret reads ./.env when env is unset', function() + with_real_getenv(nil, function() + local path = os.tmpname() + local fh = assert(io.open(path, 'w')) + fh:write('# comment\n') + fh:write('OTHER_KEY=ignored\n') + fh:write('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET="dotenv-secret-value"\n') + fh:close() + local value = preflight.read_dotenv_value('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET', path) + os.remove(path) + helper.assert_equal(value, 'dotenv-secret-value') + end) +end) + +helper.test('secure_random_hex returns 64 lowercase hex chars (32 bytes)', function() + local hex = preflight.secure_random_hex(32) + helper.assert_equal(#hex, 64) + helper.assert_true(hex:match('^[0-9a-f]+$') ~= nil, 'expected lowercase hex') + -- Two draws must differ (not a constant). + helper.assert_true(hex ~= preflight.secure_random_hex(32), 'CSPRNG must not repeat') +end) + +helper.test('persist_dotenv_value writes a readable KEY="value" line', function() + local path = os.tmpname() + os.remove(path) -- start from absent so we exercise the create path + local ok = preflight.persist_dotenv_value('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET', 'persisted-xyz', path) + helper.assert_equal(ok, true) + local value = preflight.read_dotenv_value('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET', path) + os.remove(path) + helper.assert_equal(value, 'persisted-xyz') +end) + +helper.test('ensure_challenge_binding_secret generates + persists a secret when env and .env are empty', function() + with_real_getenv(nil, function() + -- Point dotenv at a temp dir by chdir is overkill; instead drive the + -- generator directly and confirm it is a 64-char hex secret. The + -- env-empty + dotenv-empty branch is covered by read_dotenv_value + -- returning nil for a missing file. + helper.assert_equal(preflight.read_dotenv_value('NO_SUCH_KEY', '/nonexistent/path/.env'), nil) + local cfg = {mpp = {challenge_binding_secret = nil}} + -- Persist to a temp .env via PWD override so the repo is not touched. + local tmpdir = os.tmpname() + os.remove(tmpdir) + assert(os.execute('mkdir -p ' .. tmpdir)) + local old_pwd = _real_getenv('PWD') + os.getenv = function(name) -- luacheck: ignore + if name == 'PWD' then return tmpdir end + if name == 'PAY_KIT_DISABLE_PREFLIGHT' then return '1' end + if name == 'PAY_KIT_MPP_CHALLENGE_BINDING_SECRET' then return nil end + return _real_getenv(name) + end + local resolved = preflight.ensure_challenge_binding_secret(cfg) + -- restore the with_real_getenv shim + os.getenv = function(name) -- luacheck: ignore + if name == 'PWD' then return old_pwd end + if name == 'PAY_KIT_DISABLE_PREFLIGHT' then return '1' end + if name == 'PAY_KIT_MPP_CHALLENGE_BINDING_SECRET' then return nil end + return _real_getenv(name) + end + helper.assert_equal(#resolved, 64) + helper.assert_equal(cfg.mpp.challenge_binding_secret, resolved) + -- The generated secret was persisted to the temp .env and re-reads. + helper.assert_equal( + preflight.read_dotenv_value('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET', tmpdir .. '/.env'), + resolved + ) + os.execute('rm -rf ' .. tmpdir) + end) +end) diff --git a/lua/tests/pay_kit/preflight_spec.lua b/lua/tests/pay_kit/preflight_spec.lua index 81ecb589b..f406e77b4 100644 --- a/lua/tests/pay_kit/preflight_spec.lua +++ b/lua/tests/pay_kit/preflight_spec.lua @@ -178,3 +178,63 @@ helper.test('preflight downgrades RPC failure to warning (no raise)', function() restore_rpc() helper.assert_true(ok, 'preflight must not raise on RPC failure') end) + +-- Read a file's octal permission bits via stat. Tries the BSD/macOS form +-- first (`stat -f %Lp`) then the GNU/Linux form (`stat -c %a`). Returns the +-- string of octal digits (e.g. "600") or nil if neither worked. +local function file_mode(path) + for _, cmd in ipairs({ + "stat -f '%Lp' " .. string.format('%q', path) .. ' 2>/dev/null', + "stat -c '%a' " .. string.format('%q', path) .. ' 2>/dev/null', + }) do + local fh = io.popen(cmd) + if fh then + local out = (fh:read('*a') or ''):gsub('%s+', '') + fh:close() + -- Only accept octal permission bits. On GNU/Linux the BSD form + -- `stat -f '%Lp'` is parsed as `--file-system` and prints a statvfs + -- table instead of failing, so guard against that non-octal output + -- and fall through to the `stat -c '%a'` form. + if out:match('^[0-7]+$') then return out end + end + end + return nil +end + +-- Regression: a freshly created ./.env (which holds the CSPRNG +-- challenge-binding secret) must NOT be world/group readable. A prior round +-- wrote it with the default mode (644 under umask 022). persist_dotenv_value +-- must lock a newly created file to owner-only 0600. Pre-fix this asserts +-- "600" against "644" and fails; post-fix it passes. +helper.test('persist_dotenv_value locks a newly created .env to 0600', function() + local pf = require('pay_kit.preflight') + local tmp = os.tmpname() + -- os.tmpname() creates the file; remove it so persist sees a fresh path + -- and exercises the create branch. + os.remove(tmp) + local ok = pf.persist_dotenv_value('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET', 'deadbeef', tmp) + helper.assert_true(ok, 'persist must succeed') + local mode = file_mode(tmp) + os.remove(tmp) + -- Skip the assertion only if stat is entirely unavailable (no way to + -- observe the mode); otherwise it must be owner-only. + if mode then + helper.assert_equal(mode, '600', 'new .env must be owner-only') + end +end) + +-- Permissions on a PRE-EXISTING .env must not be touched (we only tighten +-- files we create, never relax/override operator-set modes). +helper.test('persist_dotenv_value leaves an existing .env mode untouched', function() + local pf = require('pay_kit.preflight') + local tmp = os.tmpname() -- file already exists here + -- Give it a deliberately looser mode and confirm persist does not change it. + os.execute('chmod 644 ' .. string.format('%q', tmp)) + local ok = pf.persist_dotenv_value('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET', 'deadbeef', tmp) + helper.assert_true(ok, 'persist must succeed') + local mode = file_mode(tmp) + os.remove(tmp) + if mode then + helper.assert_equal(mode, '644', 'existing .env mode must be preserved') + end +end) diff --git a/lua/tests/pay_kit/x402_rust_parity_spec.lua b/lua/tests/pay_kit/x402_rust_parity_spec.lua new file mode 100644 index 000000000..8980f14d2 --- /dev/null +++ b/lua/tests/pay_kit/x402_rust_parity_spec.lua @@ -0,0 +1,182 @@ +--[[ +Regression coverage for x402 exact-scheme parity with the Rust spine +(rust/crates/x402/src/protocol/schemes/exact/verify.rs). + +Each test asserts the rust-matching behaviour and would have FAILED on +the pre-fix Lua verifier: + + * compute-unit-price cap raised from 50_000 to 5_000_000 (verify.rs:17) + * transfer program bound to the canonical TOKEN/TOKEN_2022 set, NOT to + `extra.tokenProgram` (verify.rs:373) + * u64 amount decoded exactly (verify.rs:405-409) so a high-range value + cannot collide with a different amount through float rounding +]] + +local helper = require('tests.test_helper') +local base64 = require('pay_kit.util.base64_std') +local base58 = require('pay_kit.solana.base58') +local tx_mod = require('pay_kit.solana.transaction') +local ata = require('pay_kit.solana.ata') +local x402_verify = require('pay_kit.protocols.x402.exact.verify') + +local COMPUTE_BUDGET = 'ComputeBudget111111111111111111111111111111' +local MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr' +local TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' +local TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb' + +-- Exact little-endian u32 (full range). +local function u32_le(n) + local out = {} + for _ = 1, 4 do out[#out + 1] = string.char(n % 256); n = math.floor(n / 256) end + return table.concat(out) +end + +-- Exact little-endian u64 from a decimal STRING so the full u64 range is +-- representable without float rounding (the prior numeric helpers in the +-- other specs top out at 2^53). +local function u64_le_from_decimal(decimal) + -- Repeated divmod by 256 over an arbitrary-precision decimal string. + local digits = decimal + local out = {} + for _ = 1, 8 do + local quotient, carry = {}, 0 + for i = 1, #digits do + local cur = carry * 10 + tonumber(digits:sub(i, i)) + quotient[#quotient + 1] = tostring(math.floor(cur / 256)) + carry = cur % 256 + end + local remainder = carry + out[#out + 1] = string.char(remainder) + digits = table.concat(quotient):gsub('^0+', '') + if digits == '' then digits = '0' end + end + return table.concat(out) +end + +local function build_ix(program_index, accounts, data) + local out = {string.char(program_index), tx_mod.compact_u16(#accounts)} + for i = 1, #accounts do out[#out + 1] = string.char(accounts[i]) end + out[#out + 1] = tx_mod.compact_u16(#data) + out[#out + 1] = data + return table.concat(out) +end + +-- Standard 8-key layout. token_program controls which SPL program id the +-- transfer instruction points at (slot index 6). +local function standard_keys(facilitator, source, mint, destination, authority, token_program) + return table.concat({ + base58.decode(facilitator), + base58.decode(source), + base58.decode(mint), + base58.decode(destination), + base58.decode(authority), + base58.decode(COMPUTE_BUDGET), + base58.decode(token_program), + base58.decode(MEMO_PROGRAM), + }) +end + +local function assemble(account_keys_blob, account_count, instruction_blobs) + local header = string.char(1, 0, 3) + local recent_blockhash = string.rep('\0', 32) + local ix_blob = tx_mod.compact_u16(#instruction_blobs) + for _, ix in ipairs(instruction_blobs) do ix_blob = ix_blob .. ix end + local lookups = tx_mod.compact_u16(0) + local message = '\x80' .. header .. tx_mod.compact_u16(account_count) .. + account_keys_blob .. recent_blockhash .. ix_blob .. lookups + local sigs = tx_mod.compact_u16(1) .. string.rep('\0', 64) + return sigs .. message +end + +local function actors() + return base58.encode(string.rep('\1', 32)), -- facilitator + base58.encode(string.rep('\2', 32)), -- authority + base58.encode(string.rep('\3', 32)), -- source + base58.encode(string.rep('\4', 32)), -- mint + base58.encode(string.rep('\5', 32)) -- pay_to +end + +-- Build a full transaction whose transfer carries the given decimal amount +-- and token program; price_micro sets the compute-unit-price value. +local function make_tx(opts) + local facilitator, authority, source, mint, pay_to = actors() + local token_program = opts.token_program or TOKEN_PROGRAM + local destination = ata.derive(pay_to, mint, token_program) + local keys = standard_keys(facilitator, source, mint, destination, authority, token_program) + local raw = assemble(keys, 8, { + build_ix(5, {}, string.char(2) .. u32_le(200000)), + build_ix(5, {}, string.char(3) .. u64_le_from_decimal(opts.price_micro or '1000')), + build_ix(6, {1, 2, 3, 4}, + string.char(12) .. u64_le_from_decimal(opts.amount or '1000') .. string.char(6)), + build_ix(7, {}, '/paid'), + }) + return raw, facilitator, mint, pay_to +end + +local function offer(facilitator, mint, pay_to, opts) + opts = opts or {} + local extra = {feePayer = facilitator, decimals = 6, memo = '/paid'} + if opts.token_program ~= nil then extra.tokenProgram = opts.token_program end + return { + scheme = 'exact', network = 'solana:dev', + asset = mint, amount = opts.amount or '1000', payTo = pay_to, + extra = extra, + } +end + +-- #39: compute-unit price between the old 50_000 cap and the rust 5_000_000 +-- cap must now be ACCEPTED (was rejected pre-fix as compute_price_too_high). +helper.test('parity: compute-unit price above old 50k cap but under 5M is accepted', function() + local raw, facilitator, mint, pay_to = make_tx({price_micro = '60000'}) + local ok = pcall(x402_verify.verify, base64.encode(raw), + offer(facilitator, mint, pay_to, {token_program = TOKEN_PROGRAM}), {facilitator}) + helper.assert_true(ok, 'price 60000 must pass under the 5M rust cap') +end) + +helper.test('parity: compute-unit price above the 5M rust cap is rejected', function() + local raw, facilitator, mint, pay_to = make_tx({price_micro = '5000001'}) + local ok, err = pcall(x402_verify.verify, base64.encode(raw), + offer(facilitator, mint, pay_to, {token_program = TOKEN_PROGRAM}), {facilitator}) + helper.assert_true(not ok) + helper.assert_true(tostring(err):find('compute_price', 1, true) ~= nil, tostring(err)) +end) + +-- #42: an offer that OMITS extra.tokenProgram must still verify against a +-- canonical TOKEN_PROGRAM transfer (rust binds to the program set, not to +-- extra.tokenProgram). Pre-fix this raised missing_extra_tokenProgram. +helper.test('parity: offer without extra.tokenProgram still verifies TOKEN_PROGRAM transfer', function() + local raw, facilitator, mint, pay_to = make_tx({token_program = TOKEN_PROGRAM}) + local ok, err = pcall(x402_verify.verify, base64.encode(raw), + offer(facilitator, mint, pay_to), {facilitator}) + helper.assert_true(ok, 'expected accept; got: ' .. tostring(err)) +end) + +-- #42: Token-2022 transfer is accepted by the canonical program set even +-- when the offer omits extra.tokenProgram. +helper.test('parity: Token-2022 transfer accepted without extra.tokenProgram', function() + local raw, facilitator, mint, pay_to = make_tx({token_program = TOKEN_2022_PROGRAM}) + local ok, err = pcall(x402_verify.verify, base64.encode(raw), + offer(facilitator, mint, pay_to), {facilitator}) + helper.assert_true(ok, 'expected accept; got: ' .. tostring(err)) +end) + +-- #43: high-range u64 amount is compared EXACTLY. 9007199254740993 = 2^53+1 +-- which float math rounds to 2^53; the exact decoder must accept the true +-- value and reject the rounded neighbour. +helper.test('parity: high-range u64 amount above 2^53 matches exactly', function() + local big = '9007199254740993' -- 2^53 + 1 + local raw, facilitator, mint, pay_to = make_tx({amount = big}) + local ok, err = pcall(x402_verify.verify, base64.encode(raw), + offer(facilitator, mint, pay_to, {token_program = TOKEN_PROGRAM, amount = big}), {facilitator}) + helper.assert_true(ok, 'exact 2^53+1 amount must match; got: ' .. tostring(err)) +end) + +helper.test('parity: u64 amount off-by-one above 2^53 is rejected', function() + local raw, facilitator, mint, pay_to = make_tx({amount = '9007199254740993'}) -- 2^53+1 + -- Offer expects 2^53 (the value a lossy float decode would collapse to). + local ok, err = pcall(x402_verify.verify, base64.encode(raw), + offer(facilitator, mint, pay_to, + {token_program = TOKEN_PROGRAM, amount = '9007199254740992'}), {facilitator}) + helper.assert_true(not ok) + helper.assert_true(tostring(err):find('amount_mismatch', 1, true) ~= nil, tostring(err)) +end) diff --git a/lua/tests/pay_kit/x402_verify_negative_spec.lua b/lua/tests/pay_kit/x402_verify_negative_spec.lua index d303a2ea2..816683e01 100644 --- a/lua/tests/pay_kit/x402_verify_negative_spec.lua +++ b/lua/tests/pay_kit/x402_verify_negative_spec.lua @@ -32,6 +32,11 @@ end local COMPUTE_BUDGET = 'ComputeBudget111111111111111111111111111111' local MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr' local TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' +-- Official x402 SVM exact Lighthouse program id (matches php/go verifiers). +local LIGHTHOUSE_PROGRAM = 'L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95' +-- SPL Associated Token Program. An ATA-create in an optional slot must be +-- REJECTED per the official x402 exact contract (destination ATA pre-exists). +local ASSOCIATED_TOKEN_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' local function build_ix(program_index, accounts, data) local out = {string.char(program_index), tx_mod.compact_u16(#accounts)} @@ -236,7 +241,107 @@ helper.test('verify accepts when offer has no memo extra', function() offer.extra.memo = nil local ok, transfer = pcall(x402_verify.verify, base64.encode(raw), offer, {facilitator}) helper.assert_true(ok, 'expected verify to accept when memo extra is not set') - helper.assert_equal(transfer.amount, 1000) + helper.assert_equal(transfer.amount, '1000') +end) + +-- The standard 8-key block plus one trailing program key. Used by the +-- Lighthouse / ATA-create optional-slot cases below: key index 8 holds the +-- extra program (Lighthouse guard or ATA-create) the wallet injects. +local function keys_with_extra(facilitator, source, mint, destination, authority, extra_program) + return table.concat({ + base58.decode(facilitator), + base58.decode(source), + base58.decode(mint), + base58.decode(destination), + base58.decode(authority), + base58.decode(COMPUTE_BUDGET), + base58.decode(TOKEN_PROGRAM), + base58.decode(MEMO_PROGRAM), + base58.decode(extra_program), -- index 8 + }) +end + +-- Rule 9 (a + c): a single trailing Lighthouse guard (Phantom injects one) +-- with the corrected program id is an allowed optional instruction. +helper.test('rule 9: single trailing Lighthouse guard (Phantom) accepted', function() + local facilitator, authority, source, mint, pay_to, destination = setup_actors() + local keys = keys_with_extra(facilitator, source, mint, destination, authority, + LIGHTHOUSE_PROGRAM) + -- memo at index 7, lighthouse at index 8. + local raw = assemble(keys, 9, { + build_ix(5, {}, string.char(2) .. u32_le(200000)), + build_ix(5, {}, string.char(3) .. u64_le(1000)), + build_ix(6, {1, 2, 3, 4}, string.char(12) .. u64_le(1000) .. string.char(6)), + build_ix(7, {}, '/paid'), + build_ix(8, {}, string.char(1)), -- lighthouse guard payload (opaque) + }) + local ok, transfer = pcall(x402_verify.verify, base64.encode(raw), + default_offer(facilitator, mint, pay_to), {facilitator}) + helper.assert_true(ok, 'expected verify to accept a trailing Lighthouse guard: ' .. + tostring(transfer)) + helper.assert_equal(transfer.amount, '1000') +end) + +-- Rule 9 (c): two trailing Lighthouse guards (Solflare injects two). Lighthouse +-- must be allowed in ANY optional slot, not just the first two. +helper.test('rule 9: two trailing Lighthouse guards (Solflare) accepted', function() + local facilitator, authority, source, mint, pay_to, destination = setup_actors() + local keys = keys_with_extra(facilitator, source, mint, destination, authority, + LIGHTHOUSE_PROGRAM) + -- memo at index 7, two lighthouse guards at index 8 (slots 3,4,5 used). + local raw = assemble(keys, 9, { + build_ix(5, {}, string.char(2) .. u32_le(200000)), + build_ix(5, {}, string.char(3) .. u64_le(1000)), + build_ix(6, {1, 2, 3, 4}, string.char(12) .. u64_le(1000) .. string.char(6)), + build_ix(7, {}, '/paid'), + build_ix(8, {}, string.char(1)), + build_ix(8, {}, string.char(2)), + }) + local ok, transfer = pcall(x402_verify.verify, base64.encode(raw), + default_offer(facilitator, mint, pay_to), {facilitator}) + helper.assert_true(ok, 'expected verify to accept two trailing Lighthouse guards: ' .. + tostring(transfer)) + helper.assert_equal(transfer.amount, '1000') +end) + +-- Rule 9 (b): an Associated-Token-Program ATA-create in an optional slot is +-- REJECTED. Per the official x402 SVM exact contract the destination ATA MUST +-- pre-exist; ATA-create is NOT a permitted optional instruction. This test +-- FAILS before the fix (the old verifier accepted a buyer-funded ATA-create +-- via valid_ata_create) and PASSES after it. +helper.test('rule 9: ATA-create optional instruction rejected', function() + local facilitator, authority, source, mint, pay_to, destination = setup_actors() + -- 10-key layout: standard 8 keys + ATA program at index 8 + payTo owner at + -- index 9. The ATA-create accounts genuinely satisfy the OLD valid_ata_create + -- gate (owner==payTo, mint match, ata==destination), so the old verifier + -- ACCEPTED this transaction. The corrected verifier MUST now reject it. + local keys = table.concat({ + base58.decode(facilitator), + base58.decode(source), + base58.decode(mint), + base58.decode(destination), + base58.decode(authority), + base58.decode(COMPUTE_BUDGET), + base58.decode(TOKEN_PROGRAM), + base58.decode(MEMO_PROGRAM), + base58.decode(ASSOCIATED_TOKEN_PROGRAM), -- index 8 + base58.decode(pay_to), -- index 9 (ATA owner) + }) + -- Slot order: compute-limit, compute-price, transfer, ata-create, memo. + -- ATA-create accounts [payer=0, ata=3 (destination), owner=9 (payTo), + -- mint=2, system=5, token=6] with CreateIdempotent discriminator (1). + local raw = assemble(keys, 10, { + build_ix(5, {}, string.char(2) .. u32_le(200000)), + build_ix(5, {}, string.char(3) .. u64_le(1000)), + build_ix(6, {1, 2, 3, 4}, string.char(12) .. u64_le(1000) .. string.char(6)), + build_ix(8, {0, 3, 9, 2, 5, 6}, string.char(1)), -- ATA-create at slot 3 + build_ix(7, {}, '/paid'), -- memo at slot 4 + }) + local ok, err = pcall(x402_verify.verify, base64.encode(raw), + default_offer(facilitator, mint, pay_to), {facilitator}) + helper.assert_true(not ok, 'expected verify to REJECT an ATA-create optional instruction') + helper.assert_true(tostring(err):find('fourth_instruction', 1, true) ~= nil or + tostring(err):find('unknown', 1, true) ~= nil, tostring(err)) end) helper.test('verify_client_signatures rejects when no client signatures remain', function() diff --git a/lua/tests/pay_kit/x402_verify_positive_spec.lua b/lua/tests/pay_kit/x402_verify_positive_spec.lua index d8259e3c4..5668ec28f 100644 --- a/lua/tests/pay_kit/x402_verify_positive_spec.lua +++ b/lua/tests/pay_kit/x402_verify_positive_spec.lua @@ -145,7 +145,7 @@ helper.test('verify accepts a structurally-valid transferChecked envelope', func helper.assert_true(transfer ~= nil, 'expected verify to return a transfer descriptor') helper.assert_equal(transfer.mint, mint) helper.assert_equal(transfer.destination, destination) - helper.assert_equal(transfer.amount, amount) + helper.assert_equal(transfer.amount, tostring(amount)) helper.assert_equal(transfer.authority, authority) end) diff --git a/lua/tests/run.lua b/lua/tests/run.lua index 7e8cca952..e8d704a02 100644 --- a/lua/tests/run.lua +++ b/lua/tests/run.lua @@ -12,6 +12,7 @@ require('tests.json_canonical_rfc8785_spec') require('tests.expires_rfc3339_spec') require('tests.server_spec') require('tests.solana_verify_spec') +require('tests.mpp_rust_parity_spec') require('tests.html_spec') require('tests.cross_route_replay_spec') require('tests.rpc_spec') @@ -44,6 +45,7 @@ require('tests.pay_kit.schemes_x402_spec') require('tests.pay_kit.x402_verify_spec') require('tests.pay_kit.x402_verify_positive_spec') require('tests.pay_kit.x402_verify_negative_spec') +require('tests.pay_kit.x402_rust_parity_spec') require('tests.pay_kit.util_reexports_spec') require('tests.pay_kit.signer_more_spec') require('tests.pay_kit.dispatcher_more_spec') @@ -56,5 +58,6 @@ require('tests.pay_kit.kong_plugin_spec') require('tests.pay_kit.kong_plugin_runtime_spec') require('tests.pay_kit.apisix_plugin_spec') require('tests.pay_kit.apisix_plugin_runtime_spec') +require('tests.pay_kit.main_fixes_spec') require('tests.test_helper').run() diff --git a/lua/tests/test_helper.lua b/lua/tests/test_helper.lua index 05c3fbf96..48cb83b41 100644 --- a/lua/tests/test_helper.lua +++ b/lua/tests/test_helper.lua @@ -5,9 +5,22 @@ if not os.getenv('PAY_KIT_DISABLE_PREFLIGHT') then -- LuaJIT doesn't ship setenv in the standard lib; rely on Lua 5.1's -- limitation: the preflight module checks os.getenv at call time, so -- we monkey-patch it for the duration of the suite. + -- + -- We also surface a fixed PAY_KIT_MPP_CHALLENGE_BINDING_SECRET so the + -- secret-resolution path inside `configure()` short-circuits to the env + -- var (deterministic) instead of generating a CSPRNG secret and writing + -- a stray `./.env` into the repo on every test run. The dedicated + -- secret-resolution spec restores the real os.getenv to exercise the + -- .env / CSPRNG fallbacks. local _real_getenv = os.getenv + -- Stash the real getenv on a global so a spec that needs to exercise + -- the genuine env/.env/CSPRNG resolution order can restore it locally. + rawset(_G, '_PAY_KIT_REAL_GETENV', _real_getenv) -- luacheck: ignore os.getenv = function(name) -- luacheck: ignore if name == 'PAY_KIT_DISABLE_PREFLIGHT' then return '1' end + if name == 'PAY_KIT_MPP_CHALLENGE_BINDING_SECRET' then + return 'test-suite-fixed-challenge-binding-secret' + end return _real_getenv(name) end end diff --git a/php/src/Frameworks/Laravel/PayKitServiceProvider.php b/php/src/Frameworks/Laravel/PayKitServiceProvider.php index 9991b817c..3ca0ea86c 100644 --- a/php/src/Frameworks/Laravel/PayKitServiceProvider.php +++ b/php/src/Frameworks/Laravel/PayKitServiceProvider.php @@ -78,7 +78,7 @@ public static function buildConfig(array $cfg): Config && $cfg['mpp_challenge_binding_secret'] !== '' ? (string) $cfg['mpp_challenge_binding_secret'] : null, - expiresIn: (int) ($cfg['mpp']['expires_in'] ?? 120), + expiresIn: MppConfig::resolveExpiresIn($cfg['mpp']['expires_in'] ?? null), ); $x402 = new X402Config( facilitatorUrl: isset($cfg['x402_facilitator_url']) && $cfg['x402_facilitator_url'] !== '' diff --git a/php/src/Middleware/RequirePayment.php b/php/src/Middleware/RequirePayment.php index 02c174f0a..dbd747475 100644 --- a/php/src/Middleware/RequirePayment.php +++ b/php/src/Middleware/RequirePayment.php @@ -146,7 +146,15 @@ private function build402(Gate $gate, ServerRequestInterface $request): Response 'accepts' => $accepts, ]; $factory = HttpFactory::responseFactory(); - $resp = $factory->createResponse(402)->withHeader('content-type', 'application/json'); + // 402 challenges are per-request and MUST NOT be cached by any + // intermediary or browser. Without `no-store` a CDN could replay a + // stale challenge (different blockhash / expiry / amount) to a + // later client. Matches the protocol 402 helper at + // ChargeServer::paymentRequiredResponse() and the cross-SDK rule + // (main-audit medium finding 6). + $resp = $factory->createResponse(402) + ->withHeader('cache-control', 'no-store') + ->withHeader('content-type', 'application/json'); foreach ($headers as $k => $v) { $resp = $resp->withHeader($k, $v); } diff --git a/php/src/Protocols/Mpp/Adapter.php b/php/src/Protocols/Mpp/Adapter.php index 552cf2072..b853f82ed 100644 --- a/php/src/Protocols/Mpp/Adapter.php +++ b/php/src/Protocols/Mpp/Adapter.php @@ -36,10 +36,30 @@ final class Adapter /** @var array */ private array $handlerCache = []; + private readonly Store $replayStore; + + /** + * @param ?Store $replayStore Replay-protection store shared across every + * {@see SolanaChargeHandler} this adapter builds. When null (the + * default) an in-process {@see MemoryStore} is used and a loud + * 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). + */ public function __construct( private readonly Config $config, - private readonly Store $replayStore = new MemoryStore(), + ?Store $replayStore = null, ) { + if ($replayStore === null) { + if (function_exists('error_log')) { + error_log( + 'pay_kit: WARN: mpp adapter using in-memory replay store; ' + . 'dev-only. Inject a shared atomic Store (Redis/Postgres) in production.', + ); + } + $replayStore = new MemoryStore(); + } + $this->replayStore = $replayStore; } public function acceptsEntry(Gate $gate, ServerRequestInterface $request): array @@ -75,10 +95,29 @@ public function challengeHeaders(Gate $gate, ServerRequestInterface $request): a { [$charges, $_handler] = $this->serverFor($gate); $chargeRequest = $this->chargeRequestFor($gate); - $header = $charges->createChallengeHeader($chargeRequest); + $header = $charges->createChallengeHeader($chargeRequest, $this->challengeExpires()); return ['www-authenticate' => $header]; } + /** + * RFC 3339 `expires` timestamp threaded into issued charge challenges. + * + * Derived from {@see MppConfig::$expiresIn}: `now + expiresIn` seconds, + * UTC. `expiresIn = 0` is the documented dev-only opt-out and yields an + * empty string, leaving the challenge with no expiry (never expires). + * Without this wiring `createChallengeHeader` defaulted to `''`, so + * signed challenges were valid indefinitely (main-audit finding 7). + */ + private function challengeExpires(): string + { + $expiresIn = $this->config->mpp->expiresIn; + if ($expiresIn <= 0) { + return ''; + } + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + return $now->add(new \DateInterval('PT' . $expiresIn . 'S'))->format('Y-m-d\TH:i:s\Z'); + } + public function verifyAndSettle(Gate $gate, ServerRequestInterface $request): Payment { $authorization = $request->getHeaderLine('Authorization'); @@ -109,7 +148,13 @@ private function chargeRequestFor(Gate $gate): ChargeRequest { $coin = $this->settlementCoin($gate); $payTo = $gate->payTo ?? $this->config->effectiveRecipient(); - $amount = (string) $this->priceUnits($gate->amount); + // Charge the gate total (base + any fee-on-top), matching the amount + // advertised in acceptsEntry. The MPP wire derives the primary + // recipient share as amount - sum(splits), so pinning the bare base + // here while advertising the total would let the verifier accept a + // payment short by the on-top fee. fee-within gates are unaffected + // (total == base). + $amount = (string) $this->totalUnits($gate, $coin); // Pay's MPP client reads request.methodDetails.network as the // short network slug ("mainnet" / "devnet" / "localnet") when // filtering challenges by active wallet diff --git a/php/src/Protocols/Mpp/MppConfig.php b/php/src/Protocols/Mpp/MppConfig.php index e209b56fa..20a8fd73c 100644 --- a/php/src/Protocols/Mpp/MppConfig.php +++ b/php/src/Protocols/Mpp/MppConfig.php @@ -14,6 +14,13 @@ * Null `challengeBindingSecret` triggers Preflight's resolution chain * (env / .env / generate + persist) so the example apps boot without * the operator having to set anything. + * + * `expiresIn` is the challenge TTL in seconds. It is threaded into every + * issued charge challenge as an RFC 3339 `expires` timestamp (see + * {@see \PayKit\Protocols\Mpp\Adapter::challengeHeaders()}). The default + * 120s matches the Python/Rust reference TTLs. Setting `expiresIn = 0` is + * an explicit, documented development opt-out: challenges are issued with + * no `expires`, so they never expire. Do not ship `0` to production. */ final readonly class MppConfig { @@ -22,9 +29,10 @@ public function __construct( public ?string $challengeBindingSecret = null, public int $expiresIn = 120, ) { - if ($expiresIn <= 0) { + if ($expiresIn < 0) { throw new ConfigurationException( - 'pay_kit: mpp.expiresIn must be a positive number of seconds', + 'pay_kit: mpp.expiresIn must be a non-negative number of seconds ' + . '(0 is the explicit dev-only never-expires opt-out)', ); } } @@ -33,4 +41,64 @@ public function withChallengeBindingSecret(string $secret): self { return new self($this->realm, $secret, $this->expiresIn); } + + /** + * Resolve a framework-config `expires_in` value into a safe TTL in + * seconds without letting a malformed value silently collapse to the + * never-expires opt-out. + * + * PHP's `(int)` cast turns `""`, `null`, or a non-numeric string (e.g. a + * mis-typed `MPP_EXPIRES_IN` env) into `0`, which {@see MppConfig} accepts + * as the explicit dev-only never-expires opt-out. Casting blindly would + * therefore disable challenge expiry in production on a typo. This helper + * distinguishes three cases: + * + * - absent (`null`) -> fall back to the safe 120s default; + * - explicit integer/numeric -> use the parsed integer (incl. `0`); + * - present but non-numeric -> fail fast with ConfigurationException. + * + * Only an explicit `0` (or `"0"`, `0.0`) yields the never-expires opt-out. + * + * @param mixed $value The raw configured value (typically from an array). + * @param int $default The TTL to use when the value is absent (`null`). + */ + public static function resolveExpiresIn(mixed $value, int $default = 120): int + { + if ($value === null) { + return $default; + } + + if (is_int($value)) { + return $value; + } + + // Floats with an integer value (e.g. 0.0, 120.0) are accepted; a + // fractional TTL is treated as malformed rather than truncated. + if (is_float($value)) { + if ($value === floor($value) && is_finite($value)) { + return (int) $value; + } + throw new ConfigurationException( + 'pay_kit: mpp.expires_in must be a whole number of seconds, got a fractional value', + ); + } + + // Booleans, arrays, objects, etc. are never a valid TTL. A bare `true` + // would (int)-cast to 1 and a `false`/`[]` to 0; reject them outright. + if (is_string($value)) { + $trimmed = trim($value); + if ($trimmed !== '' && ( + ctype_digit($trimmed) + || (str_starts_with($trimmed, '-') && ctype_digit(substr($trimmed, 1))) + )) { + return (int) $trimmed; + } + } + + throw new ConfigurationException( + 'pay_kit: mpp.expires_in is set but is not a valid integer number of seconds ' + . '(empty/non-numeric values would silently disable challenge expiry; ' + . 'use an explicit 0 only for the documented dev-only never-expires opt-out)', + ); + } } diff --git a/php/src/Protocols/Mpp/Server/ChargeServer.php b/php/src/Protocols/Mpp/Server/ChargeServer.php index b3e8425b0..0aea6902e 100644 --- a/php/src/Protocols/Mpp/Server/ChargeServer.php +++ b/php/src/Protocols/Mpp/Server/ChargeServer.php @@ -36,6 +36,8 @@ public function __construct( private readonly string $realm, private readonly string $method = 'solana', private readonly ?Closure $blockhashProvider = null, + private readonly ?string $pinnedCurrency = null, + private readonly ?string $pinnedRecipient = null, ) { } @@ -136,6 +138,19 @@ public function verifyAuthorizationHeader( return VerificationResult::failure('invalid charge request'); } + // Tier-2 pinned-field backstop. Runs unconditionally so even callers + // who do not pass $expectedRequest are protected against cross-route + // replay on the fields fixed at server construction. Mirrors Rust + // verify_pinned_fields (rust/crates/mpp/src/server/charge.rs:457-468), + // which always compares the credential currency/recipient against the + // pinned server configuration. + if ($this->pinnedCurrency !== null && $request->currency !== $this->pinnedCurrency) { + return VerificationResult::failure('charge request mismatch'); + } + if ($this->pinnedRecipient !== null && $request->recipient !== $this->pinnedRecipient) { + return VerificationResult::failure('charge request mismatch'); + } + if ($expectedRequest !== null && !$this->matchesExpectedRequest($request, $expectedRequest)) { return VerificationResult::failure('charge request mismatch'); } diff --git a/php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php b/php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php index 3c2e4d16c..0dc94b029 100644 --- a/php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php +++ b/php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php @@ -60,7 +60,8 @@ public function verify(Credential $credential, Challenge $challenge): Verificati if (is_string($transaction) && $transaction !== '') { try { $request = ChargeRequest::fromArray($challenge->decodeRequest()); - return $this->verifyTransactionPayload($transaction, $request); + // Pull-mode pre-broadcast: enforce the compute-budget caps. + return $this->runVerification($transaction, $request, onChain: false); } catch (Throwable $error) { // Surface the message from any failure (the SDK's own // InvalidArgumentException, an upstream solana-php SolanaException @@ -92,9 +93,19 @@ public function verify(Credential $credential, Challenge $challenge): Verificati * from the RPC by signature. */ public function verifyTransactionPayload(string $transactionBase64, ChargeRequest $request): VerificationResult + { + // Push-mode on-chain re-verification. The transaction has already + // landed and confirmed, so the compute-budget caps no longer gate + // anything; mirror Rust validate_parsed_instruction_allowlist + // (rust/crates/mpp/src/server/charge.rs:1873-1876), which skips the + // unit-limit/price caps on the parsed (settled) path. + return $this->runVerification($transactionBase64, $request, onChain: true); + } + + private function runVerification(string $transactionBase64, ChargeRequest $request, bool $onChain): VerificationResult { try { - $this->verifyTransaction($transactionBase64, $request); + $this->verifyTransaction($transactionBase64, $request, $onChain); } catch (Throwable $error) { return VerificationResult::failure($error->getMessage()); } @@ -119,7 +130,7 @@ private function validateSignature(string $signature): void } } - private function verifyTransaction(string $transactionBase64, ChargeRequest $request): void + private function verifyTransaction(string $transactionBase64, ChargeRequest $request, bool $onChain = false): void { $wire = base64_decode($transactionBase64, true); if ($wire === false || $wire === '') { @@ -175,6 +186,7 @@ private function verifyTransaction(string $transactionBase64, ChargeRequest $req expectedAtaPayer: $feePayer, requiredAtaOwners: [], createdAtaOwners: $createdAtaOwners, + onChain: $onChain, ); return; } @@ -213,15 +225,22 @@ private function verifyTransaction(string $transactionBase64, ChargeRequest $req ); } $this->verifyMemos($decoded, $request, $splits, $matched); + // The expected ATA payer defaults to the transaction fee payer when no + // route fee payer is configured (client-pays-fees mode). Mirrors Rust + // expected_ata_payer = fee_payer.unwrap_or(tx_fee_payer) + // (rust/crates/mpp/src/server/charge.rs:1299-1305); otherwise a + // client-pays charge would skip the ATA-payer binding entirely. + $expectedAtaPayer = $feePayer ?? ($decoded['accountKeys'][0] ?? null); $this->validateInstructionAllowlist( $decoded, $matched, expectedMint: $mint, allowedAtaOwners: $allowedAtaOwners, expectedTokenProgram: $tokenProgram, - expectedAtaPayer: $feePayer, + expectedAtaPayer: $expectedAtaPayer, requiredAtaOwners: $requiredAtaOwners, createdAtaOwners: $createdAtaOwners, + onChain: $onChain, ); } @@ -460,6 +479,7 @@ private function validateInstructionAllowlist( ?PublicKey $expectedAtaPayer, array $requiredAtaOwners, array &$createdAtaOwners, + bool $onChain = false, ): void { $allowedPrograms = [ self::COMPUTE_BUDGET_PROGRAM, @@ -475,7 +495,14 @@ private function validateInstructionAllowlist( throw new InvalidArgumentException('Unexpected program instruction in payment transaction: ' . $programId); } if ($programId === self::COMPUTE_BUDGET_PROGRAM) { - $this->validateComputeBudgetInstruction($instruction); + // On the push/on-chain path the transaction has already + // landed, so the unit-limit/price caps are no longer a gate. + // Rust validate_parsed_instruction_allowlist does a bare + // `continue` here (charge.rs:1873-1876); only the pull-mode + // pre-broadcast path enforces the caps. + if (!$onChain) { + $this->validateComputeBudgetInstruction($instruction); + } continue; } if (isset($matched[$index])) { diff --git a/php/src/Protocols/X402/Adapter.php b/php/src/Protocols/X402/Adapter.php index 305f736f1..dc8a655b3 100644 --- a/php/src/Protocols/X402/Adapter.php +++ b/php/src/Protocols/X402/Adapter.php @@ -9,10 +9,13 @@ use PayKit\Gate; use PayKit\Payment; use PayKit\Protocol; +use PayKit\Protocols\Mpp\Server\RpcGateway; +use PayKit\Protocols\Mpp\Server\SolanaRpcGateway; use PayKit\Protocols\X402\Exact\Verifier; use PayKit\Store\MemoryStore; use PayKit\Store\Store; use Psr\Http\Message\ServerRequestInterface; +use RuntimeException; use SolanaPhpSdk\Keypair\Keypair; use SolanaPhpSdk\Rpc\RpcClient; use SolanaPhpSdk\Transaction\VersionedTransaction; @@ -33,14 +36,36 @@ final class Adapter private const PAYMENT_SIGNATURE_HEADER = 'payment-signature'; private const X402_VERSION = 2; private const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; + private const REPLAY_KEY_PREFIX = 'x402-svm-exact:consumed:'; /** @var \Closure():?string|null */ private $recentBlockhashProvider = null; + private ?RpcGateway $rpc = null; + + private readonly Store $replayStore; + + /** + * @param ?Store $replayStore Replay-protection store. When null (the + * default) an in-process {@see MemoryStore} is used and a loud + * 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). + * @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. + * @param int $confirmationAttempts How many times to poll + * `getSignatureStatuses` before giving up. 40 attempts at the + * default delay = 10 seconds. Mirrors the MPP charge handler. + * @param int $confirmationDelayMicros Sleep between polls in microseconds. + */ public function __construct( private readonly Config $config, - private readonly Store $replayStore = new MemoryStore(), + ?Store $replayStore = null, ?\Closure $recentBlockhashProvider = null, + ?RpcGateway $rpc = null, + private readonly int $confirmationAttempts = 40, + private readonly int $confirmationDelayMicros = 250_000, ) { if ($config->x402->isDelegated()) { throw new InvalidProofException( @@ -48,7 +73,28 @@ public function __construct( . 'leave X402Config::$facilitatorUrl null for self-hosted', ); } + if ($replayStore === null) { + self::warnDefaultReplayStore(); + $replayStore = new MemoryStore(); + } + $this->replayStore = $replayStore; $this->recentBlockhashProvider = $recentBlockhashProvider; + $this->rpc = $rpc; + } + + private static function warnDefaultReplayStore(): void + { + if (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.', + ); + } + } + + private function rpc(): RpcGateway + { + return $this->rpc ??= new SolanaRpcGateway(new RpcClient($this->config->rpcUrl)); } /** @@ -214,9 +260,13 @@ public function verifyAndSettle(Gate $gate, ServerRequestInterface $request): Pa // Broadcast via the raw-wire path so PHP doesn't have to // reconstruct a SignedTransaction wrapper just to send. - $rpc = new RpcClient($this->config->rpcUrl); + $rpc = $this->rpc(); try { - $sig = $rpc->sendRawTransaction($cosignedWire, ['encoding' => 'base64', 'skipPreflight' => false]); + $sig = $rpc->sendRawTransaction($cosignedWire, [ + 'encoding' => 'base64', + 'skipPreflight' => false, + 'preflightCommitment' => 'confirmed', + ]); } catch (Throwable $e) { throw new InvalidProofException( 'pay_kit: invalid proof: broadcast failed: ' . $e->getMessage(), @@ -226,11 +276,31 @@ public function verifyAndSettle(Gate $gate, ServerRequestInterface $request): Pa throw new InvalidProofException('pay_kit: empty broadcast result'); } - // Reserve in replay store. - if (!$this->replayStore->putIfAbsent('x402-svm-exact:consumed:' . $sig, true)) { + // 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'); } + // Confirm BEFORE returning the payment-response success. RPC + // acceptance is not settlement: the transaction can still fail or + // never finalize. Poll getSignatureStatuses until confirmed or + // finalized, throwing on on-chain failure or timeout so callers + // never receive a success header for an unsettled transaction. + // Closes main-audit finding 3 (PHP x402 confirm-before-success). + try { + $this->awaitConfirmation($sig); + } catch (Throwable $e) { + throw new InvalidProofException( + 'pay_kit: invalid proof: settlement not confirmed: ' . $e->getMessage(), + ); + } + $responseEnvelope = base64_encode(json_encode([ 'success' => true, 'transaction' => $sig, @@ -250,6 +320,34 @@ public function verifyAndSettle(Gate $gate, ServerRequestInterface $request): Pa ); } + /** + * Poll `getSignatureStatuses` until the broadcast transaction is + * confirmed or finalized. Throws on on-chain failure (`err`) or when + * the confirmation budget is exhausted. Mirrors + * {@see \PayKit\Protocols\Mpp\Server\SolanaChargeHandler::awaitConfirmation()}. + */ + private function awaitConfirmation(string $signature): void + { + $rpc = $this->rpc(); + for ($attempt = 0; $attempt < $this->confirmationAttempts; $attempt += 1) { + $statuses = $rpc->getSignatureStatuses([$signature]); + $status = $statuses[0] ?? null; + if (is_array($status)) { + if (($status['err'] ?? null) !== null) { + throw new RuntimeException( + "Transaction $signature failed: " . json_encode($status['err'], JSON_THROW_ON_ERROR), + ); + } + $confirmationStatus = $status['confirmationStatus'] ?? null; + if ($confirmationStatus === 'confirmed' || $confirmationStatus === 'finalized') { + return; + } + } + usleep($this->confirmationDelayMicros); + } + throw new RuntimeException("Timed out waiting for transaction $signature"); + } + private function caip2(): string { return $this->config->network->caip2(); diff --git a/php/src/Protocols/X402/Exact/Verifier.php b/php/src/Protocols/X402/Exact/Verifier.php index 9dae0e025..770fe783e 100644 --- a/php/src/Protocols/X402/Exact/Verifier.php +++ b/php/src/Protocols/X402/Exact/Verifier.php @@ -28,7 +28,12 @@ * 6. Mint match * 7. Destination ATA match (re-derived) * 8. Amount match - * 9. ix[3..6] in allowlist (memo + lighthouse + optional ATA-create) + * 9. ix[3..6] in allowlist (Lighthouse + Memo ONLY). Per the official + * x402 SVM exact contract the destination ATA MUST pre-exist; an + * Associated-Token-Program create instruction is NOT an allowed + * optional slot. Wallets inject Lighthouse guard instructions + * (Phantom 1, Solflare 2), so Lighthouse is allowed in any optional + * slot. * 10. Memo binding (exactly one if extra.memo set) * 11. Token program strict bind to extra.tokenProgram */ @@ -36,10 +41,13 @@ final class Verifier { public const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111'; public const MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'; - public const LIGHTHOUSE_PROGRAM = 'L1TEVtgA75k273wWz1s6XMmDhQY5i3MwcvKb4VbZzfK'; - public const ASSOCIATED_TOKEN_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'; + // Official x402 SVM exact Lighthouse program id (specs/schemes/exact/ + // scheme_exact_svm.md). The prior `L1TEVtgA75k...` value was wrong and + // would have rejected wallet-injected Lighthouse guards. + public const LIGHTHOUSE_PROGRAM = 'L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95'; + public const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; public const TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; - public const MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 50000; + public const MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5000000; /** * Verify a base64-encoded transaction against an offer. @@ -48,7 +56,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,destinationCreateAta:bool} + * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:int} */ public static function verify( string $transactionBase64, @@ -89,8 +97,13 @@ public static function verify( // Rules 4 + 5 + 6 + 7 + 8 + 11. $transfer = self::verifyTransfer($instructions[2], $accountKeys, $requirement, $managedSigners); - // Rule 9: ix[3..6] allowlist. - $destinationCreateAta = false; + // Rule 9: ix[3..6] allowlist. Optional slots may carry ONLY + // Lighthouse (wallet-injected guard) or SPL Memo. An + // Associated-Token-Program ATA-create is NOT permitted: per the + // official x402 SVM exact contract the destination ATA MUST + // pre-exist. Lighthouse is allowed in any optional slot because + // wallets inject a variable number of guards (Phantom 1, + // Solflare 2). $reasons = [ 'invalid_exact_svm_payload_unknown_fourth_instruction', 'invalid_exact_svm_payload_unknown_fifth_instruction', @@ -101,12 +114,7 @@ public static function verify( $program = self::programOf($accountKeys, $ix); $slotIndex = $i - 3; $allowed = ($program === self::MEMO_PROGRAM) - || ($slotIndex < 2 && $program === self::LIGHTHOUSE_PROGRAM); - if (!$allowed && $slotIndex < 2 - && self::validAtaCreate($ix, $accountKeys, $requirement, $transfer)) { - $destinationCreateAta = true; - $allowed = true; - } + || ($program === self::LIGHTHOUSE_PROGRAM); if (!$allowed) { throw new InvalidProofException( $reasons[$slotIndex] ?? 'invalid_exact_svm_payload_unknown_optional_instruction', @@ -120,7 +128,6 @@ public static function verify( self::findMemoMatch($accountKeys, $instructions, $expectedMemo); } - $transfer['destinationCreateAta'] = $destinationCreateAta; return $transfer; } @@ -177,9 +184,13 @@ private static function verifyTransfer( array $requirement, array $managedSigners, ): array { + // Rule 11: the transfer program id must be one of the two canonical + // SPL token programs. Rust derives the gate from the actual + // instruction program (verify.rs:373), NOT from a seller-pinned + // extra.tokenProgram, so an offer that omits extra.tokenProgram still + // verifies against a real Token / Token-2022 transfer. $program = self::programOf($accountKeys, $ix); - $tokenProgramExtra = self::stringExtra($requirement, 'tokenProgram', true); - if ($program !== $tokenProgramExtra && $program !== self::TOKEN_2022_PROGRAM) { + if ($program !== self::TOKEN_PROGRAM && $program !== self::TOKEN_2022_PROGRAM) { throw new InvalidProofException('invalid_exact_svm_payload_no_transfer_instruction'); } $data = $ix->data; @@ -244,43 +255,6 @@ private static function verifyTransfer( ]; } - /** - * @param object{programIdIndex:int,data:string,accountKeyIndexes:array} $ix - * @param list $accountKeys - * @param array $requirement - * @param array $transfer - */ - private static function validAtaCreate( - object $ix, - array $accountKeys, - array $requirement, - array $transfer, - ): bool { - if (self::programOf($accountKeys, $ix) !== self::ASSOCIATED_TOKEN_PROGRAM) { - return false; - } - $data = $ix->data; - if (strlen($data) < 1 || (ord($data[0]) !== 0 && ord($data[0]) !== 1)) { - return false; - } - if (count($ix->accountKeyIndexes) < 6) { - return false; - } - $ata = self::accountAt($accountKeys, $ix, 1); - $owner = self::accountAt($accountKeys, $ix, 2); - $mint = self::accountAt($accountKeys, $ix, 3); - if ($owner !== ($requirement['payTo'] ?? null)) { - return false; - } - if ($mint !== $transfer['mint']) { - return false; - } - if ($ata !== $transfer['destination']) { - return false; - } - return true; - } - /** * @param list $accountKeys * @param list}> $instructions diff --git a/php/tests/Middleware/RequirePaymentTest.php b/php/tests/Middleware/RequirePaymentTest.php index 5233bed9f..fa34469f1 100644 --- a/php/tests/Middleware/RequirePaymentTest.php +++ b/php/tests/Middleware/RequirePaymentTest.php @@ -76,6 +76,18 @@ public function test402BodyCarriesAcceptsEntries(): void $this->assertGreaterThanOrEqual(1, count($body['accepts'])); } + public function test402SetsCacheControlNoStore(): void + { + // main-audit medium finding 6: the umbrella 402 MUST NOT be + // 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); + $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); + $this->assertSame(402, $response->getStatusCode()); + $this->assertSame('no-store', $response->getHeaderLine('cache-control')); + } + public function testWwwAuthenticateHeaderStampedFromMpp(): void { $gate = new Gate(amount: Price::usd('0.10')); diff --git a/php/tests/MppConfigTest.php b/php/tests/MppConfigTest.php index 3a5ca102b..96a76ea4c 100644 --- a/php/tests/MppConfigTest.php +++ b/php/tests/MppConfigTest.php @@ -18,10 +18,13 @@ public function testDefaultsMatchCrossLanguageTarget(): void $this->assertNull($c->challengeBindingSecret); } - public function testExpiresInZeroRejected(): void + public function testExpiresInZeroIsDevOnlyOptOut(): void { - $this->expectException(ConfigurationException::class); - new MppConfig(expiresIn: 0); + // expiresIn = 0 is the explicit, documented dev-only never-expires + // opt-out. It must be accepted (not rejected); the Adapter turns it + // into an empty `expires` so the challenge never expires. + $c = new MppConfig(expiresIn: 0); + $this->assertSame(0, $c->expiresIn); } public function testExpiresInNegativeRejected(): void @@ -38,4 +41,57 @@ public function testWithChallengeBindingSecretReturnsCopy(): void $this->assertSame('abc', $b->challengeBindingSecret); $this->assertNull($a->challengeBindingSecret); } + + public function testResolveExpiresInAbsentUsesDefault(): void + { + // Mirrors the Laravel provider's `$cfg['mpp']['expires_in'] ?? null`: + // an absent key must fall back to the safe 120s default. + $this->assertSame(120, MppConfig::resolveExpiresIn(null)); + $this->assertSame(90, MppConfig::resolveExpiresIn(null, 90)); + } + + public function testResolveExpiresInEmptyStringRejectedNotNeverExpires(): void + { + // Regression: a mis-typed/empty `MPP_EXPIRES_IN` env arrives as "". + // PHP's (int)"" is 0, which MppConfig accepts as never-expires. The + // resolver must reject it instead of silently disabling expiry. + $this->expectException(ConfigurationException::class); + MppConfig::resolveExpiresIn(''); + } + + public function testResolveExpiresInNonNumericRejected(): void + { + // A non-numeric value (e.g. "abc", "120s") would (int)-cast to 0. + $this->expectException(ConfigurationException::class); + MppConfig::resolveExpiresIn('120s'); + } + + public function testResolveExpiresInBooleanRejected(): void + { + // (bool) true -> (int) 1, (bool) false -> (int) 0; both are wrong. + $this->expectException(ConfigurationException::class); + MppConfig::resolveExpiresIn(true); + } + + public function testResolveExpiresInExplicitZeroIsOptOut(): void + { + // Only an explicit integer/numeric 0 yields the never-expires opt-out. + $this->assertSame(0, MppConfig::resolveExpiresIn(0)); + $this->assertSame(0, MppConfig::resolveExpiresIn('0')); + $this->assertSame(0, MppConfig::resolveExpiresIn(0.0)); + } + + public function testResolveExpiresInValidIntegerPreserved(): void + { + $this->assertSame(300, MppConfig::resolveExpiresIn(300)); + $this->assertSame(300, MppConfig::resolveExpiresIn('300')); + $this->assertSame(300, MppConfig::resolveExpiresIn(' 300 ')); + $this->assertSame(300, MppConfig::resolveExpiresIn(300.0)); + } + + public function testResolveExpiresInFractionalRejected(): void + { + $this->expectException(ConfigurationException::class); + MppConfig::resolveExpiresIn(1.5); + } } diff --git a/php/tests/Protocols/Mpp/AdapterTest.php b/php/tests/Protocols/Mpp/AdapterTest.php index d5468f680..dbe5b5035 100644 --- a/php/tests/Protocols/Mpp/AdapterTest.php +++ b/php/tests/Protocols/Mpp/AdapterTest.php @@ -65,6 +65,52 @@ public function testAcceptsEntryIncludesSplitsForFeeBearingGate(): void $this->assertSame('300000', $entry['splits'][0]['amount']); } + public function testChargeRequestAmountIsGateTotalForFeeOnTop(): void + { + // Regression: chargeRequestFor pinned the bare base amount while + // acceptsEntry advertised the total, so a fee-on-top gate issued a + // challenge short by the on-top fee. The MPP wire derives the primary + // share as amount - sum(splits), so the merchant was undercharged the + // fee. The expected (and issued) charge request must use gate->total(). + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg); + $platform = Signer::generate()->pubkey(); + $gate = new Gate( + amount: Price::usd('10.00'), + feeOnTop: [$platform => Price::usd('0.30')], + ); + + $method = new \ReflectionMethod($adapter, 'chargeRequestFor'); + $method->setAccessible(true); + $chargeRequest = $method->invoke($adapter, $gate); + + // 10.00 base + 0.30 on top = 10.30 USDC = 10_300_000 micro-units. + $this->assertSame('10300000', $chargeRequest->amount); + + // acceptsEntry already advertised the total; the two must agree. + $req = (new Psr17Factory())->createServerRequest('GET', '/marketplace'); + $entry = $adapter->acceptsEntry($gate, $req); + $this->assertSame('10300000', $entry['amount']); + } + + public function testChargeRequestAmountUnchangedForFeeWithin(): void + { + // fee-within gates keep total == base, so the total switch is a no-op. + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg); + $platform = Signer::generate()->pubkey(); + $gate = new Gate( + amount: Price::usd('10.00'), + feeWithin: [$platform => Price::usd('0.30')], + ); + + $method = new \ReflectionMethod($adapter, 'chargeRequestFor'); + $method->setAccessible(true); + $chargeRequest = $method->invoke($adapter, $gate); + + $this->assertSame('10000000', $chargeRequest->amount); + } + public function testChallengeHeadersHaveWwwAuthenticate(): void { $cfg = $this->makeConfig(); @@ -85,4 +131,76 @@ public function testVerifyAndSettleWithoutAuthorizationRaises(): void $this->expectException(\PayKit\Exception\InvalidProofException::class); $adapter->verifyAndSettle($gate, $req); } + + private function makeConfigWithExpiresIn(int $expiresIn): Config + { + return new Config( + network: Network::SolanaDevnet, + operator: new Operator( + recipient: Signer::generate()->pubkey(), + signer: Signer::generate(), + feePayer: true, + ), + preflight: false, + mpp: new MppConfig(challengeBindingSecret: 'unit-test', expiresIn: $expiresIn), + ); + } + + public function testChallengeWiresMppExpiresIntoIssuance(): void + { + // main-audit finding 7: config.mpp.expiresIn must thread into every + // issued challenge as an RFC 3339 expires. Previously the adapter + // issued challenges with no expiry, so they never expired. + $cfg = $this->makeConfigWithExpiresIn(120); + $adapter = new Adapter($cfg); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + + $headers = $adapter->challengeHeaders($gate, $req); + $challenge = \PayKit\Protocols\Mpp\Core\Headers::parseWwwAuthenticate($headers['www-authenticate']); + + $this->assertNotSame('', $challenge->expires, 'challenge must carry an expires when expiresIn > 0'); + $parsed = \PayKit\Protocols\Mpp\Core\Rfc3339Parser::parse($challenge->expires); + $this->assertNotNull($parsed, 'expires must be valid RFC 3339'); + // ~120s in the future (allow generous slack for slow CI). + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $deltaSeconds = $parsed->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThan(60, $deltaSeconds); + $this->assertLessThanOrEqual(121, $deltaSeconds); + // A freshly-issued 120s challenge is not yet expired. + $this->assertFalse($challenge->isExpired($now)); + } + + public function testChallengeIsRejectedAfterExpiryWindow(): void + { + // The wired expiry must actually drive isExpired(): a challenge + // issued with a short TTL is expired once that window elapses. + $cfg = $this->makeConfigWithExpiresIn(1); + $adapter = new Adapter($cfg); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + + $headers = $adapter->challengeHeaders($gate, $req); + $challenge = \PayKit\Protocols\Mpp\Core\Headers::parseWwwAuthenticate($headers['www-authenticate']); + + $future = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT10S')); + $this->assertTrue($challenge->isExpired($future), 'challenge must be expired 10s past a 1s TTL'); + } + + public function testExpiresInZeroIsNeverExpiresOptOut(): void + { + // expiresIn = 0 is the documented dev-only opt-out: the challenge + // is issued with no expires and never expires. + $cfg = $this->makeConfigWithExpiresIn(0); + $adapter = new Adapter($cfg); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + + $headers = $adapter->challengeHeaders($gate, $req); + $challenge = \PayKit\Protocols\Mpp\Core\Headers::parseWwwAuthenticate($headers['www-authenticate']); + + $this->assertSame('', $challenge->expires, 'expiresIn=0 must issue an empty (never-expires) challenge'); + $farFuture = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->add(new \DateInterval('P3650D')); + $this->assertFalse($challenge->isExpired($farFuture)); + } } diff --git a/php/tests/Protocols/Mpp/Server/ChargeServerTest.php b/php/tests/Protocols/Mpp/Server/ChargeServerTest.php index 00b116b16..9109c490f 100644 --- a/php/tests/Protocols/Mpp/Server/ChargeServerTest.php +++ b/php/tests/Protocols/Mpp/Server/ChargeServerTest.php @@ -496,6 +496,72 @@ private function decodedMethodDetails(Challenge $challenge): array return Json::object($details, 'methodDetails'); } + public function testPinnedCurrencyRejectsCredentialWithDifferentCurrency(): void + { + // Tier-2 pinned-field backstop. A server pinned to USDC must reject a + // credential claiming a different currency even when the caller does + // not pass an expectedRequest, mirroring Rust verify_pinned_fields + // (rust/crates/mpp/src/server/charge.rs:457-468), which runs + // unconditionally on every credential. + $server = new ChargeServer(secretKey: 'secret', realm: 'api', pinnedCurrency: 'USDC'); + $challenge = $server->createChallenge(new ChargeRequest(amount: '1000', currency: 'USDT')); + $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); + + $result = $server->verifyAuthorizationHeader( + $credential->toAuthorizationHeader(), + $this->unusedVerifier(), + ); + + self::assertFalse($result->ok); + self::assertSame('charge request mismatch', $result->reason); + } + + public function testPinnedRecipientRejectsCredentialWithDifferentRecipient(): void + { + $server = new ChargeServer(secretKey: 'secret', realm: 'api', pinnedRecipient: 'expected-recipient'); + $challenge = $server->createChallenge( + new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'attacker-recipient'), + ); + $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); + + $result = $server->verifyAuthorizationHeader( + $credential->toAuthorizationHeader(), + $this->unusedVerifier(), + ); + + self::assertFalse($result->ok); + self::assertSame('charge request mismatch', $result->reason); + } + + public function testPinnedFieldsAcceptMatchingCredential(): void + { + // Happy-path guard: a credential whose currency and recipient match the + // pinned configuration passes the backstop and reaches the verifier. + $server = new ChargeServer( + secretKey: 'secret', + realm: 'api', + pinnedCurrency: 'USDC', + pinnedRecipient: 'pinned-recipient', + ); + $challenge = $server->createChallenge( + new ChargeRequest(amount: '1000', currency: 'USDC', recipient: 'pinned-recipient'), + ); + $credential = new Credential(challenge: $challenge->toEcho(), payload: ['type' => 'signature']); + + $result = $server->verifyAuthorizationHeader( + $credential->toAuthorizationHeader(), + new class () implements PaymentVerifier { + public function verify(Credential $credential, Challenge $challenge): VerificationResult + { + return VerificationResult::success(reference: 'tx-signature'); + } + }, + ); + + self::assertTrue($result->ok, $result->reason); + self::assertSame('tx-signature', $result->reference); + } + private function unusedVerifier(): PaymentVerifier { return new class () implements PaymentVerifier { diff --git a/php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php b/php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php index 6264ab54b..18992c4c8 100644 --- a/php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php +++ b/php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php @@ -420,6 +420,170 @@ public function testRejectsNonIdempotentAtaCreation(): void self::assertSame('Only idempotent ATA creation is allowed', $result->reason); } + public function testPushPathSkipsComputeBudgetPriceCap(): void + { + // Rust validate_parsed_instruction_allowlist (charge.rs:1873-1876) + // skips the compute-budget caps on the settled (push) path: a confirmed + // transaction with an above-cap unit price is accepted. The pull-mode + // pre-broadcast path still rejects it (asserted below), so this is the + // exact pull-vs-push divergence the fix introduces. + $fixture = $this->fixture(); + $request = $this->request($fixture); + $transaction = $this->transactionPayload( + $fixture, + includeSplitAta: true, + extraInstructions: [ComputeBudgetProgram::setComputeUnitPrice(5_000_001)], + ); + + $pull = $this->verify($request, $transaction); + self::assertFalse($pull->ok); + self::assertSame('compute unit price exceeds maximum', $pull->reason); + + $push = (new SolanaChargeTransactionVerifier()) + ->verifyTransactionPayload($transaction, $request); + self::assertTrue($push->ok, $push->reason); + } + + public function testPushPathSkipsComputeBudgetLimitCap(): void + { + $fixture = $this->fixture(); + $request = $this->request($fixture); + $transaction = $this->transactionPayload( + $fixture, + includeSplitAta: true, + extraInstructions: [ComputeBudgetProgram::setComputeUnitLimit(200_001)], + ); + + $pull = $this->verify($request, $transaction); + self::assertFalse($pull->ok); + self::assertSame('compute unit limit exceeds maximum', $pull->reason); + + $push = (new SolanaChargeTransactionVerifier()) + ->verifyTransactionPayload($transaction, $request); + self::assertTrue($push->ok, $push->reason); + } + + public function testClientPaysAtaCreationPayerMustMatchTransactionFeePayer(): void + { + // Client-pays-fees mode (methodDetails.feePayer absent). Rust defaults + // expected_ata_payer to the transaction fee payer + // (charge.rs:1299-1305), so an ATA-create funded by any other account + // is rejected. Before the fix PHP passed a null expected payer and + // skipped this binding entirely. + $fixture = $this->fixture(); + $request = $this->clientPaysRequest($fixture); + + $strangerPayer = PublicKey::fromBytes(str_repeat("\x0a", 32)); + $transaction = $this->clientPaysTransactionPayload($fixture, ataPayer: $strangerPayer); + + $result = $this->verify($request, $transaction); + + self::assertFalse($result->ok); + self::assertSame('ATA payer must match the transaction fee payer', $result->reason); + } + + public function testClientPaysAtaCreationByTransactionFeePayerAccepted(): void + { + // Happy-path guard for the fix: when the ATA-create payer is the + // transaction fee payer (the client paying its own fees), the charge + // still verifies. + $fixture = $this->fixture(); + $request = $this->clientPaysRequest($fixture); + $transaction = $this->clientPaysTransactionPayload($fixture, ataPayer: $fixture['payer']); + + $result = $this->verify($request, $transaction); + + self::assertTrue($result->ok, $result->reason); + } + + /** + * Client-pays-fees charge request: no server-side feePayer, so the client + * is both the transaction fee payer and the ATA-creation payer. + * + * @param array $fixture + */ + private function clientPaysRequest(array $fixture): ChargeRequest + { + return new ChargeRequest( + amount: '1000', + currency: $fixture['mint']->toBase58(), + recipient: $fixture['recipient']->toBase58(), + externalId: 'order-123', + methodDetails: [ + 'network' => 'localnet', + 'decimals' => 6, + 'tokenProgram' => TokenProgram::PROGRAM_ID, + 'splits' => [ + [ + 'recipient' => $fixture['splitRecipient']->toBase58(), + 'amount' => '250', + 'ataCreationRequired' => true, + 'memo' => 'split memo', + ], + ], + ], + ); + } + + /** + * Build a client-pays transaction whose fee payer (and transfer authority) + * is $fixture['payer'] but whose ATA-creation payer is configurable. + * + * @param array $fixture + */ + private function clientPaysTransactionPayload(array $fixture, PublicKey $ataPayer): string + { + $tokenProgram = TokenProgram::programId(); + $recipientAta = AssociatedTokenProgram::findAssociatedTokenAddress( + $fixture['recipient'], + $fixture['mint'], + $tokenProgram, + )[0]; + $splitAta = AssociatedTokenProgram::findAssociatedTokenAddress( + $fixture['splitRecipient'], + $fixture['mint'], + $tokenProgram, + )[0]; + + $instructions = [ + TokenProgram::transferChecked( + $fixture['sourceTokenAccount'], + $fixture['mint'], + $recipientAta, + $fixture['payer'], + 750, + 6, + $tokenProgram, + ), + AssociatedTokenProgram::createIdempotent( + $ataPayer, + $splitAta, + $fixture['splitRecipient'], + $fixture['mint'], + $tokenProgram, + ), + TokenProgram::transferChecked( + $fixture['sourceTokenAccount'], + $fixture['mint'], + $splitAta, + $fixture['payer'], + 250, + 6, + $tokenProgram, + ), + MemoProgram::create('order-123'), + MemoProgram::create('split memo'), + ]; + + $transaction = Transaction::new( + $instructions, + $fixture['payer'], + str_repeat("\x09", 32), + ); + + return base64_encode($transaction->serialize(verifySignatures: false)); + } + /** * @param array $fixture * @param array>|null $splits diff --git a/php/tests/Protocols/X402/ConfirmationTest.php b/php/tests/Protocols/X402/ConfirmationTest.php new file mode 100644 index 000000000..ece0c14cc --- /dev/null +++ b/php/tests/Protocols/X402/ConfirmationTest.php @@ -0,0 +1,117 @@ +pubkey(), + signer: Signer::generate(), + feePayer: true, + ), + preflight: false, + ); + return new Adapter( + $config, + recentBlockhashProvider: fn () => null, + rpc: $rpc, + confirmationAttempts: $attempts, + confirmationDelayMicros: 0, + ); + } + + private function invokeAwaitConfirmation(Adapter $adapter, string $signature): void + { + $method = new ReflectionMethod(Adapter::class, 'awaitConfirmation'); + $method->setAccessible(true); + $method->invoke($adapter, $signature); + } + + public function testConfirmationReturnsWhenConfirmed(): void + { + $rpc = new FakeRpcGateway( + statuses: [['err' => null, 'confirmationStatus' => 'confirmed']], + ); + $adapter = $this->makeAdapter($rpc); + // No exception means the confirm gate passed. + $this->invokeAwaitConfirmation($adapter, 'sigConfirmed'); + $this->addToAssertionCount(1); + } + + public function testConfirmationReturnsWhenFinalized(): void + { + $rpc = new FakeRpcGateway( + statuses: [['err' => null, 'confirmationStatus' => 'finalized']], + ); + $adapter = $this->makeAdapter($rpc); + $this->invokeAwaitConfirmation($adapter, 'sigFinalized'); + $this->addToAssertionCount(1); + } + + public function testConfirmationTimesOutWhenNeverConfirmed(): void + { + // RPC accepted the transaction but it only ever reaches + // `processed`; the gate must throw rather than report success. + $rpc = new FakeRpcGateway( + statuses: [['err' => null, 'confirmationStatus' => 'processed']], + ); + $adapter = $this->makeAdapter($rpc, attempts: 3); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Timed out waiting for transaction/'); + $this->invokeAwaitConfirmation($adapter, 'sigStuck'); + } + + public function testConfirmationTimesOutWhenStatusAlwaysNull(): void + { + // getSignatureStatuses returns null entries (signature unknown to + // the cluster) for the whole budget. Must throw, not succeed. + $rpc = new FakeRpcGateway(statuses: [null]); + $adapter = $this->makeAdapter($rpc, attempts: 3); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Timed out waiting for transaction/'); + $this->invokeAwaitConfirmation($adapter, 'sigUnknown'); + } + + public function testConfirmationThrowsOnOnChainFailure(): void + { + // The transaction landed but failed on-chain (err set). Must throw + // so no success header is emitted for a reverted transfer. + $rpc = new FakeRpcGateway( + statuses: [['err' => ['InstructionError' => [0, 'Custom']], 'confirmationStatus' => 'confirmed']], + ); + $adapter = $this->makeAdapter($rpc); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/failed/'); + $this->invokeAwaitConfirmation($adapter, 'sigFailed'); + } +} diff --git a/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php b/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php new file mode 100644 index 000000000..753de3cf5 --- /dev/null +++ b/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php @@ -0,0 +1,197 @@ +}> $optionals + * @return array{0:string,1:array} + */ + private function buildTransaction(array $optionals): array + { + $payer = Keypair::generate()->getPublicKey()->toBase58(); + $authority = Keypair::generate()->getPublicKey()->toBase58(); + $source = Keypair::generate()->getPublicKey()->toBase58(); + $payTo = Keypair::generate()->getPublicKey()->toBase58(); + $mint = self::USDC_MINT; + $tokenProgram = self::TOKEN_PROGRAM; + $destination = Mints::deriveAta($payTo, $mint, $tokenProgram); + $amount = 100000; + + // 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 + + // Build the ordered account-key table and an index resolver. + $keys = []; + $indexOf = function (string $addr) use (&$keys): int { + $i = array_search($addr, $keys, true); + if ($i === false) { + $keys[] = $addr; + return count($keys) - 1; + } + return $i; + }; + + $instructions = []; + $instructions[] = new CompiledInstructionV0( + $indexOf(self::COMPUTE_BUDGET), + [], + $computeLimitData, + ); + $instructions[] = new CompiledInstructionV0( + $indexOf(self::COMPUTE_BUDGET), + [], + $computePriceData, + ); + $instructions[] = new CompiledInstructionV0( + $indexOf($tokenProgram), + [$indexOf($source), $indexOf($mint), $indexOf($destination), $indexOf($authority)], + $transferData, + ); + foreach ($optionals as $opt) { + $accountIdxs = array_map($indexOf, $opt['accounts']); + $instructions[] = new CompiledInstructionV0( + $indexOf($opt['program']), + $accountIdxs, + $opt['data'], + ); + } + + // Ensure the fee payer is the first account (required signer). + array_unshift($keys, $payer); + // Re-resolve every index now that we prepended one key: shift by 1. + foreach ($instructions as $ix) { + $ix->programIdIndex += 1; + $ix->accountKeyIndexes = array_map(static fn (int $i): int => $i + 1, $ix->accountKeyIndexes); + } + + $message = new MessageV0(); + $message->numRequiredSignatures = 1; + $message->numReadonlySignedAccounts = 0; + $message->numReadonlyUnsignedAccounts = 0; + $message->staticAccountKeys = array_map( + static fn (string $addr): PublicKey => new PublicKey($addr), + $keys, + ); + $message->recentBlockhash = Base58::encode(str_repeat("\x01", 32)); + $message->compiledInstructions = $instructions; + + $tx = new VersionedTransaction($message); + $wire = $tx->serialize(verifySignatures: false); + + $requirement = [ + 'asset' => $mint, + 'payTo' => $payTo, + 'amount' => (string) $amount, + 'extra' => ['tokenProgram' => $tokenProgram, 'memo' => ''], + ]; + + return [base64_encode($wire), $requirement]; + } + + public function testTransactionWithoutOptionalsVerifies(): void + { + [$tx, $req] = $this->buildTransaction([]); + $result = Verifier::verify($tx, $req, ['someFacilitatorPubkeyThatIsNotInTx']); + $this->assertSame(self::TOKEN_PROGRAM, $result['program']); + $this->assertSame(100000, $result['amount']); + $this->assertArrayNotHasKey('destinationCreateAta', $result); + } + + public function testLighthouseOptionalInstructionAccepted(): void + { + // Wallets (Phantom 1, Solflare 2) inject Lighthouse guard + // instructions; the verifier MUST allow them in optional slots. + [$tx, $req] = $this->buildTransaction([ + ['program' => self::LIGHTHOUSE, 'data' => chr(0), 'accounts' => []], + ['program' => self::LIGHTHOUSE, 'data' => chr(0), 'accounts' => []], + ]); + $result = Verifier::verify($tx, $req, ['someFacilitatorPubkeyThatIsNotInTx']); + $this->assertSame(100000, $result['amount']); + } + + public function testMemoOptionalInstructionAccepted(): void + { + [$tx, $req] = $this->buildTransaction([ + ['program' => self::MEMO_PROGRAM, 'data' => 'abc123nonce', 'accounts' => []], + ]); + $result = Verifier::verify($tx, $req, ['someFacilitatorPubkeyThatIsNotInTx']); + $this->assertSame(100000, $result['amount']); + } + + public function testOfferWithoutExtraTokenProgramStillVerifies(): void + { + // Rule 11 transfer-program binding. Rust derives the program-id gate + // from the actual transfer instruction (verify.rs:373), accepting any + // canonical Token / Token-2022 transfer; it does NOT require a + // seller-pinned extra.tokenProgram. An offer that omits it must still + // verify a real Token-program transfer rather than rejecting with + // missing_extra_tokenProgram. + [$tx, $req] = $this->buildTransaction([]); + unset($req['extra']['tokenProgram']); + + $result = Verifier::verify($tx, $req, ['someFacilitatorPubkeyThatIsNotInTx']); + + $this->assertSame(self::TOKEN_PROGRAM, $result['program']); + $this->assertSame(100000, $result['amount']); + } + + public function testAtaCreateOptionalInstructionRejected(): void + { + // An Associated-Token-Program create instruction must NOT be an + // allowed optional slot. The destination ATA must pre-exist. + [$tx, $req] = $this->buildTransaction([ + [ + 'program' => self::ATA_PROGRAM, + 'data' => chr(1), // idempotent-create discriminator + 'accounts' => [ + Keypair::generate()->getPublicKey()->toBase58(), // payer + Keypair::generate()->getPublicKey()->toBase58(), // ata + Keypair::generate()->getPublicKey()->toBase58(), // owner + self::USDC_MINT, + '11111111111111111111111111111111', // system + self::TOKEN_PROGRAM, + ], + ], + ]); + $this->expectException(InvalidProofException::class); + $this->expectExceptionMessageMatches('/unknown_fourth_instruction/'); + Verifier::verify($tx, $req, ['someFacilitatorPubkeyThatIsNotInTx']); + } +} diff --git a/php/tests/Protocols/X402/Exact/VerifierTest.php b/php/tests/Protocols/X402/Exact/VerifierTest.php index 19b4da0bb..e91eda849 100644 --- a/php/tests/Protocols/X402/Exact/VerifierTest.php +++ b/php/tests/Protocols/X402/Exact/VerifierTest.php @@ -45,8 +45,23 @@ public function testRuleConstantsCanonical(): void // against haven't drifted. $this->assertSame('ComputeBudget111111111111111111111111111111', Verifier::COMPUTE_BUDGET_PROGRAM); $this->assertSame('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr', Verifier::MEMO_PROGRAM); + $this->assertSame('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', Verifier::TOKEN_PROGRAM); $this->assertSame('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', Verifier::TOKEN_2022_PROGRAM); - $this->assertSame('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', Verifier::ASSOCIATED_TOKEN_PROGRAM); - $this->assertSame(50000, Verifier::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS); + // Official x402 SVM exact Lighthouse program id. + $this->assertSame('L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95', Verifier::LIGHTHOUSE_PROGRAM); + // Canonical compute-unit-price cap matches the Rust spine + // (rust/crates/x402/src/protocol/schemes/exact/verify.rs:17). + $this->assertSame(5000000, Verifier::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS); + } + + public function testAssociatedTokenProgramConstantRemoved(): void + { + // The optional-slot allowlist is Lighthouse + Memo ONLY; the + // destination ATA must pre-exist. The Associated Token Program + // ATA-create path was removed, so the constant must not exist. + $this->assertFalse( + defined(Verifier::class . '::ASSOCIATED_TOKEN_PROGRAM'), + 'ASSOCIATED_TOKEN_PROGRAM must be removed: ATA-create is not an allowed optional slot', + ); } } diff --git a/ruby/lib/mpp.rb b/ruby/lib/mpp.rb index 58e53a216..861c69386 100644 --- a/ruby/lib/mpp.rb +++ b/ruby/lib/mpp.rb @@ -26,6 +26,13 @@ module Mpp DEFAULT_REALM = "MPP" + # Sentinel used to detect when the caller did not pass an explicit + # replay store. The sentinel allows us to distinguish "caller passed + # nil" (an error) from "caller never passed replay_store at all" + # (where we emit a dev-only warning and fall back to MemoryStore). + DEV_ONLY_MEMORY_STORE = :__mpp_dev_only_memory_store__ + private_constant :DEV_ONLY_MEMORY_STORE + # Build a server-side MPP instance. Pass it a method (e.g. one built by # Mpp::Protocol::Solana.charge), an HMAC secret_key for challenge signing, # a realm string for WWW-Authenticate, and an optional replay store. @@ -35,9 +42,22 @@ module Mpp # secret_key: "secret", # realm: "My App", # ) - def self.create(method:, secret_key:, realm: DEFAULT_REALM, replay_store: MemoryStore.new, + # + # PRODUCTION NOTE: `replay_store` defaults to a volatile in-memory store + # that is NOT safe for production use. It loses all replay markers on + # process restart and is not shared across workers or hosts. Supply a + # durable, process-shared store (e.g. Redis or Postgres-backed) in + # production to prevent same-signature replay across restarts. + def self.create(method:, secret_key:, realm: DEFAULT_REALM, replay_store: DEV_ONLY_MEMORY_STORE, settlement_header: Server::Charge::Handler::DEFAULT_SETTLEMENT_HEADER, expires_in: Protocol::Core::ChallengeStore::DEFAULT_EXPIRES_SECONDS) + if replay_store == DEV_ONLY_MEMORY_STORE + warn "[Mpp] WARNING: no replay_store supplied to Mpp.create — " \ + "defaulting to volatile MemoryStore. Replay markers are lost on " \ + "process restart and are NOT shared across workers or hosts. " \ + "Supply a durable shared store in production." + replay_store = MemoryStore.new + end Server::Charge.new( method: method, secret_key: secret_key, diff --git a/ruby/lib/mpp/protocol/intents/charge.rb b/ruby/lib/mpp/protocol/intents/charge.rb index adbb81244..b80439e66 100644 --- a/ruby/lib/mpp/protocol/intents/charge.rb +++ b/ruby/lib/mpp/protocol/intents/charge.rb @@ -58,9 +58,23 @@ def to_h }.compact end - # Parse the base-unit amount as an Integer. + # Largest value representable by an unsigned 64-bit integer. The + # Rust spine stores charge amounts as a base-unit `u64` + # (`rust/crates/mpp/src/protocol/intents/charge.rs:53-58`, + # `parse_amount` -> `u64`) and surfaces an `Invalid amount` error on + # overflow rather than letting an out-of-range value reach the + # on-chain transfer matcher. + U64_MAX = (2**64) - 1 + + # Parse the base-unit amount as an Integer, rejecting values that do + # not fit in a u64 so overflow surfaces as an explicit invalid-amount + # error instead of a downstream "No matching transfer" failure. + # Mirrors the Rust spine `ChargeRequest::parse_amount`. def amount_i - Integer(amount, 10) + value = Integer(amount, 10) + raise ArgumentError, "invalid amount: #{amount}" if value > U64_MAX + + value rescue ArgumentError raise ArgumentError, "invalid amount: #{amount}" end diff --git a/ruby/lib/mpp/protocol/solana/verifier.rb b/ruby/lib/mpp/protocol/solana/verifier.rb index 937f10dfd..66bebd87d 100644 --- a/ruby/lib/mpp/protocol/solana/verifier.rb +++ b/ruby/lib/mpp/protocol/solana/verifier.rb @@ -114,7 +114,14 @@ def expected_fee_payer(transaction, details) end def amount_from(split, label) - Integer(split.fetch("amount"), 10) + value = Integer(split.fetch("amount"), 10) + # Split amounts are base-unit u64 on the Rust spine; reject + # overflow here so it surfaces as an explicit invalid-amount error + # rather than a downstream "No matching transfer" failure. Mirrors + # `Intents::ChargeRequest::U64_MAX`. + raise VerificationError, "#{label} exceeds the maximum u64 amount" if value > Intents::ChargeRequest::U64_MAX + + value rescue KeyError, ArgumentError raise VerificationError, "#{label} must be an integer string" end diff --git a/ruby/lib/mpp/server/decorator.rb b/ruby/lib/mpp/server/decorator.rb index 68eb12464..27a9fcfaa 100644 --- a/ruby/lib/mpp/server/decorator.rb +++ b/ruby/lib/mpp/server/decorator.rb @@ -13,7 +13,10 @@ module Decorator def self.make_challenge_response(challenge, _realm = nil) [ challenge.status, - challenge.headers.merge("content-type" => "application/json"), + challenge.headers.merge( + "content-type" => "application/json", + "cache-control" => "no-store" + ), [JSON.generate(challenge.body)] ] end diff --git a/ruby/lib/pay_core/solana/mints.rb b/ruby/lib/pay_core/solana/mints.rb index f4a2478cf..3daa238cd 100644 --- a/ruby/lib/pay_core/solana/mints.rb +++ b/ruby/lib/pay_core/solana/mints.rb @@ -19,20 +19,35 @@ module Mints MEMO_PROGRAM = Programs::MEMO_PROGRAM COMPUTE_BUDGET_PROGRAM = Programs::COMPUTE_BUDGET_PROGRAM + # Testnet stablecoin mints alias the devnet addresses, matching the + # Rust spine `rust/crates/mpp/src/protocol/solana.rs:19-26` + # (USDC_TESTNET == USDC_DEVNET, USDG_TESTNET == USDG_DEVNET, + # PYUSD_TESTNET == PYUSD_DEVNET). Without the explicit "testnet" + # entry, `resolve(currency, "testnet")` fell back to the MAINNET mint, + # so a testnet-configured server verified SPL transferChecked against + # the mainnet mint while a spec/rust client built against the devnet + # mint. + USDC_DEVNET = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" + USDG_DEVNET = "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7" + PYUSD_DEVNET = "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM" + MINTS = { "USDC" => { - "devnet" => "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "devnet" => USDC_DEVNET, + "testnet" => USDC_DEVNET, "mainnet" => "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, "USDT" => { "mainnet" => "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" }, "USDG" => { - "devnet" => "4F6PM96JJxngmHnZLBh9n58RH4aTVNWvDs2nuwrT5BP7", + "devnet" => USDG_DEVNET, + "testnet" => USDG_DEVNET, "mainnet" => "2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH" }, "PYUSD" => { - "devnet" => "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + "devnet" => PYUSD_DEVNET, + "testnet" => PYUSD_DEVNET, "mainnet" => "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" }, "CASH" => { diff --git a/ruby/lib/pay_kit/rack/payment_required.rb b/ruby/lib/pay_kit/rack/payment_required.rb index ad409380d..048455689 100644 --- a/ruby/lib/pay_kit/rack/payment_required.rb +++ b/ruby/lib/pay_kit/rack/payment_required.rb @@ -72,14 +72,17 @@ def call(env) class << self def render_402(challenge) body = JSON.generate(challenge.to_h) - headers = {"content-type" => "application/json"}.merge(normalize_headers(challenge.headers)) + headers = { + "content-type" => "application/json", + "cache-control" => "no-store" + }.merge(normalize_headers(challenge.headers)) [402, headers, [body]] end def render_invalid(error) payload = {error: error.code.to_s, message: error.detail} payload[:spec_code] = error.spec_code if error.spec_code - [402, {"content-type" => "application/json"}, [JSON.generate(payload)]] + [402, {"content-type" => "application/json", "cache-control" => "no-store"}, [JSON.generate(payload)]] end # Rack 3 requires response header names to be lowercase. The diff --git a/ruby/lib/x402/protocol/schemes/exact/verify.rb b/ruby/lib/x402/protocol/schemes/exact/verify.rb index b71c55ebd..4660c4cc2 100644 --- a/ruby/lib/x402/protocol/schemes/exact/verify.rb +++ b/ruby/lib/x402/protocol/schemes/exact/verify.rb @@ -21,7 +21,7 @@ module Exact # 6. Mint match (verify.rs:395-400) # 7. Destination ATA match (re-derive) (verify.rs:402-405) # 8. Amount match (verify.rs:407-410) - # 9. ix[3..6] in allowlist (verify.rs:266-300) + # 9. ix[3..6] in allowlist (Lighthouse + Memo ONLY) (verify.rs:266-300) # 10. Memo binding (exactly one if extra.memo set) (verify.rs:283-300) # 11. Token program strict bind to extra.tokenProgram (verify.rs:380-395) module Verifier @@ -86,45 +86,31 @@ def verify_instructions!(account_keys:, instructions:, requirement:, managed_sig transfer = verify_transfer_instruction!(instructions.fetch(2), account_keys, requirement, managed_signers) reject_fee_payer_in_instruction_accounts!(instructions, account_keys, managed_signers) - destination_create_ata = false invalid_reason_by_index = [ "invalid_exact_svm_payload_unknown_fourth_instruction", "invalid_exact_svm_payload_unknown_fifth_instruction", "invalid_exact_svm_payload_unknown_sixth_instruction" ] - # INTENTIONAL_DIVERGENCE from spine: the Rust spine - # (`rust/crates/x402/src/protocol/schemes/exact/verify.rs:266`) and - # the TS spine (`typescript/packages/x402/src/facilitator/exact/scheme.ts:300`) - # permit only Memo + Lighthouse in slots 3-5. This port additionally - # allows `AssociatedTokenAccount::Create` / `CreateIdempotent` in slots - # 3-4 so a buyer can fund their own destination ATA in-band; the shape - # of that exception is structurally validated by - # `valid_destination_ata_create_instruction?` and paired with the - # ATA-create-payer-slot carve-out in - # `reject_fee_payer_in_instruction_accounts!`. Matches the Go and Lua - # ports. + # Rule 9: ix[3..6] allowlist. Optional slots may carry ONLY SPL + # Memo or Lighthouse (wallet-injected guard) in ANY optional + # slot. Per the official x402 SVM exact contract the destination + # ATA MUST pre-exist; an Associated-Token-Program ATA-create + # instruction is NOT an allowed optional slot and is rejected. + # Wallets inject a variable number of Lighthouse guards + # (Phantom 1, Solflare 2). Mirrors the PHP, Lua, and Go ports. + memo_program = Exact.base58_decode(Exact::MEMO_PROGRAM) + lighthouse_program = Exact.base58_decode(Exact::LIGHTHOUSE_PROGRAM) instructions.drop(3).each_with_index do |instruction, index| program = instruction_program(instruction, account_keys) - allowed_programs = if index == 2 - [Exact.base58_decode(Exact::MEMO_PROGRAM)] - else - [Exact.base58_decode(Exact::LIGHTHOUSE_PROGRAM), Exact.base58_decode(Exact::MEMO_PROGRAM)] - end - if index < 2 && program == Exact.base58_decode(Exact::ASSOCIATED_TOKEN_PROGRAM) && - valid_destination_ata_create_instruction?(instruction, account_keys, requirement, transfer) - destination_create_ata = true - next - end - next if allowed_programs.include?(program) + next if program == memo_program || program == lighthouse_program raise invalid_reason_by_index.fetch(index, "invalid_exact_svm_payload_unknown_optional_instruction") end # Rule 10: memo binding (spine verify.rs:283-300). expected_memo = Exact.string_extra(requirement, "memo", required: false) - return transfer.merge(destination_create_ata: destination_create_ata) if expected_memo.nil? + return transfer if expected_memo.nil? - memo_program = Exact.base58_decode(Exact::MEMO_PROGRAM) memo_instructions = instructions.drop(3).select do |instruction| instruction_program(instruction, account_keys) == memo_program end @@ -133,7 +119,7 @@ def verify_instructions!(account_keys:, instructions:, requirement:, managed_sig raise "invalid_exact_svm_payload_memo_mismatch" unless actual_memo_bytes.dup.force_encoding("UTF-8").valid_encoding? raise "invalid_exact_svm_payload_memo_mismatch" unless actual_memo_bytes == expected_memo.b - transfer.merge(destination_create_ata: destination_create_ata) + transfer end # Rule 2: ComputeBudget SetComputeUnitLimit (spine verify.rs:240-248). @@ -163,7 +149,17 @@ def verify_compute_price_instruction!(instruction, account_keys) # (spine verify.rs:380-410). def verify_transfer_instruction!(instruction, account_keys, requirement, managed_signers) program = instruction_program(instruction, account_keys) - allowed_programs = [Exact.base58_decode(Exact.string_extra(requirement, "tokenProgram")), Exact.base58_decode(Exact::TOKEN_2022_PROGRAM)] + # Rule 11: bind to the canonical SPL token program set, NOT to + # `extra.tokenProgram`. Mirrors the Rust spine + # `rust/crates/x402/src/protocol/schemes/exact/verify.rs:371-375` + # which accepts either Token or Token-2022 by the ACTUAL + # instruction program and never derives the gate from + # `extra.tokenProgram`. A client that omits `extra.tokenProgram` + # (or pins a different-but-canonical program than the offer's + # default) is still accepted as long as the on-chain transfer + # uses one of the two canonical programs; the destination ATA is + # re-derived below using this actual program, so it stays bound. + allowed_programs = [Exact.base58_decode(Exact::Mints::TOKEN_PROGRAM), Exact.base58_decode(Exact::TOKEN_2022_PROGRAM)] unless allowed_programs.include?(program) raise "invalid_exact_svm_payload_no_transfer_instruction" end @@ -211,19 +207,11 @@ def verify_transfer_instruction!(instruction, account_keys, requirement, managed # vector where an extra instruction (TransferChecked, SystemProgram # Transfer, etc.) names the fee payer as a signer or source. # INTENTIONAL_DIVERGENCE from spine: the Rust spine has no such - # sweep. The Ruby port mirrors the Go and Lua port carve-out - # for ATA-create's funding-payer slot 0. + # sweep. The destination ATA must pre-exist (no in-band ATA-create + # is allowed), so there is no funding-payer carve-out. def reject_fee_payer_in_instruction_accounts!(instructions, account_keys, managed_signers) - ata_program = Exact.base58_decode(Exact::ASSOCIATED_TOKEN_PROGRAM) instructions.each do |instruction| - accounts = instruction.fetch(:accounts) - program = instruction_program(instruction, account_keys) - carve_out_payer_slot = - program == ata_program && ata_create_data?(instruction.fetch(:data)) - - accounts.each_with_index do |index, position| - next if carve_out_payer_slot && position.zero? - + instruction.fetch(:accounts).each do |index| if managed_signers.include?(account_key_for_index(index, account_keys)) raise "invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts" end @@ -231,39 +219,6 @@ def reject_fee_payer_in_instruction_accounts!(instructions, account_keys, manage end end - def ata_create_data?(data) - # ATA program instruction discriminator: - # empty data -> Create (legacy variant) - # single byte 0x00 -> Create - # single byte 0x01 -> CreateIdempotent - return true if data.bytesize.zero? - return false unless data.bytesize == 1 - - first = data.getbyte(0) - first == 0 || first == 1 - end - - def valid_destination_ata_create_instruction?(instruction, account_keys, requirement, transfer) - data = instruction.fetch(:data) - return false unless data.bytesize <= 1 - return false if data.bytesize == 1 && ![0, 1].include?(data.getbyte(0)) - - accounts = instruction.fetch(:accounts) - return false if accounts.length < 6 - - associated_account = account_key_for_index(accounts.fetch(1), account_keys) - wallet = account_key_for_index(accounts.fetch(2), account_keys) - mint = account_key_for_index(accounts.fetch(3), account_keys) - system_program = account_key_for_index(accounts.fetch(4), account_keys) - token_program = account_key_for_index(accounts.fetch(5), account_keys) - - associated_account == transfer.fetch(:destination) && - wallet == Exact.base58_decode(requirement.fetch("payTo")) && - mint == transfer.fetch(:mint) && - system_program == Exact.base58_decode(Exact::SYSTEM_PROGRAM) && - token_program == transfer.fetch(:token_program) - end - def instruction_program(instruction, account_keys) account_key_for_index(instruction.fetch(:program_index), account_keys) end diff --git a/ruby/lib/x402/server/exact.rb b/ruby/lib/x402/server/exact.rb index bd359e7d2..8cf810ab7 100644 --- a/ruby/lib/x402/server/exact.rb +++ b/ruby/lib/x402/server/exact.rb @@ -380,8 +380,10 @@ def verify_token_accounts_exist!(config, transfer) unless config.account_checker.call(config, Types.base58_encode(transfer.fetch(:source))) raise "source token account does not exist" end - return if transfer.fetch(:destination_create_ata) + # Per the official x402 SVM exact contract the destination ATA MUST + # pre-exist; the verifier rejects any in-band ATA-create, so the + # destination is always checked here. unless config.account_checker.call(config, Types.base58_encode(transfer.fetch(:destination))) raise "destination token account does not exist" end diff --git a/ruby/test/api_test.rb b/ruby/test/api_test.rb index 4b8f5dfb7..47cfa59b4 100644 --- a/ruby/test/api_test.rb +++ b/ruby/test/api_test.rb @@ -86,7 +86,8 @@ class MppCreateTest < Minitest::Test def test_create_returns_a_server_instance server = Mpp.create( method: Mpp::Protocol::Solana.charge(recipient: "x", currency: "USDC", rpc: StubRpc.new), - secret_key: "secret" + secret_key: "secret", + replay_store: Mpp::MemoryStore.new ) assert_instance_of Mpp::Server::Charge, server @@ -133,7 +134,8 @@ def test_charge_accepts_a_different_currency_per_call rpc: StubRpc.new ), secret_key: "secret", - realm: "Test" + realm: "Test", + replay_store: Mpp::MemoryStore.new ) # Per-call override doesn't crash and still produces a Challenge for a @@ -163,7 +165,8 @@ def build_server Mpp.create( method: Mpp::Protocol::Solana.charge(recipient: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", currency: "USDC", rpc: StubRpc.new), secret_key: "secret", - realm: "Test" + realm: "Test", + replay_store: Mpp::MemoryStore.new ) end end @@ -178,6 +181,17 @@ def test_make_challenge_response_returns_a_rack_triplet assert_equal "application/json", headers["content-type"] assert_equal({"error" => "payment_required"}, JSON.parse(body.first)) end + + # Regression: 402 responses must carry Cache-Control: no-store so that + # proxies and browsers do not cache payment challenges. + def test_make_challenge_response_includes_cache_control_no_store + challenge = Mpp::Challenge.new(www_authenticate: "Payment realm=\"Test\"", body: {"error" => "payment_required"}) + + _status, headers, _body = Mpp::Server::Decorator.make_challenge_response(challenge) + + assert_equal "no-store", headers["cache-control"], + "402 challenge response must include Cache-Control: no-store" + end end class MiddlewareTest < Minitest::Test @@ -199,6 +213,18 @@ def test_returns_402_when_route_declares_a_charge_without_auth assert headers.key?(Mpp::Protocol::Core::Headers::WWW_AUTHENTICATE) end + # Regression: 402 challenge responses surfaced through the Rack middleware + # must include Cache-Control: no-store so proxies and browsers cannot + # cache payment challenges. + def test_402_challenge_includes_cache_control_no_store + middleware = Mpp::Server::Middleware.new(paid_app, handler: build_server) + + _status, headers, _body = middleware.call({"PATH_INFO" => "/paid"}) + + assert_equal "no-store", headers["cache-control"], + "402 challenge response must include Cache-Control: no-store" + end + def test_settlement_result_merges_headers_into_app_response settlement = Mpp::Settlement.new( signature: "sig", @@ -229,7 +255,11 @@ def test_unexpected_handler_result_raises private def build_server - Mpp.create(method: Mpp::Protocol::Solana.charge(recipient: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", currency: "USDC", rpc: StubRpc.new), secret_key: "secret") + Mpp.create( + method: Mpp::Protocol::Solana.charge(recipient: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", currency: "USDC", rpc: StubRpc.new), + secret_key: "secret", + replay_store: Mpp::MemoryStore.new + ) end def free_app @@ -252,7 +282,7 @@ def paid_app class SinatraHelperTest < Minitest::Test def test_mpp_charge_halts_with_402_when_auth_missing - server = Mpp.create(method: Mpp::Protocol::Solana.charge(recipient: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", currency: "USDC", rpc: StubRpc.new), secret_key: "secret", realm: "T") + server = Mpp.create(method: Mpp::Protocol::Solana.charge(recipient: "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", currency: "USDC", rpc: StubRpc.new), secret_key: "secret", realm: "T", replay_store: Mpp::MemoryStore.new) app = Class.new(Sinatra::Base) do helpers Mpp::Sinatra::Helpers set :mpp_server, server diff --git a/ruby/test/charge_request_test.rb b/ruby/test/charge_request_test.rb index d61dc19ce..a04274b59 100644 --- a/ruby/test/charge_request_test.rb +++ b/ruby/test/charge_request_test.rb @@ -49,4 +49,18 @@ def test_rejects_zero_and_invalid_method_details assert_raises(ArgumentError) { Mpp::Protocol::Intents::ChargeRequest.new(amount: "1", currency: "SOL", method_details: "bad") } assert_raises(ArgumentError) { Mpp::Protocol::Intents::ChargeRequest.from_h("bad") } end + + # Rust spine parity (rust/crates/mpp/src/protocol/intents/charge.rs:53-58): + # charge amounts are base-unit u64. An amount above u64::MAX must surface + # as an explicit invalid-amount error from amount_i rather than passing + # through to the on-chain transfer matcher as a "No matching transfer". + # u64::MAX itself must parse. + def test_amount_i_rejects_values_above_u64_max + max = Mpp::Protocol::Intents::ChargeRequest.new(amount: ((2**64) - 1).to_s, currency: "USDC") + assert_equal (2**64) - 1, max.amount_i + + overflow = Mpp::Protocol::Intents::ChargeRequest.new(amount: (2**64).to_s, currency: "USDC") + error = assert_raises(ArgumentError) { overflow.amount_i } + assert_match(/invalid amount/, error.message) + end end diff --git a/ruby/test/mpp/dev_store_warning_test.rb b/ruby/test/mpp/dev_store_warning_test.rb new file mode 100644 index 000000000..a75663451 --- /dev/null +++ b/ruby/test/mpp/dev_store_warning_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +# Regression for: Mpp.create must loudly warn when no replay_store is +# supplied (the default volatile MemoryStore is dev-only and unsafe in +# production). +class DevStoreWarningTest < Minitest::Test + include RubyMppTestHelpers + + def method_fixture + Mpp::Protocol::Solana.charge( + recipient: pubkey(2), + currency: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + network: "localnet", + rpc: "http://127.0.0.1:8899" + ) + end + + # When no replay_store is passed, Mpp.create must: + # (a) emit a warning to stderr that includes "MemoryStore" and "no replay_store" + # (b) still return a working Mpp::Server::Charge instance + def test_no_store_argument_emits_dev_warning + warned = nil + + # Capture the Kernel.warn output without actually printing it. + Mpp.stub(:warn, ->(msg) { warned = msg }) do + server = Mpp.create(method: method_fixture, secret_key: "test-secret") + assert_kind_of Mpp::Server::Charge, server + end + + refute_nil warned, "expected a warning to be emitted" + assert_match(/no replay_store/i, warned) + assert_match(/MemoryStore/i, warned) + assert_match(/production/i, warned) + end + + # When an explicit replay_store is passed (even a MemoryStore), no + # warning must be emitted. This ensures the warning is opt-in — callers + # that knowingly use MemoryStore in tests can pass Mpp::MemoryStore.new + # explicitly and stay warning-free. + def test_explicit_store_argument_suppresses_warning + warned = [] + explicit_store = Mpp::MemoryStore.new + + Mpp.stub(:warn, ->(msg) { warned << msg }) do + server = Mpp.create( + method: method_fixture, + secret_key: "test-secret", + replay_store: explicit_store + ) + assert_kind_of Mpp::Server::Charge, server + end + + assert_empty warned, "expected no warning when an explicit store is provided" + end + + # When an explicit FileStore is passed, no warning must be emitted. + def test_explicit_file_store_suppresses_warning + warned = [] + Dir.mktmpdir do |dir| + file_store = Mpp::FileStore.new(File.join(dir, "replay.json")) + + Mpp.stub(:warn, ->(msg) { warned << msg }) do + server = Mpp.create( + method: method_fixture, + secret_key: "test-secret", + replay_store: file_store + ) + assert_kind_of Mpp::Server::Charge, server + end + end + + assert_empty warned, "expected no warning when FileStore is provided" + end +end diff --git a/ruby/test/mpp/expires_in_test.rb b/ruby/test/mpp/expires_in_test.rb index 9d6c7df88..271a17b98 100644 --- a/ruby/test/mpp/expires_in_test.rb +++ b/ruby/test/mpp/expires_in_test.rb @@ -48,7 +48,7 @@ def test_mpp_create_threads_expires_in network: "devnet", rpc: "https://api.devnet.solana.com" ) - server = ::Mpp.create(method: method, secret_key: "secret", realm: "Test", expires_in: 42) + server = ::Mpp.create(method: method, secret_key: "secret", realm: "Test", expires_in: 42, replay_store: ::Mpp::MemoryStore.new) store = server.instance_variable_get(:@challenge_store) assert_equal 42, store.default_expires_seconds end diff --git a/ruby/test/pay_kit/middleware_test.rb b/ruby/test/pay_kit/middleware_test.rb index d9348d892..4db84115c 100644 --- a/ruby/test/pay_kit/middleware_test.rb +++ b/ruby/test/pay_kit/middleware_test.rb @@ -144,6 +144,37 @@ def test_inline_form_returns_402_with_inline_amount assert_equal "0.25", body["accepts"].first["amount"] end + # Regression: all PayKit 402 responses (payment_required challenge) must + # include Cache-Control: no-store so that proxies and browsers do not + # cache payment challenges or invalid-proof responses. + def test_402_payment_required_includes_cache_control_no_store + get "/report" + + assert_equal 402, last_response.status + assert_equal "no-store", last_response.headers["cache-control"], + "PayKit 402 challenge response must include Cache-Control: no-store" + end + + def test_render_402_class_method_includes_cache_control_no_store + challenge = PayKit::Challenge.new( + resource: "/report", + accepts: [], + headers: {} + ) + _status, headers, _body = PayKit::Rack::PaymentRequired.render_402(challenge) + + assert_equal "no-store", headers["cache-control"], + "render_402 must include Cache-Control: no-store" + end + + def test_render_invalid_class_method_includes_cache_control_no_store + error = PayKit::InvalidProof.new(:bad_proof, "test error") + _status, headers, _body = PayKit::Rack::PaymentRequired.render_invalid(error) + + assert_equal "no-store", headers["cache-control"], + "render_invalid must include Cache-Control: no-store" + end + private def build_app diff --git a/ruby/test/server_test.rb b/ruby/test/server_test.rb index 717264166..36b472071 100644 --- a/ruby/test/server_test.rb +++ b/ruby/test/server_test.rb @@ -336,6 +336,21 @@ def test_rejects_split_amount_boundaries assert_match(/split amounts exceed/, result.reason) end + # Rust spine parity (rust/crates/mpp/src/protocol/intents/charge.rs:53-58): + # split amounts are base-unit u64. A split amount above u64::MAX must + # surface as an explicit invalid-amount error rather than a downstream + # "No matching transfer" failure. + def test_rejects_split_amount_above_u64_max + tx = tx_base64( + account_keys: [pubkey(1), pubkey(2), PROGRAMS::SYSTEM_PROGRAM], + instructions: [compiled_instruction(2, [0, 1], u32(2) + u64(1000))] + ) + overflow_split = [{"recipient" => pubkey(3), "amount" => (2**64).to_s}] + result = @verifier.verify_transaction_payload(tx, charge_request(method_details: {"splits" => overflow_split})) + refute result.ok? + assert_match(/exceeds the maximum u64 amount/, result.reason) + end + def test_rejects_fee_payer_missing_key_and_mismatch tx = tx_base64( account_keys: [pubkey(1), pubkey(2), PROGRAMS::SYSTEM_PROGRAM], diff --git a/ruby/test/support_test.rb b/ruby/test/support_test.rb index 025840802..5254b910b 100644 --- a/ruby/test/support_test.rb +++ b/ruby/test/support_test.rb @@ -31,6 +31,24 @@ def test_stablecoin_resolution_and_token_programs assert_nil ::PayCore::Solana::Mints.symbol_for("unknown", "localnet") end + # Rust spine parity (rust/crates/mpp/src/protocol/solana.rs:19-26 and + # :45-60): testnet stablecoin mints alias the devnet addresses + # (USDC_TESTNET == USDC_DEVNET, etc.). Before the fix `resolve(_, "testnet")` + # fell back to the mainnet mint, so a testnet-configured server verified + # SPL transferChecked against the mainnet mint while a rust client built + # against the devnet mint. + def test_testnet_stablecoin_mints_alias_devnet + devnet_usdc = ::PayCore::Solana::Mints.resolve("USDC", "devnet") + assert_equal devnet_usdc, ::PayCore::Solana::Mints.resolve("USDC", "testnet") + refute_equal "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ::PayCore::Solana::Mints.resolve("USDC", "testnet") + + assert_equal ::PayCore::Solana::Mints.resolve("USDG", "devnet"), + ::PayCore::Solana::Mints.resolve("USDG", "testnet") + assert_equal ::PayCore::Solana::Mints.resolve("PYUSD", "devnet"), + ::PayCore::Solana::Mints.resolve("PYUSD", "testnet") + end + def test_base58_round_trip_and_invalid_character encoded = ::PayCore::Solana::Base58.encode("\x00\x00abc".b) assert_equal "\x00\x00abc".b, ::PayCore::Solana::Base58.decode(encoded) diff --git a/ruby/test/x402_server_exact_test.rb b/ruby/test/x402_server_exact_test.rb index 7c33b56b5..8048f9176 100644 --- a/ruby/test/x402_server_exact_test.rb +++ b/ruby/test/x402_server_exact_test.rb @@ -251,6 +251,36 @@ def test_settlement_rejects_transaction_amount_mismatch_before_sending assert_empty sent end + # Rust spine parity (rust/crates/x402/src/protocol/schemes/exact/ + # verify.rs:371-375): the transfer program-id gate binds to the canonical + # SPL token program set {Token, Token-2022} by the ACTUAL instruction + # program, NOT to `extra.tokenProgram`. A credential whose offer omits + # `extra.tokenProgram` but whose on-chain transfer uses the canonical + # Token program must still verify. Before the fix the verifier read + # `extra.tokenProgram` as required and raised on its absence. + def test_verifier_accepts_transfer_without_extra_token_program + state = build_state + requirement = X402::Server::Exact.exact_requirement(state) + transaction = Base64.decode64( + JSON.parse(Base64.decode64(build_payment_header(state))) + .fetch("payload").fetch("transaction") + ) + + requirement_without_token_program = Marshal.load(Marshal.dump(requirement)) + requirement_without_token_program.fetch("extra").delete("tokenProgram") + + transfer = X402::Protocol::Schemes::Exact::Verifier.verify( + transaction, + requirement_without_token_program, + managed_signers: [state.fee_payer.raw_public_key] + ) + + assert_equal( + X402::Protocol::Schemes::Exact.base58_decode(::PayCore::Solana::Mints::TOKEN_PROGRAM), + transfer.fetch(:token_program) + ) + end + def test_settlement_rejects_fee_payer_as_transfer_authority_before_sending sent = [] state = build_state(sender: ->(_state, transaction) { @@ -384,22 +414,54 @@ def test_settlement_accepts_clean_envelope_positive_control X402::Server::Exact.settle_exact_payment(state, build_payment_header(state)) end - def test_settlement_rejects_lighthouse_as_sixth_instruction + # Wallets inject a variable number of trailing Lighthouse guard + # instructions (Phantom 1, Solflare 2). Per the official x402 SVM exact + # contract Lighthouse is an allowed optional program in ANY optional + # slot, so a single trailing guard must be accepted. Mirrors the PHP, + # Lua, and Go ports. + def test_settlement_accepts_single_trailing_lighthouse_guard + state = build_state(sender: ->(_state, _transaction) { "unit-settlement" }) + payment_header = mutate_payment_transaction(build_payment_header(state), resign: true) do |transaction| + append_optional_instruction(transaction, X402::Protocol::Schemes::Exact::LIGHTHOUSE_PROGRAM) + end + + assert_equal "unit-settlement", X402::Server::Exact.settle_exact_payment(state, payment_header) + end + + # Two trailing Lighthouse guards (Solflare's shape) must also be + # accepted. The corrected program id is + # `L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95`; the prior stale + # `L1TE...` value would have rejected wallet-injected guards. + def test_settlement_accepts_two_trailing_lighthouse_guards + state = build_state(sender: ->(_state, _transaction) { "unit-settlement" }) + payment_header = mutate_payment_transaction(build_payment_header(state), resign: true) do |transaction| + append_optional_instruction(transaction, X402::Protocol::Schemes::Exact::LIGHTHOUSE_PROGRAM) + append_optional_instruction(transaction, X402::Protocol::Schemes::Exact::LIGHTHOUSE_PROGRAM) + end + + assert_equal "unit-settlement", X402::Server::Exact.settle_exact_payment(state, payment_header) + end + + # An Associated-Token-Program ATA-create instruction MUST NOT be an + # allowed optional slot: per the official x402 SVM exact contract the + # destination ATA MUST pre-exist. The first optional slot after the + # transfer (here slot index 1, the fifth instruction, since the base + # envelope carries a memo at ix[3]) carries the rejected ATA-create. + def test_settlement_rejects_ata_create_optional_instruction sent = [] state = build_state(sender: ->(_state, transaction) { sent << transaction "unit-settlement" }) - payment_header = mutate_payment_transaction(build_payment_header(state)) do |transaction| - append_optional_instruction(transaction, X402::Protocol::Schemes::Exact::LIGHTHOUSE_PROGRAM) - append_optional_instruction(transaction, X402::Protocol::Schemes::Exact::LIGHTHOUSE_PROGRAM) + payment_header = mutate_payment_transaction(build_payment_header(state), resign: true) do |transaction| + append_valid_destination_ata_create_instruction(transaction, state) end error = assert_raises(RuntimeError) do X402::Server::Exact.settle_exact_payment(state, payment_header) end - assert_equal "invalid_exact_svm_payload_unknown_sixth_instruction", error.message + assert_equal "invalid_exact_svm_payload_unknown_fifth_instruction", error.message assert_empty sent end @@ -560,22 +622,6 @@ def test_settlement_rejects_missing_destination_token_account_before_sending assert_empty sent end - def test_settlement_skips_missing_destination_account_when_create_ata_is_present - checked = [] - state = build_state( - account_checker: ->(_state, account) { - checked << account - true - } - ) - payment_header = mutate_payment_transaction(build_payment_header(state), resign: true) do |transaction| - append_valid_destination_ata_create_instruction(transaction, state) - end - - assert_equal "unit-settlement", X402::Server::Exact.settle_exact_payment(state, payment_header) - assert_equal 1, checked.length - end - def test_server_rejects_unsigned_payload_before_facilitator_sign sent = [] signed_with_facilitator = [] @@ -1131,9 +1177,8 @@ def append_system_transfer_from_fee_payer(transaction) end # Append a memo-program instruction whose accounts vector names the fee - # payer at position 1 (a non-carve-out slot). The sweep must reject - # before settlement, regardless of which slot the fee payer appears in - # (only ATA-create's funding-payer slot 0 is carved out). + # payer at position 1. The sweep must reject before settlement, + # regardless of which slot the fee payer appears in. def append_memo_with_fee_payer_at_slot_one(transaction) message_offset = 1 + (2 * 64) account_count_offset = message_offset + 4 diff --git a/skills/pay-sdk-implementation/SKILL.md b/skills/pay-sdk-implementation/SKILL.md index 375d581d8..64ae80817 100644 --- a/skills/pay-sdk-implementation/SKILL.md +++ b/skills/pay-sdk-implementation/SKILL.md @@ -72,7 +72,15 @@ the directory skeleton and CI from earlier ones. `harness/src/implementations.ts`. Run the focused matrix (`MPP_INTEROP_CLIENTS= MPP_INTEROP_SERVERS=rust pnpm test` and the inverse) before flipping `enabled: true`. -7. **Write the README last.** Read `references/readme-template.md` and +7. **Apply the operability caveats.** Read + `references/operability-caveats.md`. These are the gaps the Ruby + gem's PR #142 follow-up closed (default `localnet` RPC, mainnet + mint fallback on `localnet`, preflight + Surfnet cheatcode + auto-bootstrap, MPP HMAC secret auto-resolution chain, embedded + `recentBlockhash` in the x402 challenge, framework-host quirks). + Every port has to land them; PRs that omit any of the numbered + items need an explicit "not applicable" note in the body. +8. **Write the README last.** Read `references/readme-template.md` and fill in the title, badges, repo layout, basic snippet, install/usage, client and server matrices (with the seven rows above), example walkthrough, Solana dependency list, and links to spec. The matrix diff --git a/skills/pay-sdk-implementation/references/operability-caveats.md b/skills/pay-sdk-implementation/references/operability-caveats.md new file mode 100644 index 000000000..117c74300 --- /dev/null +++ b/skills/pay-sdk-implementation/references/operability-caveats.md @@ -0,0 +1,170 @@ +# Operability caveats + +Acceptance criteria every PayKit port has to land, distilled from the +maintainer follow-ups on the first two reference implementations: + +- Ruby gem: [PR #142](https://github.com/solana-foundation/pay-kit/pull/142), + the Sinatra-example-was-broken follow-up. +- Lua rock: [PR #141](https://github.com/solana-foundation/pay-kit/pull/141), + the OpenResty port that carried the same caveats forward. + +Each numbered item below is a hard requirement. PRs that omit one need +an explicit "not applicable" note in the body explaining the +language-specific reason (e.g. "Rack 3 header casing has no analogue +in Go because `net/http` accepts mixed-case writes by default"). No +silent omissions. + +### 1. `solana_localnet` falls back to the mainnet mint row + +`PayCore::Solana::Mints::MINTS` only ships `mainnet` and `devnet` rows +for each stablecoin. `mint_for(:USDC, :solana_localnet)` used to raise +on the first 402 with "stablecoin :USDC not configured for network +:solana_localnet". Surfpool / the hosted localnet at +`https://402.surfnet.dev:8899` clone mainnet state, so the mainnet +mint exists on them. Resolve `localnet` to the mainnet mint when no +explicit `localnet` row is set, matching `PayCore::Solana::Mints.resolve` +and the Rust spine's `resolve_stablecoin_mint`. + +### 2. Default `localnet` RPC = `https://402.surfnet.dev:8899` + +`http://localhost:8899` requires the developer to run a local validator +(`solana-test-validator`, Surfpool, …) before the example app does +anything. Default to the hosted Surfpool endpoint so +`configure { c.network = :solana_localnet }` boots against a reachable +RPC with no extra setup. + +**Per-language status:** + +- **Ruby** — implemented. `config.rb:9` sets `DEFAULT_RPC_URL = + "https://402.surfnet.dev:8899"` and `config.rb:16` reads it as the + env-overridable default. Boots out of the box. +- **Lua** — NOT YET IMPLEMENTED. `lua/mpp/protocol/solana.lua:45` + returns `http://localhost:8899` for `localnet`. The operator must set + `MPP_RPC_URL` or pass `rpc_url` in config to reach Surfpool. Do not + imply parity with Ruby until this default is updated. + +### 3. Boot-time preflight with Surfnet cheatcode auto-bootstrap + +`configure` runs two soundness checks **before locking the config**: + +1. **Fee-payer SOL balance** — operator signer's pubkey has at least + `MIN_FEE_PAYER_LAMPORTS = 1_000_000` (0.001 SOL = ~200 settlement + txs at 5000 lamports each). +2. **Recipient ATA exists** — for each `c.stablecoins`, the + operator's recipient owns an ATA for the resolved mint. + +When the check fails: + +- **On `solana_localnet` with the gem-shipped demo signer**: + auto-bootstrap via Surfnet's cheatcodes (`surfnet_setAccount` + funds the fee-payer with `AUTOFUND_LAMPORTS = 10_000_000_000` = + 10 SOL; `surfnet_setTokenAccount` provisions the missing ATA at + amount 0, state `initialized`). The example app "just works" + against `https://402.surfnet.dev:8899` with no manual setup. +- **Everywhere else** (real mainnet/devnet, or a non-demo signer + on any network): raise `ConfigurationError` at boot, naming the + missing pubkey / ATA and how to create it. + +RPC failures during preflight are **logged, not raised** — an +unreachable endpoint never blocks boot; the runtime will resurface +the connection problem on the first request. + +**Opt-out**: `c.preflight = false` (in `configure`) or +`PAY_KIT_DISABLE_PREFLIGHT=1` (env var). The test suite sets the env +var in its test helper so the offline suite never tries to hit a +real RPC. The preflight file itself is excluded from the coverage +gate via `add_filter` (or the language equivalent), with a comment +documenting the rationale — it wraps live RPC + cheatcode calls +that don't fit a unit suite. + +### 4. MPP HMAC secret auto-resolution + +This is a **portable requirement** for every server-side PayKit port. +The HMAC challenge-binding secret must survive process restarts to +avoid invalidating in-flight challenges, and must be operator-injectable +without a code change in production. + +The canonical resolution order (first hit wins): + +1. `ENV["PAY_KIT_MPP_CHALLENGE_BINDING_SECRET"]` — production + pattern (orchestrator-supplied env var). +2. `./.env` parsed for the same key — sticky across restarts, shared + by workers in the same project root. +3. Generate `SecureRandom.hex(32)` (or language-native CSPRNG hex) + and append to `./.env` (mode `0600` if the file is being created) + so subsequent boots reuse the same value. + +If `./.env` is unwritable (read-only container, etc.), fall back to +the in-memory generated value and log a warning — the server still +boots, but the secret rotates per process and invalidates in-flight +challenges on restart. Point the operator at the env var as the +production override. + +The dotenv parser is intentionally a small tolerant reader: blank +lines, `#` comments, and `KEY=value` / `KEY="value"` / `KEY='value'` +forms. No new dependency on a dotenv library. + +**Per-language status:** + +- **Ruby** — implemented. Preflight resolves the secret via env, + `.env`, then CSPRNG fallback with `.env` write-back. +- **Lua** — GAP / NOT YET IMPLEMENTED. `lua/mpp/server/init.lua:35` + reads `config.secret_key or os.getenv('MPP_SECRET_KEY')` and raises + an error if the value is absent. There is no `.env` file parsing, no + CSPRNG generation, and no write-back. The operator must supply the + secret explicitly. Mark this N/A for auto-resolution until the + preflight/env-file layer is added. PRs porting the Lua server must + note this gap explicitly in the PR body. + +### 5. x402 challenge embeds the server's recent blockhash + +The server fetches `getLatestBlockhash` against its own RPC at +challenge-build time and stamps the result into +`accepted.extra.recentBlockhash`. The pay-kit Rust client reads this +field at parse + tx-build time. Closes the surfpool / forked-mainnet +drift where the client called `getLatestBlockhash` against the public +devnet RPC and the server's surfnet ledger had never seen that hash. + +Scope note: this is **pay-kit Rust client only**. Canonical x402 SDKs +(TS, Go) ignore `accepted.extra.recentBlockhash` and unconditionally +call `getLatestBlockhash` against their own RPC. Promoting this into +the canonical wire format is an x402-foundation spec discussion. On +real mainnet/devnet the field is harmless (RPCs agree); on localnet +/ surfpool it's the difference between "works end-to-end with pay +curl" and "client gives up with 402 again". + +Inject via `recent_blockhash_provider:` (or the language-native kwarg +pattern) so unit tests stay offline. + +### 6. Framework-host quirks (per-language; flag in this language's spec) + +Each language's web framework has its own friction points that this +port has to absorb. Ruby's were: + +- **Rack 3 lint enforces lowercase response header names.** Wire-level + constants (`PAYMENT-REQUIRED`, `PAYMENT-RESPONSE`, MPP + `WWW-Authenticate`) stay canonical (uppercase); downcase happens + only at the Rack response boundary. +- **Sinatra's exception pipeline** dumps backtrace to stderr when an + exception is raised, before checking for a registered handler. + `require_payment!` uses Sinatra's `halt` to short-circuit instead + of raising `PaymentRequired`. Exception class still carries + `http_status => 402` as belt-and-suspenders. + +Land your language's equivalents (Flask exception class, FastAPI +`HTTPException`, Laravel `ResponseFactory::abort`, Go `http.Error` ++ middleware bypass, etc.). Pin them to the issue before shipping. + +### 7. Test + coverage gates + +- Preflight file excluded from the coverage gate (live RPC + cheatcode + calls don't fit a unit suite). Document the filter inline so it + doesn't look arbitrary. +- Unit tests cover both preflight knobs (`c.preflight = false` and the + env-var kill switch) by stubbing `PayKit::Preflight.run` and asserting + the spy fires / doesn't fire. No live RPC in the unit suite. +- Branch coverage on the protocol-critical paths (x402 11-rule + verifier, MPP credential parsing, Ed25519 cosign) regardless of + language. Filter the lint-but-don't-test files (asset generators, + re-export shims) from the coverage denominator. + diff --git a/skills/pay-sdk-implementation/references/readme-template.md b/skills/pay-sdk-implementation/references/readme-template.md index 61bfcecb3..a8418562a 100644 --- a/skills/pay-sdk-implementation/references/readme-template.md +++ b/skills/pay-sdk-implementation/references/readme-template.md @@ -178,9 +178,12 @@ it sounds like a textbook or a marketing page, rewrite. language has the same safety rail. Frame it as a bullet under "Two safety rails fire at boot" after snippet 3. - MPP HMAC secret auto-resolution (env → `.env` → generate + - persist) is a Ruby preflight feature. Each language needs its - equivalent before snippet 3 can ship, and the README should - describe whichever resolution chain that language settled on. + persist) is a preflight feature shipped in Ruby PR #142. Each + language needs its equivalent before snippet 3 can ship, and the + README should describe whichever resolution chain that language + settled on. The full caveat list lives in + `references/operability-caveats.md` — read it before drafting + snippet 3. - `pay curl` link target: first mention in snippet 1 should link forward to `#run-the-example`. Avoids the reader Googling "what is pay curl".