Skip to content

fix(main): x402/MPP server-SDK safety (php/lua/go/ruby) + operability-caveats docs#144

Closed
EfeDurmaz16 wants to merge 27 commits into
solana-foundation:mainfrom
EfeDurmaz16:docs/pr142-operability-caveats-skill
Closed

fix(main): x402/MPP server-SDK safety (php/lua/go/ruby) + operability-caveats docs#144
EfeDurmaz16 wants to merge 27 commits into
solana-foundation:mainfrom
EfeDurmaz16:docs/pr142-operability-caveats-skill

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Originally the operability-caveats skill reference. Now also folds the validated main-branch non-Rust/TS server-SDK safety fixes into a single PR (per request), all aligned to the official x402 exact SVM spec (coinbase/x402 scheme_exact_svm) and the rust reference contract.

Docs

Go

  • Strict post-match instruction allowlist: reject any unmatched System / Token / Token-2022 / ATA / unknown-program instruction after the expected payment (only ComputeBudget, matched payment/memo, and validated idempotent ATA-create within expected owners). Mirrors the rust validate_instruction_allowlist.
  • Pull settlement: clear the replay-marker cleanup flag immediately after SendTransaction succeeds, so a confirmation/verify timeout never deletes the consumed marker (the broadcast tx can still land) — closes a double-pay window.
  • x402 client: always append a Memo (extra.memo, else a random 16-byte hex nonce) per spec; verifier allows only the Lighthouse + Memo optional slots.

Lua

  • MPP route binding: compare the full methodDetails (stripping only recentBlockhash) and externalId against the route-expected request, and settle from the expected — closes replay-to-a-different-route / fee-split misrouting (mirrors PHP/Ruby).
  • Wire mpp.expires_in into challenge issuance (challenges had no TTL).
  • Default localnet RPC to the hosted Surfpool endpoint; resolve the MPP challenge-binding secret from env / .env / CSPRNG (Ruby parity).
  • 402 responses set Cache-Control: no-store; default replay store warns dev-only.

PHP

  • x402 exact: reserve the signature between broadcast and confirmation, poll getSignatureStatuses until confirmed/finalized, throw on on-chain failure or timeout before returning settlement success (mirrors the MPP charge path).
  • Remove ATA-create from the x402 exact verifier optional allowlist (Lighthouse + Memo only; destination ATA must pre-exist), per the x402 svm exact spec.
  • Wire MppConfig.expiresIn into challenge issuance; 402 responses set Cache-Control: no-store.

Ruby

  • 402 responses set Cache-Control: no-store; the default in-memory replay store warns dev-only.

Verification

  • go: go build/vet/test ./... green (local). php: phpunit 329 + phpstan + cs-fixer clean. lua: 543 specs pass. ruby: 393 pass. Each fix carries a fail-before/pass-after regression test.
  • Cross-language x402/MPP interop (surfpool) recommended in CI before merge.

Reviewing this PR

This PR ports the same set of safety fixes across four SDKs, each aligned to the rust spine (rust/crates/mpp, rust/crates/x402). Reviewing one language's logic transfers to the others; the notes below point at the file that matters per SDK and the shared invariant it enforces.

Read in this order

  1. Rust reference (context, not changed): rust/crates/mpp/src/server/charge.rs (validate_instruction_allowlist, validate_create_ata_idempotent_instruction) and rust/crates/x402/src/protocol/schemes/exact/. Everything else mirrors these.
  2. Go, the closest port of the rust allowlist.
  3. PHP / Lua / Ruby, the parallel ports.

Per-SDK reading guide

  • Go
    • go/protocols/mpp/server/server.go: strict post-match instruction allowlist (validateInstructionAllowlist, validateCreateATAIdempotentInstruction, feePayerKey) and the replay-marker pin moved to immediately after SendTransaction.
    • go/protocols/x402/client/client.go: always-append Memo (seller extra.memo or random hex nonce; nonceSource is swappable for golden tests).
    • go/protocols/x402/verify.go + x402.go: trailing-slot allowlist (Lighthouse + Memo) and expectedMemo binding.
  • PHP
    • php/src/Protocols/X402/Adapter.php: reserve signature between broadcast and confirmation, then awaitConfirmation before returning success.
    • php/src/Protocols/X402/Exact/Verifier.php: corrected Lighthouse program id, ATA-create dropped from optional allowlist.
    • php/src/Protocols/Mpp/MppConfig.php (resolveExpiresIn) + Mpp/Adapter.php (challengeExpires): TTL wiring and the malformed-expires_in guard.
    • php/src/Middleware/RequirePayment.php: Cache-Control: no-store on 402.
  • Lua
    • lua/pay_kit/protocols/mpp/server/init.lua: full methodDetails + externalId route binding (comparable_method_details, canonical compare), settle-from-expected.
    • lua/pay_kit/protocols/mpp/init.lua: TTL wiring into issuance, default volatile-replay-store warning.
    • lua/pay_kit/internal/config.lua: localnet RPC default, MPP secret resolution, expires_in = false opt-out.
    • lua/pay_kit/preflight.lua (new): boot checks, secret resolution, .env persistence.
  • Ruby
    • ruby/lib/mpp.rb: dev-only replay-store sentinel + warning.
    • ruby/lib/mpp/server/decorator.rb, ruby/lib/pay_kit/rack/payment_required.rb: Cache-Control: no-store on 402.

Risk areas

  • Go pull-settlement replay marker: the cleanup flag is now cleared right after SendTransaction, so a confirmation/verify timeout no longer deletes the consumed marker. Confirm the marker is never reopened on the timeout path.
  • PHP x402 confirm-before-success: new awaitConfirmation polling loop (40 attempts x 250ms). Verify timeout and on-chain err both throw before any success header is returned.
  • Lua MPP route binding: verify_credential_with_expected now binds the full methodDetails. The minimal {amount, currency, recipient} form intentionally skips method-detail binding; confirm callers that need shape pinning pass methodDetails.
  • Cross-SDK ATA-create: PHP and Lua now reject ATA-create in the x402 optional slots (destination ATA must pre-exist). Ruby x402 (ruby/lib/x402/protocol/schemes/exact/verify.rb) is out of scope for this PR and still permits ATA-create; that divergence is intentional here and tracked as a follow-up.

Test entry points

  • Go: cd go && go test ./...; coverage gate go test -coverprofile=build/coverage.out ./... && go tool cover -func=build/coverage.out | tail -1 (>= 91.0%). Key suites: protocols/mpp/server/server_allowlist_test.go, server_replay_durability_test.go, protocols/x402/{client,verify}_test.go.
  • PHP: cd php && composer test && composer run lint. Key suites: tests/Protocols/X402/{ConfirmationTest,Exact/AtaCreateRejectTest}.php, tests/MppConfigTest.php, tests/Protocols/Mpp/AdapterTest.php.
  • Lua: cd lua && eval "$(luarocks --lua-version=5.1 --tree lua_modules path)" && luajit tests/run.lua. Key specs: tests/cross_route_replay_spec.lua, tests/pay_kit/{main_fixes,preflight,x402_verify_negative}_spec.lua.
  • Ruby: cd ruby && bundle exec standardrb && bundle exec ruby -Itest test/run.rb. Key suites: test/mpp/dev_store_warning_test.rb, test/pay_kit/middleware_test.rb.

@EfeDurmaz16 EfeDurmaz16 force-pushed the docs/pr142-operability-caveats-skill branch from c0954fc to 639537e Compare May 30, 2026 15:06
@EfeDurmaz16 EfeDurmaz16 changed the title docs(skill): operability-caveats reference for future PayKit ports fix(main): x402/MPP server-SDK safety (php/lua/go/ruby) + operability-caveats docs May 30, 2026
@EfeDurmaz16 EfeDurmaz16 marked this pull request as ready for review May 30, 2026 17:56
@EfeDurmaz16 EfeDurmaz16 force-pushed the docs/pr142-operability-caveats-skill branch from b6e720b to a704ea3 Compare May 30, 2026 21:19
…tokenProgram

The Rust spine x402 exact verifier gates the transferChecked program-id
against the fixed {Token, Token-2022} set by the actual instruction
program and never derives the gate from extra.tokenProgram
(rust/crates/x402/src/protocol/schemes/exact/verify.rs:371-375). Ruby
required extra.tokenProgram and built its allowlist from it, rejecting a
credential whose offer omitted the field even when the on-chain transfer
used the canonical Token program. Switch to the canonical set; the
destination ATA is still re-derived from the actual instruction program.
The Rust spine aliases testnet stablecoin mints to their devnet
addresses (rust/crates/mpp/src/protocol/solana.rs:19-26, :45-60:
USDC_TESTNET == USDC_DEVNET, USDG_TESTNET == USDG_DEVNET,
PYUSD_TESTNET == PYUSD_DEVNET). Ruby's mint table had only devnet and
mainnet keys, so resolve(currency, "testnet") fell back to the mainnet
mint. A testnet-configured server then verified SPL transferChecked
against the mainnet mint while a rust client built against the devnet
mint, so matching failed. Add explicit testnet entries that alias the
devnet mints.
The Rust spine stores MPP charge amounts as base-unit u64 and surfaces an
explicit invalid-amount error on overflow
(rust/crates/mpp/src/protocol/intents/charge.rs:53-58, parse_amount ->
u64). Ruby parsed amounts as arbitrary-precision Integers with no upper
bound, so a value above u64::MAX passed through construction and split
arithmetic and was only rejected later as a downstream no-matching
transfer. Cap amount_i and the split-amount parser at u64::MAX so
overflow surfaces as the spine's invalid-amount error. The
construction-time acceptance of "0"/leading-zero amounts is left
unchanged (see report).
Match the canonical Rust x402 verifier on two points:

- Raise the compute-unit-price cap from 50000 to 5000000
  micro-lamports to match MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS in
  rust/crates/x402/src/protocol/schemes/exact/verify.rs.
- Derive the transfer program-id gate from the actual instruction
  program (Token or Token-2022) instead of requiring a seller-pinned
  extra.tokenProgram. An offer that omits extra.tokenProgram now
  verifies a real Token-program transfer, matching verify_transfer_instruction.

Regression tests assert the canonical cap and that an offer without
extra.tokenProgram still verifies.
The Rust mpp charge server runs verify_pinned_fields on every
credential, comparing the decoded request currency and recipient
against the values fixed at server construction
(rust/crates/mpp/src/server/charge.rs:457-468). The PHP ChargeServer
only compared fields when the caller passed an expectedRequest.

Add optional pinnedCurrency and pinnedRecipient constructor params and
compare them unconditionally inside verifyAuthorizationHeader, so a
credential that drifts from the pinned configuration is rejected even
without an expectedRequest. Regression tests cover reject-on-drift and
accept-on-match.
Two mpp charge verifier divergences from the Rust spine:

- Default the expected ATA-creation payer to the transaction fee payer
  when no route fee payer is configured (client-pays-fees mode),
  mirroring expected_ata_payer = fee_payer.unwrap_or(tx_fee_payer) in
  rust/crates/mpp/src/server/charge.rs. The PHP path passed a null
  expected payer in client-pays mode and skipped the ATA-payer binding
  entirely.
- Skip the compute-budget unit-limit/price caps on the push (on-chain)
  verification path. The Rust parsed-instruction allowlist does a bare
  continue on compute-budget instructions for settled transactions
  (charge.rs:1873-1876); only the pull-mode pre-broadcast path enforces
  the caps. The push path threads an onChain flag through verification.

Regression tests assert the client-pays ATA-payer binding (reject on
stranger payer, accept on fee-payer) and the pull-rejects /
push-accepts split for above-cap compute budget.
The Rust spine caps SetComputeUnitPrice at 5_000_000 micro-lamports
(rust/crates/x402/src/protocol/schemes/exact/verify.rs:17). The Lua
verifier used 50_000, rejecting canonical wallet transactions whose
compute-unit price sits above 50k but under the protocol cap.
Match the Rust spine (verify.rs:373): the transferChecked program id is
accepted iff it is TOKEN_PROGRAM or TOKEN_2022_PROGRAM, derived from the
actual instruction. The Lua verifier required extra.tokenProgram and
bound the program id to it, rejecting spec-valid canonical offers that
omit extra.tokenProgram.
The Lua verifier reconstructed u64 values via float multiplication,
losing precision above 2^53. The Rust spine uses u64::from_le_bytes
(verify.rs:405-409, :350-354), so a high-range amount or compute-unit
price could collide with a different value through rounding. Decode
byte-wise into an arbitrary-precision decimal string and compare via
the uint helper so the full u64 range is exact.
Asserts the rust-matching behaviour for the three x402 verifier fixes:
compute-price cap at 5M, transfer program bound to the canonical token
set without extra.tokenProgram, and exact u64 amount decoding above 2^53.
Existing positive/negative specs updated for the exact-decimal transfer
descriptor amount.
Port the canonical-wire precedence from rust
(protocol/schemes/exact/types.rs Deserialize, client/exact/payment.rs
build_payment) onto the Go AcceptsEntry parse path:

- amount falls back to maxAmountRequired, payTo to recipient, asset to
  currency (top-level canonical fields win over extra mirrors)
- decimals defaults to 6 when absent (requirements.decimals.unwrap_or(6))
- tokenProgram defaults to the per-currency default when omitted
- feePayer is a boolean toggle plus key, so an explicit false opts out
- native SOL is detected when the currency resolves to no mint, so a
  literal SOL asset routes to a system transfer
- currency matching resolves both offer and preference sides
- preferred network defaults to mainnet and cluster slugs normalize to
  CAIP-2 before comparison
- seller-pinned extra.memo over 256 bytes is rejected at build time
- maxTimeoutSeconds defaults to 300 and the parsed offer retains its
  verbatim bytes for accepted echo
Match the rust x402 server spine on two server-side surfaces:

- verify.go now returns the specific invalid_exact_svm_payload_* reason
  codes from verify.rs (instructions_length, compute_limit/price,
  no_transfer_instruction, fee_payer_transferring_funds, mint/recipient/
  amount mismatch, positional unknown optional instruction, memo
  count/mismatch) and VerifyAndSettle surfaces them verbatim instead of
  collapsing every structural failure to charge_request_mismatch
- VerifyAndSettle enforces the credential's echoed accepted offer against
  the route requirements before any transaction processing, mirroring
  verify_envelope_payload: targeted network/amount/recipient/currency
  checks plus a canonical structural backstop, rejecting a credential
  that lies about what it is paying for
Match the Rust spine (charge.rs:1623-1624): a transferChecked whose
decimals byte disagrees with the route's expected decimals is not a
valid match. The Lua verifier ignored the decimals byte, so a transfer
of the right amount with the wrong decimals could satisfy the charge.
Match the Rust spine (charge.rs:1649-1657): when a route configures a
fee payer, a transferChecked that sources funds from the fee-payer's own
associated token account must be rejected, even when the transfer
authority is a different key. The Lua verifier only guarded the
authority slot, leaving the source-ATA drain shape open.
Match the Rust spine extract_parsed_instructions (charge.rs:2218-2230):
concatenate meta.innerInstructions[*].instructions with the top-level
message instructions before running the transfer matchers and allowlist.
A settled transaction whose payment was emitted through a CPI previously
failed to match because only top-level instructions were inspected.
Asserts the rust-matching behaviour for the three MPP verifier fixes:
transferChecked decimals pinning, fee-payer source-ATA drain rejection,
and inner-CPI instruction matching on the confirmed path.
Port the rust mpp charge verifier guards onto the Go server path
(rust/crates/mpp/src/server/charge.rs):

- SPL transferChecked rejects the configured fee payer as the transfer
  authority and rejects the fee payer's token account as the source ATA
  (verify_spl_transfer_instructions, both hard rejects)
- SPL transferChecked decimals byte must match the challenge-pinned
  decimals
- SOL transfer rejects the configured fee payer as the funding source
  (verify_sol_transfer_instructions)
- ataCreationRequired splits require a raw SPL mint-address currency,
  enforced both at challenge issuance (validate_charge_options) and in
  the verify path when the currency is a symbol
- push mode verifies on-chain before consuming the replay marker and
  never deletes it on failure (verify_push -> consume_signature)
- v0 transactions carrying address lookup tables are rejected with a
  structured error (reject_address_lookup_tables)
…ssignment

golangci-lint flagged the parse-alias raw field as unused (the public
AcceptsEntry.raw is the one wired for the verbatim accepted echo); remove the
dead alias field. luacheck flagged a remainder value overwritten before use in
the x402 rust-parity spec; declare it at the assignment site.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Superseded by #153, which carries this PR's server-SDK safety content for go/ruby/php/lua plus the consistent layout/naming/examples/README/CI cleanup (addressing the review feedback) and the new interop conformance harness. Closing in favor of #153.

@EfeDurmaz16 EfeDurmaz16 closed this Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant