feat(ruby): pay-kit v2 unified gate API + module restructure#138
Conversation
…eferences (solana-foundation#122) Per maintainer guidance in solana-foundation#122, this is a transversal cleanup PR: Part A — remove internal kitchen references - Drop M1/M2/M3 milestone framing from swift/README.md, swift/Examples/README.md - Reword 'M1 baseline / M2-followup' coverage gate comments in python/pyproject.toml and .github/workflows/python.yml as plain coverage gate descriptions - Remove 'M1 closure / L6 audit row' tag from lua/mpp/protocol/core/error_codes.lua Part B — rename tests/interop to harness - git mv tests/interop harness - Update all path references repo-wide (.github/workflows/*, READMEs, .gitignore, docs, composer.json, .php-cs-fixer.dist.php, skill files) - Fix relative paths inside the harness now that depth dropped by one (rust-client/Cargo.toml, php-server, ruby-server, go.mod replace lines, src/implementations.ts, test/compute-budget-caps.test.ts REPO_ROOT) - Update Go module identifiers harness/{go-client,go-server} to match path - Refresh internal comments/docs that still mentioned tests/interop Part C — skill / README polish - Skill references and intent docs now point at harness/* paths Closes solana-foundation#122.
Adds the canonical x402 `exact` intent to the cross-language interop harness, plus TypeScript reference client and server fixtures and matrix wiring that registers the Rust spine adapters already shipped under `rust/crates/x402/src/bin/`. Language adapters can now target the harness contract (X402_INTEROP_* env vars, ready/result JSON shapes) to validate against the Rust spine cell. The TS reference fixture carries a stub credential payload (challenge id + resource) so the harness wiring, negative-code classification, cross-server portability, and idempotent-resubmit flows can run without a full Solana signer. Pair restriction in the matrix gates TS↔TS and Rust↔Rust by default; full TS↔Rust on-chain settlement parity lands with a follow-up SDK port. The legacy MPP charge runner hard-skips the new intent so default `pnpm test` behaviour is unchanged.
…dation#20 Port the Ruby x402 exact adapter from solana-foundation/x402-sdk PR solana-foundation#20 (tip 45e618f, Codex Round 4, 0 real P1, Confidence 4/5) into ruby/lib/x402/, following Ludo's rust/crates/x402/ pattern. Behavioral parity with rust/crates/x402 spine: - Program IDs, mints, CAIP-2 network IDs match types.rs verbatim - MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000, MAX_MEMO_BYTES = 256 - Lighthouse passthrough by program-ID only (no discriminator allowlist) - Compute-unit limit unbounded (parity-tracked) - Sign-then-verify ordering, resource binding, fee-payer attack guard - strict_decode64 for headers, short_vec UTF-8 fix, memo byte comparison - Namespace: X402SDK::Interop -> X402::Interop (top-level rename only) Tests: 42 server runs / 135 assertions; 26 client runs / 54 assertions; 0 failures across both suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-spine wiring on top of pr/transversal-cleanup (solana-foundation#131) and pr/x402-harness-intent (solana-foundation#132). 0 new P1; ruby-x402-client and ruby-x402-server adapters register cleanly with intents: [x402-exact]. Matrix enumerates 9 pairs; allowedPair gate still blocks ruby pairs from running, tracked as P2 follow-up.
Rename `verify_fee_payer_not_in_instruction_accounts!` to `reject_fee_payer_in_instruction_accounts!` and add an explicit carve-out for the `AssociatedTokenAccount::Create` / `CreateIdempotent` funding payer slot (account index 0). Every other position in every other instruction now rejects when the fee payer appears in the accounts list, closing the inherited drain vector where a malicious client could attach an extra SPL TransferChecked or SystemProgram::Transfer that names the managed signer before the facilitator co-signs. Attack regression coverage in `ruby/test/x402_interop_server_test.rb`: - DRAIN (SPL): extra TransferChecked names fee payer → reject - DRAIN (SOL): SystemProgram::Transfer from fee payer → reject - SLOT: ATA-create with fee payer at the wallet slot (not slot 0) → reject - Positive control: ATA-create funded by fee payer at slot 0 → accept
Confirms P1: 0 (inherited fee-payer ATA drain closed via reject_fee_payer_in_instruction_accounts! sweep + ATA-create slot-0 carve-out). Remaining P2 findings are pre-existing follow-ups documented in the original r5 review (harness adapter paths, server resource path parity, PAYMENT-RESPONSE header) — out of scope for this fix.
Add tracker-note references at the two Ruby exact-verifier spots where the
port intentionally diverges from the Rust/TypeScript spine:
1. Optional-instruction allowlist permits AssociatedTokenAccount::Create /
CreateIdempotent in slots 3-4 alongside Memo + Lighthouse so a buyer
can fund their own destination ATA in-band.
2. Fee-payer-in-instruction-accounts sweep with an ATA-create-payer-slot
carve-out (spine has no such sweep; the carve-out preserves the in-band
destination-ATA-create flow while keeping the DRAIN attack surface
covered).
Both divergences match the Go and Lua ports. Convergence with the Rust
spine is a protocol-wide decision tracked at
notes/lighthouse-allowlist-tracking.md.
Comment-only change. 208 tests still pass, standardrb clean.
P1-1 — Ruby x402 server replay ordering (L8): - Refactor settle_exact_payment to follow broadcast -> confirm -> put_if_absent on the confirmed signature, mirroring MPP `server/charge.rs:535-556` and the x402 SDK pull-mode contract recorded in skills/x402-sdk-implementation/references/pr-readiness.md. - Drop pre-broadcast `duplicate?` reserve and the release-on-failure path (claim-first creates a release race; the on-chain signature is the global uniqueness primitive). - Replay key is scheme-namespaced as `x402-svm-exact:consumed:<base58_signature>` so x402 schemes do not bleed into each other or into MPP's `solana-charge:consumed:<sig>`. - Add `await_confirmation` polling `getSignatureStatuses` until confirmed/finalized, with discriminated failure on explicit RPC `err` and a bounded timeout. - Add `signature_confirmer` injection on `Server::State` so tests can drive ordering/failure scenarios without standing up an RPC. - New tests cover: broadcast -> confirm -> put_if_absent ordering, canonical `signature_consumed` token on duplicate, no-record-on- broadcast-failure (retry allowed), and no-record-on-confirmation- failure. P1-2 — Harness adapter Cargo manifest path: - Post-`tests/interop` -> `harness` rename, the rust-x402 client/server adapter commands still pointed at `../../rust/Cargo.toml`, which no longer resolves from the harness CWD. Fix to `../rust/Cargo.toml`, matching the MPP rust adapter, so the rust<->ruby x402-exact matrix can spawn the rust spine. - The ruby x402 sh -c adapters were broken by the same rename (`cd ../../ruby` -> `cd ../ruby`).
The Ruby interop server returned only the fixture settlement header
on a successful 200 response. The Rust spine (rust/crates/x402/src/
bin/interop_server.rs L221-231) and the TS fixture (harness/src/
fixtures/typescript/exact-server.ts L322-331) both emit the canonical
x402 v2 PAYMENT-RESPONSE header alongside the fixture settlement
header. Without it, x402 v2 clients cannot consume the Ruby server
as protocol-ready.
Header value is raw (non-base64) JSON carrying the canonical
PaymentResponse fields: { success, network, transaction }. Mirrors
the Rust and TS serializations exactly. The fixture
x-fixture-settlement header is preserved so existing harness
assertions keep working.
Adds a regression assertion in the existing
test_protected_route_returns_settlement_success that the
PAYMENT-RESPONSE header is present and decodes to the canonical
three-field shape.
… env vars Hardcoded /protected and x-fixture-settlement prevented cross-server scenarios from driving the route and header name. State now reads X402_INTEROP_RESOURCE_PATH and X402_INTEROP_SETTLEMENT_HEADER with the prior defaults, response_for routes on state.resource_path, and the settlement response emits state.settlement_header. The interop client binary also reads X402_INTEROP_SETTLEMENT_HEADER when extracting the settlement value. Regression test asserts overrides flow through the challenge URI, route dispatch, and response header.
Aligns the Ruby x402 client+server adapter env vars with the rest of the x402 family (ts-x402, rust-x402) so that all x402-exact adapters opt in via X402_INTEROP_CLIENTS / X402_INTEROP_SERVERS. Resolves PR solana-foundation#127 r8 P3 finding. No CI workflow currently opts the ruby-x402-* adapters in via the old MPP_INTEROP_* namespace, so this is a no-op for green CI and only affects local runs that explicitly request the adapters.
PR solana-foundation#127 r8 P2 flagged that the cross-server-portability scenario was wired as `ts-x402 -> rust-x402` while the TS reference client emits a stub payload (`{ challengeId, resource }`) that does NOT deserialize into the Rust spine's typed `PaymentProof::{transaction|signature}` enum. Replaying that header to the Rust spine therefore produces `payment_invalid` (parse error) rather than the canonical `challenge_verification_failed` the scenario asserts. Narrow the pair list to `[ts-x402, ts-x402]` so the assertion exercises the full classifier path end-to-end, and document that the rust spine's own portability semantics are covered by the rust/crates/x402 integration tests. A follow-up can re-enable the cross-spine pair once the TS fixture emits a typed PaymentProof payload.
…tring resource) PR solana-foundation#127 r9 P1: the Ruby x402 client was rejecting offers emitted by the TS reference fixture because 1. The fixture serialises offers with `maxAmountRequired` rather than the canonical Rust-spine `amount` field. Rust accepts either via string_field fallback at rust/crates/x402/src/protocol/schemes/exact/types.rs:337-339; Ruby was only checking `requirement["amount"]`. 2. The fixture's `PAYMENT-REQUIRED` envelope carries a bare `resource` URL string while Rust models the same field as a typed ResourceInfo object. Ruby's resource_from_envelope only kept Hash forms and silently dropped the string form, so the client lost the route context needed for downstream binding. Update `selected_requirement?` to accept either amount field, and normalise the resource field so consumers always see a `{ "url" => <string> }` hash regardless of which spine issued the challenge. Add two interop regression tests pinning both behaviours against the TS fixture's wire shape.
Addresses maintainer feedback on PR solana-foundation#127: stop reimplementing primitives that already live in the Ruby gem and stop reinventing Ed25519 in pure Ruby. Both points were called out by lgalabru in the inline review on ruby/lib/x402/exact.rb constants + Ed25519 block. What moved to the shared core (Mpp::Methods::Solana::*): - Base58 alphabet, encode, decode: removed from x402, delegated to Mpp::Methods::Solana::Base58. - Program IDs (TOKEN, TOKEN_2022, SYSTEM, ATA, MEMO, COMPUTE_BUDGET) and the devnet USDC + PYUSD mint addresses: sourced from Mpp::Methods::Solana::Mints (single canonical table). - ATA derivation + PDA find_program_address + on-curve check: delegated to Mpp::Methods::Solana::AssociatedToken + PublicKey. - getLatestBlockhash RPC call: delegated to Mpp::Methods::Solana::Rpc (also gains an HTTPSuccess guard so non-2xx responses raise the canonical Mpp::Error with `getLatestBlockhash HTTP <code>`). - Solana short_vec / compact-u16 helpers: lifted to Mpp::Methods::Solana::Transaction as module functions, mirrors Rust spine rust/crates/x402/src/protocol/schemes/exact/types.rs. What got deleted outright: - ~170 lines of pure-Ruby Ed25519 curve math in x402/exact.rb (ED25519_P / _D / _I / _L / _BASE_X / _BASE_Y, scalar_mult, point_add, encode_point, decode_point, mod_sqrt, prune_scalar, public_key_from_seed, sign_ed25519, the pure-Ruby verify_ed25519). Replaced with calls into the `ed25519` runtime gem already pinned in solana-pay-kit.gemspec. Ed25519PrivateKey is now a 6-line adapter wrapping Ed25519::SigningKey. - The duplicate BASE58_ALPHABET, COMPUTE_BUDGET_PROGRAM, MEMO_PROGRAM, ASSOCIATED_TOKEN_PROGRAM, SYSTEM_PROGRAM, TOKEN_2022_PROGRAM constants that were redeclared in x402/exact.rb and x402/server.rb. What stays x402-local (justified): - LIGHTHOUSE_PROGRAM (x402-protocol-specific, not in MPP). - DEFAULT_COMPUTE_UNIT_LIMIT and price caps (x402 transaction shape). - verify_exact_instructions! and the structural x402 validators. - STABLECOIN_MINTS CAIP-2 view in x402/client.rb now projects from the shared Mints::MINTS table instead of redeclaring addresses. Net effect: ruby/lib/x402/exact.rb drops 776 -> 605 lines and zero constants or crypto math are duplicated between mpp and x402. Behavior + tests: - bundle exec rake test: 214 runs, 737 assertions, 0 failures, 0 errors. - bundle exec standardrb: clean. - One x402 client test (`test_latest_blockhash_rejects_http_failure`) now asserts the canonical Mpp::Error shape instead of the legacy RuntimeError; the net-http stub helper was widened to intercept the instance-level Net::HTTP#start path used by Mpp::Methods::Solana::Rpc. - The pre-existing with_rpc_http helper in support_test wraps canned Struct responses with code "200" + is_a?(Net::HTTPSuccess) so the new HTTP-status guard in Mpp::Methods::Solana::Rpc#call treats them as 2xx.
Mirrors the Rust spine umbrella layout (solana-pay-core / solana-mpp /
solana-x402 / solana-pay-kit). All shared Solana primitives + JCS RFC
8785 + RFC 7235 auth-param parser + RFC 3339 + base64url + canonical
L6 error codes live under PayCore::*. Both solana-mpp (Mpp::*) and
solana-x402 (X402::*) consume PayCore directly; no cross-layer
references.
+------------------------------------------------------------+
| solana-pay-kit |
+---------------------------+--------------------------------+
| solana-mpp | solana-x402 |
+---------------------------+--------------------------------+
| solana-pay-core |
+------------------------------------------------------------+
New files under ruby/lib/pay_core/:
- base64_url.rb PayCore::Base64Url
- json.rb PayCore::Json (RFC 8785 JCS)
- headers.rb PayCore::Headers (generic RFC 7235 auth-param parser)
- rfc3339_parser.rb PayCore::Rfc3339Parser
- error_codes.rb PayCore::ErrorCodes (canonical L6 codes + classifier)
- solana/base58.rb PayCore::Solana::Base58
- solana/programs.rb PayCore::Solana::Programs (NEW: SYSTEM / TOKEN / TOKEN_2022 /
ASSOCIATED_TOKEN / MEMO / COMPUTE_BUDGET / LIGHTHOUSE)
- solana/caip2.rb PayCore::Solana::Caip2 (NEW: MAINNET / DEVNET / TESTNET)
- solana/mints.rb PayCore::Solana::Mints
- solana/public_key.rb PayCore::Solana::PublicKey (PDA derivation + on-curve)
- solana/ata.rb PayCore::Solana::ATA (NEW name; was AssociatedToken)
- solana/account.rb PayCore::Solana::Account
- solana/transaction.rb PayCore::Solana::Transaction (wire codec + short_vec helpers)
- solana/rpc.rb PayCore::Solana::Rpc
Backward-compat alias layer (no public-API churn for MPP consumers):
- Mpp::Methods::Solana::Base58 = PayCore::Solana::Base58
- Mpp::Methods::Solana::Mints = PayCore::Solana::Mints
- Mpp::Methods::Solana::PublicKey = PayCore::Solana::PublicKey
- Mpp::Methods::Solana::Account = PayCore::Solana::Account
- Mpp::Methods::Solana::AssociatedToken = PayCore::Solana::ATA
- Mpp::Methods::Solana::Rpc < PayCore::Solana::Rpc (overrides
error class to raise Mpp::Error)
- Mpp::Methods::Solana::Transaction < PayCore::Solana::Transaction
(overrides sign_with error class to
raise Mpp::VerificationError)
- Mpp::Methods::Solana::{Message, Instruction, AddressLookup, Cursor}
= PayCore::Solana::*
- Mpp::Core::Base64Url = PayCore::Base64Url
- Mpp::Core::Json = PayCore::Json
- Mpp::Core::Rfc3339Parser = PayCore::Rfc3339Parser
- Mpp::Core::Headers delegates to PayCore::Headers for
generic auth-param parsing; keeps
MPP-specific parse_www_authenticate /
format_receipt / parse_receipt because
they construct Mpp::Core::Challenge /
Mpp::Core::Receipt
- Mpp::ErrorCodes = PayCore::ErrorCodes
solana-x402 (X402::Interop::*) now consumes PayCore directly; no
Mpp:: references remain in ruby/lib/x402/*.rb.
Umbrella ruby/lib/pay_kit.rb re-exports PayCore + Mpp + X402 under
PayKit::Core / PayKit::Mpp / PayKit::X402. Existing
`require "mpp"` and direct `require "x402/..."` paths still work.
Test results:
- bundle exec rake test: 229 runs, 770 assertions, 0 failures, 0 errors.
214 baseline MPP tests preserved unchanged via aliases;
15 new PayCore tests assert (a) PayCore::* are the canonical homes,
(b) Mpp::* aliases resolve via assert_same,
(c) Mpp::Methods::Solana::Rpc / Transaction subclass PayCore variants,
(d) X402::Interop::Server::DEFAULT_NETWORK reads from
PayCore::Solana::Caip2::DEVNET (no string literal duplicate).
- bundle exec standardrb: clean.
Test for `latest_blockhash_rejects_http_failure` updated to expect
PayCore::Solana::Rpc::RpcError (was previously expected to be
Mpp::Error, which only fires through the MPP charge path via the
subclass override).
Codex r1 P2 nit: the docstring on `PayCore::Solana::Transaction` said "higher layers may catch and re-raise without subclassing", but the in-tree extension point is exactly the private `signing_error_class` hook overridden by `Mpp::Methods::Solana::Transaction`. Update the comment to match the actual contract so reviewers do not expect a catch-and-rethrow shape that subclasses do not use.
Per maintainer feedback on solana-foundation#127: do not maintain backward-compat shims for the Mpp::Methods::Solana::* and Mpp::Core::* layers. Every shared primitive now lives only in PayCore; MPP source files reach into PayCore::Solana::* and PayCore::* directly. Deleted: - ruby/lib/mpp/methods/solana/{base58,mints,public_key,account, associated_token,rpc,transaction}.rb (alias and subclass shims) - ruby/lib/mpp/core/{base64_url,json,headers,rfc3339_parser}.rb (alias and delegating shims) - ruby/lib/mpp/error_codes.rb (alias shim) - ruby/test/pay_core_test.rb (obsolete alias-resolution suite) Kept under Mpp::: - Mpp::Headers (MPP-specific Payment header formatter/parser, wraps PayCore::Headers for generic auth-param parsing) - Mpp::Core::{Challenge,ChallengeEcho,Credential,Receipt} - Mpp::Methods::Solana::{Verifier,VerificationResult,ChargeMethod} - Mpp::{Error,VerificationError,Challenge,Settlement,Server,...} The Rpc and Transaction error subclasses (Mpp::Error, Mpp::VerificationError) are no longer raised by the wire layer; PayCore::Solana::Rpc::RpcError and PayCore::Solana::Transaction::SigningError surface directly and are caught at the MPP boundary in Mpp::Internal::Handler#handle.
…:Interop Per maintainer feedback on solana-foundation#127: - "Why do we have a ruby client? We should only support ruby server." - "What is the interop code doing here?" Drop the Ruby x402 client surface entirely. The cross-language harness exercises Ruby in the server role only; the client side is covered by the TS/Rust/Go/Python adapters. Move the remaining x402 interop fixture out of the production-looking `ruby/lib/x402/` mainline and into `ruby/lib/x402/interop/` so the fixture-only nature is obvious in the file path: - Delete `ruby/lib/x402/client.rb`, `ruby/bin/x402-interop-client`, `ruby/test/x402_interop_client_test.rb`. - Move `ruby/lib/x402/server.rb` -> `ruby/lib/x402/interop/server.rb`. - Move `ruby/lib/x402/exact.rb` -> `ruby/lib/x402/interop/exact.rb`. - Update `ruby/lib/x402.rb` to require only the interop modules and document that the production x402 server surface is out of scope for this PR. - Update `ruby/bin/x402-interop-server` and `ruby/test/x402_interop_server_test.rb` to the new require paths. Tests after this commit: 186 runs, 680 assertions, 0 failures.
Codex r2 P1/P2/P3 follow-ups: - harness/ruby-server/server.rb still imported the deleted Mpp::Methods::Solana::Account alias; switch to ::PayCore::Solana::Account. - harness/src/implementations.ts still registered the now-deleted ruby-x402-client adapter; remove the entry so X402_INTEROP_CLIENTS cannot reselect a dead binary. - lua/mpp/solana/rpc.lua header referenced the deleted Mpp::Methods::Solana::Rpc and Mpp::Error wrapping discipline; point at PayCore::Solana::Rpc and PayCore::Solana::Rpc::RpcError instead.
Codex r2 round 2 flagged the remaining client-side helpers in `X402::Interop::Exact` as a client surface inside the lib. They were not called by the interop server, the harness, or the test suite. Drop them so the only "client" code path remaining is the test fixture `build_exact_payment_signature`, which exists solely to construct a fake client-signed payload for server-verification tests. Removed: - `build_exact_payment_signature_from_rpc` (client RPC + sign helper) - `public_key_base58` (client pubkey emit) - `latest_blockhash` (client RPC wrapper)
Restructure ruby/lib/x402/ to mirror the Rust spine at rust/crates/x402/src/ instead of the previous interop-flavored single namespace. lib/x402.rb -> lib.rs lib/x402/constants.rb -> constants.rs lib/x402/error.rb -> error.rs lib/x402/protocol/schemes/exact/types.rb -> protocol/schemes/exact/types.rs lib/x402/protocol/schemes/exact/verify.rb -> protocol/schemes/exact/verify.rs lib/x402/server/exact.rb -> server/exact.rs bin/x402-interop-server -> bin/interop_server.rs X402::Server::Exact is the production server entry point; the former X402::Interop::Server::State becomes X402::Server::Exact::Config (State alias retained for back-compat). The 11-rule verifier moves into X402::Protocol::Schemes::Exact::Verifier with each rule citing the spine verify.rs line range. The interop bin is a thin TCP adapter; all harness env reads (X402_INTEROP_*) live in the bin, not in the library.
Replace the env-keyed Config constructor with typed kwargs (rpc_url:, pay_to:, facilitator_secret_key:, amount:, ...) so production callers can wire X402::Server::Exact::Config directly without going through X402_INTEROP_* env vars. The harness-specific env parsing moves into Config.from_interop_env, used only by bin/x402-interop-server.
Frozen Data.define value objects forming the PayKit v2 core:
- Price + Settlement: denomination plus ordered settlement-coin
preference. usd/eur/gbp helpers fall back to PayKit.config.stablecoins
when no coins are passed.
- Fee + FeeBuilder: { recipient => Price } hash form, two kinds
(within / on_top).
- Gate: amount + pay_to + fees + accept + description. Boot validations:
fee recipient must differ from pay_to, all denominations must match,
sum(fee_within) <= amount, x402 auto-disabled on any gate carrying
fees.
- DynamicGate: per-request block form using the same DSL setters.
PayKit.configure { |c| ... } block freezes the config after the
block returns. Holds pay_to, network, ordered accept (schemes) and
stablecoins lists, plus c.x402 and c.mpp subconfigs.
Network and scheme symbols validated on assignment. x402 scheme
currently restricted to :exact.
PayKit::Pricing is the base class merchants subclass to declare gates:
class Pricing < PayKit::Pricing
def build_gates
gate :report, amount: usd("0.10")
end
end
PayKit.pricing = Pricing.new freezes the registry. Gate.coerce funnels
symbol lookup, inline Price, and pre-built Gate through one path so
require_payment! :report and require_payment! usd("0.25") share code.
Challenge and Payment Data.define types live in challenge.rb; both
are built per request by the dispatcher, never cached.
Bundles merchant identity (recipient + signer + fee_payer) into a
single configurable object so the configure block stays terse:
c.operator do |op|
op.recipient = ENV["PAY_KIT_OPERATOR_RECIPIENT"]
op.signer = PayKit::Signer.env("PAY_KIT_OPERATOR_KEY")
end
Setters silently ignore nil so env-driven configuration composes
cleanly without 'if ENV[...]' guards (matches the DESIGN.md setter
convention). reset!(:field) is the escape hatch when a previously-set
value needs to be cleared.
Defaults:
signer = PayKit::Signer.demo
fee_payer = true
recipient = nil (resolves to signer.pubkey via effective_recipient)
Validation is loud (production-faithful): non-String recipients,
non-signer-shaped signers, and non-strict-boolean fee_payer values all
raise PayKit::ConfigurationError. Truthy coercion ("yes", 1, 0) is
explicitly rejected because flag-bug masking is exactly what
ConfigurationError is for.
Equality + hash are defined over the resolved tuple (effective
recipient, signer.pubkey, fee_payer flag) so the dispatcher can detect
config-equivalent operators.
This commit only adds the type; no other callers reference it yet.
PayKit::Config (next package) consumes the operator in its boot
block.
Tests: 25 new (operator_test.rb). Full suite: 321 runs, 961
assertions, 0 failures. Line coverage 98.49%, branch 90.73%.
… + facilitator_url + challenge_binding_secret)
Centralise everything on the new c.operator value (recipient + signer +
fee_payer). Split the historically conflated x402.facilitator (always a
Solana RPC URL) into c.rpc_url (chain endpoint) and
c.x402.facilitator_url (delegated mode). Rename c.mpp.secret to
c.mpp.challenge_binding_secret to track draft-httpauth-payment-00
vocabulary.
New surface:
- c.operator { |op| op.recipient = ...; op.signer = ...; op.fee_payer = ... }
- c.operator = PayKit::Operator.new(...) for direct assignment
- c.rpc_url (nil resolves to PUBLIC_RPC_URLS per network)
- c.x402.facilitator_url + c.x402.delegated? predicate
- c.x402.signer override (falls back to operator.signer)
- c.mpp.challenge_binding_secret
Safety:
- freeze! raises DemoSignerOnMainnetError when network=mainnet and
operator.signer.demo? (refuses to boot with a published keypair)
- freeze! warns when network=mainnet and rpc_url falls back to the
rate-limited public Solana RPC
Deprecation shims (warn-once per process, route to new surface):
- c.pay_to= -> c.operator.recipient
- c.x402.facilitator= -> c.rpc_url (was always a Solana RPC, never an x402 facilitator)
- c.x402.facilitator_secret_key= -> c.operator.signer via Signer.json
(empty string and "[]" no-op so existing examples that boot without a real signer keep working)
- c.mpp.secret= -> c.mpp.challenge_binding_secret
Rack middleware reads the new fields:
- x402 dispatcher uses c.x402.effective_signer + c.effective_rpc_url
- MPP dispatcher uses c.operator.effective_recipient + c.mpp.challenge_binding_secret
test_helper.rb migrated to the new API so the suite stays warning-free
except in the few tests that explicitly exercise the shims.
35 new config tests cover defaults, rpc_url per network, operator
block + assignment + non-Operator rejection, mainnet+demo refusal,
mainnet+public-RPC warning, delegated predicate, x402 signer override,
challenge_binding_secret rename, and warn-once for every shim.
…cache across requests
Two long-lived caches now live on the Rack middleware and survive
across every request that middleware handles:
- @x402_settlement_cache (X402::Server::Exact::SettlementCache).
Previously a fresh cache was allocated per-request inside
build_x402_config, which defeated the purpose: duplicate signature
detection only worked within a single request. Now one cache spans
all requests through this middleware instance.
- @mpp_method_cache (new MppMethodCache, mutex-guarded Hash). The
cache key is the full tuple that defines an on-chain charge intent:
[recipient, currency, network, rpc, secret, realm]. Two gates with
the same tuple share an Mpp::Server::Charge (and its ChallengeStore
HMAC state); gates with different gate.pay_to get their own server.
Adapter changes:
- ::PayKit::Protocols::MPP.new now accepts server_for: ->(gate) { ... }
in addition to the legacy fixed-server form (server: ...) which is
preserved so existing fake-server tests keep working.
- Dispatcher.mpp_adapter wires server_for to mpp_server_for(gate).
- Dispatcher.build_x402_config threads the shared settlement_cache
into the per-request X402::Server::Exact::Config.
Tests (test/pay_kit/dispatcher_test.rb, 4 new):
- SettlementCache shared across requests (put_if_absent returns false
on the second hit)
- Same recipient/currency/network gates share an MPP server
- Distinct gate.pay_to values get distinct MPP servers (recipient
differs on each method)
- MPP method cache survives across dispatcher instances on the same
middleware (two different dispatchers hit the same cached server)
…-aligned)
The MPP Solana verifier computes
primary = request.amount - sum(splits.amount)
and matches a transfer of `primary` to `request.recipient` (the
gate's pay_to) - see lib/mpp/protocol/solana/verifier.rb:75-87.
Including the primary recipient inside splits[] therefore
double-counts the principal and would fail verification with
"split amounts exceed total amount".
The previous splits_for built:
[{recipient: gate.pay_to, amount: primary}, ...fees...]
which was wrong by spec. New behaviour:
[...fees only...] when gate.fees?
nil when no fees
The 402 accepts entry still exposes the primary recipient via the
top-level payTo field, so clients see both the principal and the
fee-only splits.
4 new tests in test/pay_kit/mpp_adapter_test.rb pin:
- nil when no fees
- primary recipient never appears in splits[]
- fee order preserved + amounts converted to 6-decimal smallest units
- accepts_entry surfaces primary via payTo, splits carries only fees
PayKit.config.mpp.expires_in was previously a silently-ignored knob.
The new path:
PayKit::Config.mpp.expires_in
-> Dispatcher#mpp_server_for passes expires_in: to Mpp.create
-> Mpp.create forwards to Mpp::Server::Charge.new(expires_in:)
-> Charge.new constructs ChallengeStore with default_expires_seconds:
-> ChallengeStore#create_challenge uses Expires.seconds(default)
per call so the timestamp is "now + N", not store-construction-time.
ChallengeStore::DEFAULT_EXPIRES_SECONDS = 300 keeps existing call sites
on the same 5-minute default they had via Expires.minutes(5). The
previous create_challenge/create_challenge_header kwargs (`expires:`)
still work for callers that want to pin an exact RFC3339 timestamp.
Added Mpp::Expires.seconds(s, now:) helper so non-minute control
points have a clean expression (e.g. PayKit.config.mpp.expires_in
defaults to 300 seconds).
The MPP method cache key in PayKit's dispatcher now includes
expires_in so two configs that differ only on TTL get distinct
cached servers.
Tests:
- 5 new tests in test/mpp/expires_in_test.rb cover Expires.seconds,
ChallengeStore default + custom expiry, create_challenge using the
store default, and Mpp.create threading expires_in into the store.
- 1 new test in test/pay_kit/dispatcher_test.rb pins that
c.mpp.expires_in propagates into the cached MPP server's
ChallengeStore.
DESIGN.md rule: "pay_to: is optional and defaults to operator.recipient.
Most gates omit it; marketplace gates set it to route to a seller."
The Pricing DSL now resolves the default recipient from
PayKit.config.operator.effective_recipient (which itself falls back to
operator.signer.pubkey when no explicit recipient is set). The previous
default_pay_to: PayKit.config.pay_to path went through the deprecated
shim and emitted a warn on every gate construction.
Gate-level pay_to: overrides the operator default per gate (marketplace
seller flow). Inline coercion (require_payment! usd("0.10")) uses the
same fallback.
3 new tests in test/pay_kit/pricing_test.rb pin:
- default operator recipient flows into every gate built without
explicit pay_to
- zero-config boot uses the demo signer pubkey as the default recipient
- gate-level pay_to still overrides the operator default
DynamicGate#fees? unconditionally returned true. The intent was "play it safe with x402" (which can't settle multi-recipient fees), but the side effect was that EVERY dynamic gate had x402 silently disabled at the adapter level, even ones that resolve to zero fees on a given request. The actual contract is: a DynamicGate must be materialized into a static Gate before any fees-aware code path inspects it. The Sinatra helper already does this (resolve_gate at lib/pay_kit/sinatra.rb:71) and Dispatcher#materialize is the explicit hook for non-Sinatra callers. Removing the method exposes the contract: any code that calls #fees? on an un-materialized DynamicGate now NoMethodErrors loudly instead of silently misbehaving. Pinned by a refute_respond_to test so this can't quietly reappear.
Mpp::Server::Charge#charge already accepts an external_id: kwarg (used
to correlate a settled charge with a merchant order ID, invoice number,
etc.). PayKit's MPP adapter previously dropped this on the floor.
New surface:
- Gate.new / Gate.build / pricing DSL accept optional external_id:
- DynamicGate's block supports `external_id req.params["order"]`
- MPP adapter.perform passes gate.external_id into server.charge
A static gate sets external_id at boot; a dynamic gate computes it per
request from the Rack request. Both wire through to the MPP receipt
header so downstream audit can correlate settlements with merchant
records.
Drive-by: config_test's setup now calls PayKit.reset! defensively.
Previously it relied on teardown for cleanup, but random seed orderings
could schedule a test that ran configure { ... } block AFTER another
test's frozen @config slot was restored to the global, hitting
FrozenError on c.x402.facilitator_url=.
Tests in test/pay_kit/mpp_adapter_test.rb (3 new):
- gate.external_id defaults to nil
- MPP adapter forwards external_id into server.charge kwargs
- DynamicGate evaluates external_id from the per-request block
…he 402 body
The MPP server returns a Challenge with body["code"] set to a
canonical L6 wire code (e.g. challenge_expired, replay,
amount_mismatch). The previous adapter dropped that on the floor and
raised InvalidProof.new(:payment_required, reason) so clients only
saw the generic PayKit code and a human message.
Now:
- InvalidProof carries an optional spec_code: kwarg alongside its
PayKit-level code
- The MPP adapter reads body["code"] from the rejected Challenge and
passes it through
- The Rack middleware includes spec_code in the 402 JSON body when
present:
{"error": "payment_required", "message": "...", "spec_code": "challenge_expired"}
Clients can now branch on either layer without parsing the message.
Test pins the chain: fake server emits a body["code"], adapter
raises InvalidProof, err.spec_code == "challenge_expired".
Mpp::Protocol::Solana::ChargeMethod#method_details emits the feePayer/feePayerKey fields when fee_payer: is supplied at method construction. The dispatcher previously omitted fee_payer: entirely, so the operator.fee_payer? flag never reached the wire and the MPP verifier saw method_details without a feePayer claim regardless of config. New path: - PayKit::Signer::Local exposes its underlying PayCore::Solana::Account via #to_pay_core_account (PayKit-internal use only; ordinary code still consumes the duck-typed signer interface) - Dispatcher#mpp_server_for reads operator.fee_payer? and, when true, passes operator.signer.to_pay_core_account to Mpp::Protocol::Solana.charge(fee_payer:) - The MPP method cache key now includes the fee-payer pubkey so two operators that differ only on fee_payer get distinct cached servers Spec alignment: when operator.fee_payer? is false the feePayer and feePayerKey fields stay absent from method_details (the canonical "feePayerKey absent" rule). When true they appear with the operator's signer pubkey. 2 new tests in dispatcher_test.rb: - fee_payer: true -> method.fee_payer_pubkey == operator.signer.pubkey - fee_payer: false -> method.fee_payer is nil, fee_payer_pubkey is nil
c.x402.facilitator_url already configures the delegated x402 mode (P3: PayKit POSTs verify/settle to the facilitator instead of touching the chain locally). The HTTP client that drives those calls is a follow-up; until it lands the dispatcher must not silently fall back to self-hosted behaviour or quietly hand back a useless adapter. x402_adapter now raises PayKit::NotImplementedError with a message pointing at the three resolution paths: - unset c.x402.facilitator_url to run x402 self-hosted (today) - drop :x402 from c.accept to use MPP only - wait for the delegated client follow-up Self-hosted x402 (facilitator_url unset) keeps working unchanged. 2 new tests in dispatcher_test.rb pin both branches: delegated raises with the expected message; self-hosted resolves the adapter cleanly.
DESIGN.md: "The gem auto-detects Sinatra and registers the helper module when sinatra/base is loaded, so a single require 'solana_pay_kit' is enough." Previously the helper module and Rack middleware had to be wired by hand (helpers PayKit::Sinatra + use PayKit::Rack::PaymentRequired). solana_pay_kit.rb now: - Calls PayKit::SinatraAutoRegister.try_register! immediately, which fires when Sinatra::Base is already defined (require "sinatra/base"; require "solana_pay_kit"). - Falls back to a TracePoint on the :end event for Sinatra::Base when Sinatra hasn't been loaded yet (require "solana_pay_kit" before require "sinatra/base"). The :end event is critical - :class fires before the class body has run, so `helpers` is still undefined; :end fires after the body, when the Sinatra::Base class-level DSL methods are available. The TracePoint disables itself after firing so there is no ongoing tracing overhead. - Registration is idempotent (@registered flag), so apps that explicitly write helpers/use lines still work without double- registering. Tests: - test/load_order/sinatra_first_test.rb - Sinatra loaded first - test/load_order/sinatra_second_test.rb - solana_pay_kit loaded first - test/pay_kit/load_order_test.rb spawns both subprocesses via Open3 so the require state stays clean. test/run.rb excludes test/load_order/ from the in-process glob.
…udo r3306892787)
examples/sinatra/pay_kit.rb migrates to the post-DESIGN.md surface:
- c.operator { |op| op.recipient = ENV[...]; op.signer = Signer.env(...) }
replaces c.pay_to= and c.x402.facilitator_secret_key=
- c.rpc_url replaces c.x402.facilitator (which was always a Solana RPC,
never an x402 facilitator)
- c.mpp.challenge_binding_secret replaces c.mpp.secret
- Drops the no-longer-relevant PAY_KIT_X402_FACILITATOR_KEY=[] sentinel
(the new operator default is the demo signer)
examples/sinatra/app.rb drops the explicit `helpers PayKit::Sinatra` and
`use PayKit::Rack::PaymentRequired` calls: a single
`require "solana_pay_kit"` now wires both via Sinatra auto-detect.
README first snippet rewritten to the form Ludo asked for in his
r3306892787 review: one configure line + one route in `get("/report")
{ require_payment!(usd("0.10")); "ok" }` shape. The expanded "Quick
start" copy points at zero-config boot, the demo signer, mainnet
refusal, and the auto-detect path. The longer Pricing class walkthrough
stays further down the README.
…llenge_binding_secret + push-mode fee-payer ban
The interop harness used the pre-DESIGN.md surface: c.pay_to=,
c.x402.facilitator= (which was always a Solana RPC URL), and
c.mpp.secret=. All four routed through the deprecation shims and
emitted warnings into the harness stdout, which the interop matrix
parsed and treated as noise.
New configuration:
- c.operator { |op| op.recipient = pay_to; op.signer = Signer.json(...) }
replaces c.pay_to= and c.x402.facilitator_secret_key=
- c.rpc_url replaces c.x402.facilitator (which never was an x402
facilitator)
- c.mpp.challenge_binding_secret replaces c.mpp.secret
MPP-mode push-mode fee-payer ban (R3): in the MPP harness flow the
server holds no Ed25519 keypair (verification uses the HMAC challenge-
binding secret; on-chain settlement is read-only). The client pays
its own SOL fee. operator.fee_payer = false now flips this on, so
MPP method_details emits no feePayer/feePayerKey claim - the verifier
correctly treats it as a push-mode charge.
Two README sections drifted from the post-DESIGN.md surface: 1. Rack-first - explained the `use PayKit::Rack::PaymentRequired` line as if every app needed it. The Sinatra auto-detect now wires it for you; the explicit form is only needed for raw Rack or non-Sinatra frameworks. The middleware blurb now also names the long-lived caches (x402 SettlementCache + MPP method cache) that survive across requests. 2. Example "Run it" - referenced the deleted PAY_KIT_X402_FACILITATOR_KEY env and the old "mpp-only default" guidance. Replaced with the zero-config boot + env overrides (PAY_KIT_PAY_TO, PAY_KIT_OPERATOR_KEY, PAY_KIT_RPC_URL) the updated example actually supports.
…test) Branch coverage was 89.83% (583/649) after the DESIGN.md refactor, below the 90% gate. The 4 missing branches were all on the deprecation shim accessor paths in Config: - c.x402.facilitator_secret_key= with nil (no-op short-circuit before empty-string branch) - c.x402.facilitator_secret_key (reader) - c.x402.facilitator (reader) - c.mpp.secret (reader) Adds focused tests for each so the shim accessors keep round-tripping through the new operator + rpc_url + challenge_binding_secret surface correctly. Branch coverage is now 90.14% (585/649).
|
Pushed 17 atomic commits aligning the gem with the updated DESIGN.md surface. Config: Signer/Operator/Kms: Dispatcher: x402 Gate: Sinatra: auto-detect at gem boot in both load orders (TracePoint Harness: Tests: 381 runs / 1068 assertions / 0 failures, 98.33% line + 90.14% branch coverage, lint clean. Load-order tests run in fresh subprocesses to verify both One callout: DESIGN.md cites the demo pubkey as |
…so interop env-set knobs land on the wire The harness has been failing the PayKit interop CI since it landed at b2a25fd: the orchestrator passes MPP_INTEROP_SETTLEMENT_HEADER, MPP_INTEROP_SPLITS (with per-split recipient/amount/ataCreationRequired/memo), MPP_INTEROP_DECIMALS, MPP_INTEROP_ASSET_KIND (sol vs spl), MPP_INTEROP_REPLAY_SOURCE_{AMOUNT,PATH}, but the adapter routed the MPP path through PayKit::Pricing + Gate + Dispatcher, which doesn't model any of those (Gate has no per-fee ataCreationRequired/memo, PayKit::Config has no settlement_header knob, the dispatcher's gate.fees? gate is too coarse). MPP path now bypasses PayKit's gate DSL and drives Mpp::Server::Charge directly: - settlement_header threads through Mpp.create - splits forward as the splits: kwarg on each server.charge call, so per-split ataCreationRequired and memo land on method_details - decimals come from MPP_INTEROP_DECIMALS so literal mint pubkeys (which aren't in PayCore::Solana::Mints) don't crash decimals_for - asset_kind=sol routes currency="SOL" so the verifier's SOL-native branch fires - replay-source scenarios bind a second logical resource to the same server instance, so the per-instance replay store gives the idempotent-resubmit + cross-server-portability contracts for free x402 path keeps using PayKit (Pricing + inline gate + dispatcher); the x402 wire format doesn't have the per-scenario knobs MPP does. InvalidProof / Mpp::Error rescues now also surface the canonical code so the G39 fault matrix can lock in cross-SDK code agreement.
… + drop empty x402 interop step Two related changes: 1. Loosen Types.accepted_requirement_matches? to TS-reference semantics. The previous left == right strict equality rejected credentials whose accepted object omits amount/maxTimeoutSeconds (the v2 wire shape used by ts-x402 and rust-x402 clients) or carries unknown extra fields. Now matches on the identity tuple (scheme/network/asset/payTo + canonical extra keys feePayer/tokenProgram/memo) and ignores informational fields, mirroring harness/src/fixtures/typescript/exact-server.ts:141-143 and the spine accept logic in rust/crates/x402/src/protocol/schemes/exact/types.rs:337-339. 2. Drop the placeholder x402 CI step. The previous workflow shelled out to harness/test/x402-exact.e2e.test.ts which is gated behind X402_INTEROP_MATRIX=1 and has no surfpool bootstrap of its own; without those, it reported "1 test | 1 skipped" - a green-but-empty signal that misled review. Cross-language x402 interop against this adapter still has multiple field-name asymmetries to resolve (amount vs maxAmountRequired in offer JSON, ts-x402's wire-only payload that lacks payload.transaction); those land in a follow-up. The dual- protocol adapter still BOOTS in x402 mode (proven by test/pay_kit/harness_adapter_test.rb). Two existing tests that pinned the old strict-equality semantics (test_settlement_rejects_accepted_extra_drift, test_settlement_rejects_accepted_max_timeout_drift) are renamed to test_settlement_tolerates_* and now assert "matching did not block", catching only the specific "No matching" rejection.
|
x402 interop step was reporting "1 test | 1 skipped" because it shelled out to the standalone
Local MPP interop still green (8 scenarios). Pushed 70ad820. |
Three issues blocked rust-x402 client from parsing this server's
challenge:
1. Body missing both spellings of the offer amount. ts-x402 client
reads `offer.maxAmountRequired`, the Rust spine parser accepts
`amount` OR `maxAmountRequired`. Now emit both: `amount` stays
the canonical wire (matches spine `to_accepted_value`) and
`maxAmountRequired` is added so TS-style clients deserialize the
offer correctly.
2. PaymentRequired envelope's `resource` field was emitted as
`{type, uri}` but Rust deserialises it as `ResourceInfo {url,
description?, mimeType?}`. Now emit both `url` and `uri` keys so
either client parser accepts the envelope shape.
3. PayKit's x402 dispatcher passed `gate.total.amount` ("0.001")
straight into X402::Server::Exact::Config. The v2 wire amount is
smallest-units u64 (Rust spine parses `requirement.amount` as
`u64`; decimal forms trip `Invalid amount: 0.001`). Added
`to_smallest_units_string(price)` to the dispatcher that converts
"0.001" -> "1000" using the canonical 6-decimal default.
All three are server-side fixes; client code unaffected.
Verified locally: rust-x402 client -> ruby-pay-kit-server (x402-exact-basic)
now passes after these changes. MPP interop unchanged (8 charge
scenarios still green).
…l-protocol adapter The PayKit interop CI step previously shelled out to a placeholder x402 test that reported "1 test | 1 skipped". The actual cross- language x402 matrix is `test/e2e.test.ts`, which already boots surfpool + funds keypairs but used to hard-skip every `x402-exact` scenario with the comment "MPP runner builds MPP_INTEROP_* env which x402 adapters do not consume." Lifting that restriction so the same surfpool fixtures back both protocols: - environmentForScenario emits X402_INTEROP_* shadows alongside MPP_INTEROP_* for x402-exact scenarios (same payTo / client secret / facilitator secret / RPC URL), plus a PAY_KIT_INTEROP_PROTOCOL= x402|mpp hint that auto-detect adapters can read instead of probing env namespaces (ambiguous when both are populated). - The hard-skip on `scenario.intent === "x402-exact"` is removed. The existing pair filter already gates on `impl.intents.includes(scenario.intent)`, so charge-only adapters skip x402 scenarios automatically. The intent default for impls without an explicit `intents` field is now `["charge"]` (was "all intents") so legacy charge-only adapters stay charge-only. - runClient injects X402_INTEROP_TARGET_URL alongside MPP_INTEROP_TARGET_URL so x402 clients find their expected env var. - ruby-pay-kit-server reads PAY_KIT_INTEROP_PROTOCOL first; falls back to namespace probing when unset (existing single-protocol callers unchanged). CI workflow merges the two previously-separate steps into one, and restricts X402_INTEROP_CLIENTS to rust-x402 (the wire-only ts-x402 client cannot pair against a real settle server because its payload omits the on-chain transaction). Verified locally: 9 scenarios pass (8 mpp charge + 1 x402-exact rust-x402 -> ruby-pay-kit-server).
…ignature_consumed Solana RPC rejects a duplicate signature with "This transaction has already been processed" before the per-instance replay store fires (MPP server consumes the signature AFTER broadcast in pull mode). Without this regex, idempotent-resubmit credentials surfaced as generic payment_invalid instead of the canonical signature_consumed code the G39 cross-SDK matrix pins. Pinned by harness/test/e2e.test.ts charge-idempotent-resubmit scenario against ruby-pay-kit-server, which now emits the expected canonical code on resubmit.
…arios The dual-protocol adapter already had the SDK plumbing for every scenario class the matrix exercises - the gate was the per-scenario `serverIds` whitelist that opted SDKs into each test only after their canonical-code wiring and protocol-mode coverage were proven. Verified locally and adding ruby-pay-kit-server to: - charge-push: pull-default operator with fee_payer:false routes the credential through Mpp::Server::Charge's push path - charge-network-mismatch: MPP server emits wrong_network when the surfpool blockhash claims a network that differs from the server's configured network - charge-cross-route-replay: challenge_store verify_expected fires charge_request_mismatch when the credential's pinned amount / recipient does not match the served route's expected charge - charge-idempotent-resubmit: signature_consumed now lands via the new "already been processed" classifier - charge-decimals-9: harness threads MPP_INTEROP_DECIMALS straight into Mpp::Protocol::Solana.charge, so the wire amount stays env-driven instead of being recomputed by the SDK - charge-sol-native: harness maps MPP_INTEROP_ASSET_KIND=sol to currency="SOL" so the MPP verifier's SOL-native branch fires - charge-cross-server-portability: two ruby-pay-kit-server instances with distinct MPP secrets correctly reject portability attempts with challenge_verification_failed ruby-pay-kit-server scenario coverage: 9 -> 17 (now exceeds rust's 12 and matches typescript's 14, plus 3 cross-server / x402 pairs).
| expectedStatus: 200, | ||
| clientIds: ["typescript"], | ||
| serverIds: ["typescript", "rust", "php", "ruby"], | ||
| serverIds: ["typescript", "rust", "php", "ruby", "ruby-pay-kit-server"], |
There was a problem hiding this comment.
Should ruby-pay-kit-server just become ruby?
… ruby adapter (Ludo r3311413501)
The dual-protocol PayKit adapter is a strict superset of the previous
MPP-only ruby-server, so there is no reason to carry two distinct
harness impls. Drop the old harness/ruby-server/server.rb (189 lines,
Mpp-only) and rename harness/pay-kit-server/server.rb to
harness/ruby-server/server.rb. The impl id collapses from
{ruby, ruby-pay-kit-server} to a single `ruby` that supports both
intents.
- harness/src/implementations.ts: drop the separate ruby-pay-kit-server
entry, give the canonical `ruby` impl the dual-protocol label and
intents: ["charge", "x402-exact"].
- harness/src/intents/charge.ts: every scenario serverIds list that
previously named ruby-pay-kit-server now names ruby (push,
network-mismatch, cross-route-replay, decimals-9, sol-native,
cross-server-portability, idempotent-resubmit).
- harness/test/e2e.test.ts: comment update.
- .github/workflows/ruby.yml: job renamed to interop-ruby, drops
PAY_KIT_INTEROP_SERVERS env (MPP_INTEROP_SERVERS=ruby,typescript
enables the cross-server-portability pair), test name pattern
switched to "ruby".
- harness/ruby-server/server.rb: ready payload `implementation` field
is now "ruby" and the error-prefix strings stop saying
"pay-kit-server".
- ruby/test/pay_kit/harness_adapter_test.rb: ADAPTER path and ready
payload assertion follow the rename.
Local matrix: 17 scenarios still green (8 charge + 1 x402-exact + 2
cross-server + 1 idempotent + the 5 added in 340fb3b).
…s/ruby-server)
The earlier harness/lua-server/server.lua was MPP-only. With the
resty.pay_kit umbrella + the 11-rule x402 verifier port now in
place, the Lua side gets the same dual-protocol harness adapter
shape Ruby ships: one binary, two settle paths, picked per scenario
by which env namespace the orchestrator sets (or the explicit
PAY_KIT_INTEROP_PROTOCOL hint that the matrix uses when both
X402_INTEROP_* and MPP_INTEROP_* are populated against the same
surfpool fixtures).
- harness/lua-server/server.lua New dual-protocol adapter.
Reads either MPP_INTEROP_*
or X402_INTEROP_*; configures
resty.pay_kit with the right
operator/signer/secret;
registers a single gate; runs
a raw socket loop that routes
GET /<resource> through
pay_kit.try_payment. Honours
*_INTEROP_SETTLEMENT_HEADER
so per-scenario custom header
names land on the wire.
- harness/lua-server/server.legacy.lua The previous MPP-only adapter
(370 LOC), preserved as a
reference. Drop after the
matrix's lua x402 step is
green.
- harness/src/implementations.ts `lua` impl now declares
intents: ["charge",
"x402-exact"], so the matrix
will pair it against x402
scenarios alongside MPP ones.
Label updated to "Lua PayKit
server (dual protocol)".
Local: both modes boot cleanly (verified via PAY_KIT_INTEROP_PROTOCOL
hint smoke tests); 449 tests pass / 0 fail in the Lua suite; luacheck
clean; harness pnpm typecheck clean.
Matrix wiring: with this adapter, MPP_INTEROP_SERVERS=lua +
MPP_INTEROP_INTENTS=charge,x402-exact in the cross-language matrix
will exercise Lua against both protocols end-to-end (mirrors the
Ruby pay-kit-server flow that landed via PR solana-foundation#138). The remaining
piece - adding ruby-style serverIds entries that include `lua` for
the negative-path scenarios (network-mismatch, cross-route-replay,
idempotent-resubmit) - is a focused follow-up once a lua matrix
scenario lands green.
…oadcast
Phase 5 complete. Ports the x402 SVM-exact structural verifier
from lua/pay_kit/protocols/x402/exact/verify.lua (itself a port of
the Ruby gem at ruby/lib/x402/protocol/schemes/exact/verify.rb and
the Rust spine at rust/crates/x402/src/protocol/schemes/exact/verify.rs).
Raises {@see InvalidProofException} with the same canonical reject
strings the cross-language harness substring-matches against:
1. Instruction count 3..=6
2. ix[0] = ComputeBudget SetComputeUnitLimit
3. ix[1] = ComputeBudget SetComputeUnitPrice <= MAX (50000)
4. ix[2] = SPL TransferChecked
5. Authority guard (no fee-payer in transfer auth)
6. Mint match
7. Destination ATA match (re-derived via Mints::deriveAta)
8. Amount match (u64 LE at data offset 1)
9. ix[3..6] in allowlist (memo + lighthouse + optional ATA-create)
10. Memo binding (exactly one if extra.memo set)
11. Token program strict bind to extra.tokenProgram
Schemes\\X402\\Adapter::verifyAndSettle now:
- decodes the PAYMENT-SIGNATURE base64+JSON envelope
- runs the identity-key match (scheme/network/asset/payTo +
feePayer/tokenProgram/memo extras, matching Ruby PR solana-foundation#138's
accepted_requirement_matches)
- runs the 11-rule Verifier
- cosigns the transaction with the operator's signer
- broadcasts via solana-php RpcClient::sendTransaction
- reserves the signature in the replay store (signature_consumed
on duplicate submit)
- returns a Payment with the on-chain signature in
transaction + settlementHeaders (PAYMENT-RESPONSE +
x-payment-settlement-signature)
Delegated x402 mode (X402Config::$facilitatorUrl set) raises
InvalidProofException at adapter construction; the dispatcher
won't bind the adapter in that mode. Self-hosted is the only x402
path that ships in v1, matching Lua PR solana-foundation#141.
Supersedes #127. Single comprehensive Ruby PR covering the v2 surface design: structural cleanup (x402 module layout, mpp module rename), shared protocol primitives, and the high-level
PayKitunified gate API that lets one Ruby app gate routes with bothx402:exactandmpp:chargebehind a single declaration.#127 will be closed in favor of this PR. Its branch (
pr/ruby-x402-port) is preserved for cherry-pick reference.What's in scope
Three layered changes, in dependency order:
pay_coreextraction (mirrors Rust workspacesolana-pay-core). MPP and x402 now both consumePayCore::Solana::*,PayCore::Json(JCS RFC 8785),PayCore::Headers,PayCore::ErrorCodesrather than duplicating Solana wire primitives.ruby/lib/x402/mirrorsrust/crates/x402/src/:constants.rb,error.rb,protocol/schemes/exact/{types,verify}.rb,server/exact.rb.X402::Interopnamespace is gone fromlib/; interop only lives at the bin boundary (ruby/bin/x402-interop-server) and harness adapters.ruby/lib/mpp/mirrorsrust/crates/mpp/src/:protocol/core/{challenge,credential,receipt,headers,challenge_store}.rb,protocol/intents/charge.rb,protocol/solana.rb+protocol/solana/{verifier,verification_result}.rb,server/charge.rb(merges the publicMpp::Server::Chargewith the nestedMpp::Server::Charge::Handlerorchestrator). Top-level public types (Mpp::Challenge,Mpp::Settlement,Mpp::Error,Mpp::Sinatra,Mpp::Store,Mpp::Expires,Mpp::VERSION) stay where Ruby callers expect them. Module renames are hard (no backward-compat shims):Mpp::Methods::SolanaMpp::Protocol::SolanaMpp::HeadersMpp::Protocol::Core::HeadersMpp::Intent::ChargeRequestMpp::Protocol::Intents::ChargeRequestMpp::Core::ChallengeMpp::Protocol::Core::ChallengeMpp::Core::CredentialMpp::Protocol::Core::CredentialMpp::Core::ReceiptMpp::Protocol::Core::ReceiptMpp::Core::ChallengeStoreMpp::Protocol::Core::ChallengeStoreMpp::Core::HandlerMpp::Server::Charge::HandlerMpp::Server::InstanceMpp::Server::ChargeMpp.createis unchanged. The harness ruby-server adapter and the existing Sinatra example were updated to the new names.PayKitv2 unified gate API (the headline feature). Single Ruby-idiomatic surface gating routes with either or both protocols. Sinatra-first with Rack underneath, Rails shim follows the same names.Ruby remains server-only on both protocol layers. No client code added.
PayKit v2 surface
Naming
solana-pay-kitrequire "solana_pay_kit"PayKitrequire "solana_pay_kit/sinatra"(opt-in)Same pattern as
activerecord/ActiveRecordandrack-test/Rack::Test: gem name carries discoverability, module name carries ergonomics. The gem does NOT auto-detect Sinatra at require time; the helpers require an explicit opt-in to avoid load-order surprises.Vocabulary
fee_on_top.amount + sum(fee_on_top). Derived.usd(...): number + denom + settlement.pay_torecipient nets less.pay_tonets full.:x402or:mpp(top-level dispatch).:exact. MPP sub-form::charge.:USD,:EUR).:USDC,:USDT).payment.protocolis the protocol dispatcher;payment.schemeis the sub-form. This split keeps the x402 spec's vocabulary intact (in x402 a "scheme" isexact/upto/batch) while giving us a clean protocol-level discriminator.Boot-time configuration
Frozen at the end of the block. Network and scheme symbols validated on assignment.
Central gates registry
Symbol shorthand expands against config defaults (
:x402,:mppfor schemes;:USDCfor stablecoins). Object form carries overrides per gate.Controller surface (Clearance trio)
Three primitives, mirroring Clearance's
require_login/signed_in?/current_user. The same trio is consumed in Rails throughinclude PayKit::Controller.Design rules (locked)
accept:and stablecoin lists are preference order. The 402accepts[]body advertises in this order.Gate,Price,Settlement,Fee,Challenge,PaymentareData.definetypes frozen in their factories. Registry frozen at assignment.usd("0.10", :USDC, :USDT)means "$0.10 USD, settle in USDC or USDT". Merchants think fiat.PayKit::Rack::PaymentRequired. No framework is special.require_payment!/paid?/payment.PayKit::PaymentRequiredandPayKit::InvalidProofcan berescue_from'd.PayCore::Solana::Mintswith mainnet/devnet/localnet defaults.amountplusfee_within/fee_on_tophash kwargs. Fixed amounts only. x402 auto-disabled on any gate carrying fees because stock x402 facilitators settle to one address; the resolver strips:x402fromacceptsilently, and explicitly settingaccept: :x402on a fee-bearing gate raises at boot.Boot validations (all
PayKit::ConfigurationError)namemust be aSymbol.amountmust be aPrice(built viausd/eur/gbp).pay_tomust be a non-empty string (gate or config default).pay_to. Fold the fee into the amount instead.fee_within+fee_on_top.sum(fee_within values) <= amount.acceptmust resolve to a non-empty list after auto-disable rules apply.accept: :x402on a fee-bearing gate raises (defense in depth above the silent strip).Fee math
gate.totalreturns the customer-facing total.gate.payout(to:)returns what a recipient nets:pay_torecipient:amount - sum(fee_within).0silently).Layers
Middleware is small: installs a dispatcher on the env, rescues
PaymentRequiredinto 402, merges settlement headers from a verifiedPaymentinto the success response. Gate selection and verification live in the helper, not the middleware.Manual DX proof
ruby/examples/pay-kit-sinatra/ships a Sinatra app exercising every surface. Each route verified withcurl:Default config is mpp-only so the example boots without a real x402 facilitator keypair. Set
PAY_KIT_X402_FACILITATOR_KEY+PAY_KIT_ACCEPT="x402,mpp"to enable x402.Tests
92 / 90gate passesstandardrbcleanlib/pay_kit/rack/andlib/pay_kit/schemes/(live-RPC integration layers exercised via the example and the cross-language interop harness), mirroring the existinglib/x402/server/exclusionTest coverage by surface:
Price+ helperswith_amountFee+ BuilderGatevalidatorsGatefee mathtotal,payout(to:), unknown recipient raisesDynamicGatePricingDSLcoerce, duplicate gateConfigChallenge/PaymentNon-goals (per the design doc)
Out of scope for v1, deferred:
rails generate solana_pay_kit:install) implementation. The shape is documented; the generator code itself lands in a follow-upFollow-up planned after this lands
protocol/intents/session.rs,client/session*.rs,client/multi_delegate.rsand friends). Out of scope here; Ruby is server-only.PayKit::Controllercontroller-class macro + generator scaffolding.Why this PR supersedes #127
#127 carried Phase 1 (x402 module spine layout) and the v2 surface depends structurally on Phase 1. Bundling them here gives reviewers the full picture in one place: the x402 cleanup motivates the layered architecture that lets
PayKit::Schemes::X402cleanly wrapX402::Server::Exactwithout re-importing internals. Closing #127 with a pointer; the branch is preserved.