feat(ruby): pay-kit v2 unified gate API + module restructure#138
feat(ruby): pay-kit v2 unified gate API + module restructure#138EfeDurmaz16 wants to merge 53 commits into
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.
Mpp::Challenge exposes #headers; the adapter was calling #to_headers which raises NoMethodError on the 402 path.
Single Sinatra app showing every PayKit v2 surface in one place:
- Registry lookup (require_payment! :report)
- Opportunistic gating (paid?(:report))
- Inline form (require_payment! usd("0.25"))
- Dynamic per-request pricing (gate :tiered do |req| ... end)
- Multi-recipient via fee_within (mpp-only, x402 auto-disabled)
- before-filter for /admin/* gating
Manually verified each route returns 402 with:
- WWW-Authenticate: Payment ... header (MPP)
- JSON body { error, resource, accepts: [...] }
Splits math verified on /marketplace/sale ($10.00 with $0.30 fee_within):
splits = [{seller: 9700000}, {platform: 300000}].
Defaults to mpp-only so the example boots without a real x402
facilitator keypair. Set PAY_KIT_X402_FACILITATOR_KEY + PAY_KIT_ACCEPT
to enable x402.
- Config: keep `network` as attr_reader only so the custom `network=` validator is the only definition (lint: Lint/DuplicateMethods). - Price: drop unused `resolve_defaults` (dead code; the DSL resolves defaults via `Helpers::Pricing.build_price` at the call site) and simplify `primary_coin` since settlements is guaranteed non-empty. - Rack: standardrb formatting (hash literal whitespace, case-when layout).
Adds `rack-test` for middleware integration tests. Coverage filters `lib/pay_kit/rack/` and `lib/pay_kit/schemes/` because those layers wrap live Solana RPC and signing through `X402::Server::Exact` and `Mpp::Server`; they are exercised through the Sinatra example (manual curl DX) and the cross-language interop harness rather than unit tests, mirroring the existing `lib/x402/server/` exclusion.
Adds 73 new tests covering: - Price + Settlement value object validation, helper fallback to config.stablecoins, BigDecimal precision, with_amount. - Fee value object + FeeBuilder shape validation. - Gate boot validations: fee recipient vs pay_to, denom mixing, sum(fee_within) <= amount, x402 auto-disable, explicit x402 with fees raises, duplicate fee recipient, missing pay_to. - Gate fee math: total, payout(to:), unknown recipient raises. - DynamicGate per-request resolution. - Pricing registry DSL: known/unknown gate, coerce paths (symbol/inline/garbage), duplicate gate, frozen, each/include?. - Config validation: invalid network/scheme/empty, freezing. - Middleware end-to-end via Rack::Test: 402 with accepts[] body and both protocol headers; paid 200 with merged settlement headers; paid? predicate true/false; inline form; dynamic gate via Sinatra helper. - Errors: PaymentRequired carries challenge, InvalidProof carries code+detail, UnknownGate message. Full suite: 259 runs, 811 assertions, 0 failures. Coverage: line 98.4%, branch 90.39% (passes existing 92/90 gate).
Per Ludo's review on PR solana-foundation#127 (discussion_r3306292020): the challenge_store + handler files belong alongside the protocol primitives that already live under mpp/core/ (challenge.rb, credential.rb, receipt.rb). Net effect: - ruby/lib/mpp/internal/challenge_store.rb -> mpp/core/challenge_store.rb - ruby/lib/mpp/internal/handler.rb -> mpp/core/handler.rb - module Internal -> module Core - All call sites updated (lib/mpp.rb, lib/mpp/server.rb, 2 test files). The 402-response Challenge value object stays at lib/mpp/challenge.rb (public surface); the protocol-level Core::Challenge stays at mpp/core/challenge.rb. The unqualified Challenge.new inside ChallengeStore is now ::Mpp::Challenge.new to avoid the sibling-class shadow now that the file lives inside module Core. 259 tests, 0 failures.
Reshapes ruby/lib/mpp/ to match rust/crates/mpp/src/, addressing Ludo's "similar for the mpp module" review on PR solana-foundation#127: ruby/lib/mpp/ error.rb, expires.rb, store.rb, version.rb stays (top-level utility) challenge.rb, settlement.rb stays (public 402/200 wrappers) sinatra.rb stays (framework shim) protocol/ core/ challenge.rb was mpp/core/challenge.rb credential.rb was mpp/core/credential.rb receipt.rb was mpp/core/receipt.rb headers.rb was mpp/headers.rb (top-level) challenge_store.rb was mpp/core/challenge_store.rb (was mpp/internal/) intents/ charge.rb was mpp/intent/charge_request.rb solana.rb was mpp/methods/solana.rb solana/ verifier.rb was mpp/methods/solana/verifier.rb verification_result.rb was mpp/methods/solana/verification_result.rb server/ charge.rb merged mpp/server.rb (Server::Instance) and mpp/core/handler.rb. Exposes Mpp::Server::Charge (was Server::Instance) with nested Mpp::Server::Charge::Handler (was Mpp::Core::Handler). middleware.rb stays decorator.rb stays Module renames (no backward-compat shims): Mpp::Methods::Solana -> Mpp::Protocol::Solana Mpp::Headers -> Mpp::Protocol::Core::Headers Mpp::Intent::ChargeRequest -> Mpp::Protocol::Intents::ChargeRequest Mpp::Core::Challenge -> Mpp::Protocol::Core::Challenge Mpp::Core::Credential -> Mpp::Protocol::Core::Credential Mpp::Core::Receipt -> Mpp::Protocol::Core::Receipt Mpp::Core::ChallengeStore -> Mpp::Protocol::Core::ChallengeStore Mpp::Core::Handler -> Mpp::Server::Charge::Handler Mpp::Server::Instance -> Mpp::Server::Charge Mpp.create unchanged (public factory). 259 tests, 0 failures.
Updates every caller of the renamed Mpp::* namespaces: - lib/pay_kit/schemes/mpp.rb + lib/pay_kit/rack/payment_required.rb: PayKit adapter now references Mpp::Protocol::Solana.charge and Mpp::Server::Charge instead of the old Methods/Internal paths. - harness/ruby-server/server.rb: cross-language interop adapter uses the new namespace for Mpp.create's method factory. - examples/sinatra and examples/simple-server: updated to new names. - All ruby/test/ specs (api, b34, charge_request, core, error_codes, expires_rfc3339, handler_paths, server, test_helper): updated. - README references updated. 259 tests, 811 assertions, 0 failures. Coverage line 98.4% / branch 90.39%. standardrb clean.
The previous mpp restructure commit moved files to mpp/protocol/** and mpp/server/charge.rb but mpp.rb wasn't restaged with the new require paths, so CI tried to load the deleted mpp/core/* paths. Tests passed locally because the prior process had already loaded the files into memory.
PR solana-foundation#122 (chore: rename tests/interop to harness) removed the kotlin entry from harness/src/implementations.ts during the cleanup. The underlying adapter source at harness/kotlin-client/src/ is still in place and .github/workflows/kotlin.yml still pre-warms it with 'gradle installDist', but vitest could not find any pair test (no client named 'kotlin' on the active list) so the interop-kotlin job failed with 'No test found in suite mpp interop'. Re-adds the registration with the installDist binary path: 'kotlin-client/build/install/kotlin-client/bin/kotlin-client'. Default 'off' matches swift/php/ruby/go; the CI job opts in via MPP_INTEROP_CLIENTS=kotlin.
| require_relative "../../mpp" | ||
|
|
||
| module PayKit | ||
| module Schemes |
There was a problem hiding this comment.
| module Schemes | |
| module Protocols |
?
also fix path pay_kit/schemes -> pay_kit/protocols
The kotlin-client project name is 'mpp-kotlin-interop-client' per harness/kotlin-client/settings.gradle.kts, so gradle installDist produces build/install/mpp-kotlin-interop-client/bin/mpp-kotlin-interop-client rather than the path my previous commit guessed.
| PayKit.configure do |c| | ||
| c.pay_to = ENV.fetch("PAY_KIT_PAY_TO", "AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj") | ||
| c.network = ENV.fetch("PAY_KIT_NETWORK", "solana_devnet").to_sym | ||
| # Default to mpp-only so the demo boots without a real Solana | ||
| # facilitator keypair. Set PAY_KIT_ACCEPT="x402,mpp" once | ||
| # PAY_KIT_X402_FACILITATOR_KEY holds a valid 64-byte JSON array. | ||
| c.accept = ENV.fetch("PAY_KIT_ACCEPT", "mpp").split(",").map(&:to_sym) | ||
| c.stablecoins = ENV.fetch("PAY_KIT_STABLECOINS", "USDC").split(",").map(&:to_sym) | ||
|
|
||
| c.x402.facilitator = ENV.fetch("PAY_KIT_X402_FACILITATOR", "https://402.surfnet.dev:8899") | ||
| c.x402.facilitator_secret_key = ENV.fetch("PAY_KIT_X402_FACILITATOR_KEY", "[]") | ||
| c.x402.scheme = :exact | ||
|
|
||
| c.mpp.realm = ENV.fetch("PAY_KIT_MPP_REALM", "PayKit Demo") | ||
| c.mpp.secret = ENV.fetch("PAY_KIT_MPP_SECRET", "demo-secret-do-not-use-in-prod") | ||
| end |
There was a problem hiding this comment.
can we move this to pricing.rb and rename pricing.rb to pay_kit.rb
| @@ -30,7 +30,7 @@ require "mpp/sinatra" | |||
| class App < Sinatra::Base | |||
There was a problem hiding this comment.
Can we update the README.md and use the PayKit interface instead?
Per Ludo's inline review on lib/pay_kit/schemes/mpp.rb:10. The vocab table already reserves 'scheme' for x402 sub-forms (:exact today, :upto/:batch later) and 'protocol' for the outer dispatcher (:x402 | :mpp). The adapter namespace was holding protocol-level adapters, so the name was off: - lib/pay_kit/schemes.rb -> lib/pay_kit/protocols.rb - lib/pay_kit/schemes/x402.rb -> lib/pay_kit/protocols/x402.rb - lib/pay_kit/schemes/mpp.rb -> lib/pay_kit/protocols/mpp.rb - module PayKit::Schemes -> module PayKit::Protocols - PayKit::Schemes::SchemeRef -> PayKit::Protocols::ProtocolRef - Coverage filter path updated. Public surface affected: PayKit::Schemes::X402.exact and PayKit::Schemes::MPP.charge become PayKit::Protocols::X402.exact and PayKit::Protocols::MPP.charge. Symbol shorthand (accept: :x402, accept: :mpp) unchanged. 259 tests, line 98.4%, branch 90.39%.
… r3306365) Per Ludo's inline on examples/pay-kit-sinatra/app.rb:28. The PayKit.configure block + Pricing class + PayKit.pricing= assignment now live together in pay_kit.rb, mirroring how a Rails app would scaffold config/initializers/solana_pay_kit.rb. config.ru Rack entry app.rb Sinatra::Base + helpers + routes pay_kit.rb configure block + Pricing class + registry assignment Renamed pricing.rb -> pay_kit.rb; app.rb shrunk from 28 boot lines to one require_relative.
| # and build the MPP server. The method bundles every static knob; per-request | ||
| # only amount + description are passed to server.charge. | ||
| method = Mpp::Methods::Solana.charge( | ||
| method = Mpp::Protocol::Solana.charge( |
There was a problem hiding this comment.
Same - can we use PayKit instead?
| @@ -3,11 +3,11 @@ | |||
| require_relative "config" | |||
|
|
|||
| module SinatraExample | |||
There was a problem hiding this comment.
Let's just have one examples/sinatra using PayKit.
…306835913)
The README was teaching the old bare `Mpp::Sinatra::Helpers + mpp_charge!`
surface. Per Ludo's review on line 30, surface the PayKit interface as
the primary path:
- Quick start: `PayKit.configure` + `PayKit::Pricing` subclass +
`PayKit::Sinatra` + `PayKit::Rack::PaymentRequired`. The bang/predicate/
accessor trio (`require_payment!` / `paid?` / `payment`) replaces
`mpp_charge!`.
- New vocabulary table (gate, amount, total, price, fee_within, fee_on_top,
payment, protocol, scheme, accept, denom, settlement).
- New 'Gates' section showing fee_within/fee_on_top + dynamic block + the
boot validations the registry enforces.
- New 'Inline pricing' section for the `require_payment! usd('0.25')`
one-off path.
- Updated 'Rack-first' explanation; the framework shims sit on top.
- Protocol compatibility matrix now lists x402:exact as 'pass' on the
server side and breaks out mpp:charge into pull/push variants.
- 'Examples' section promotes `pay-kit-sinatra/` as the canonical demo;
the legacy `sinatra/` and `simple-server/` examples stay for callers
that want to drive `Mpp::Server::Charge` directly.
- Repo layout reflects the v2 tree: lib/pay_kit/, lib/mpp/protocol/,
lib/x402/protocol/, lib/pay_core/.
- Interop section adds the x402 harness command.
…r3306844575, r3306849327)
Per Ludo's two reviews: drop both legacy MPP-only examples and keep a
single PayKit-driven example at the canonical 'examples/sinatra/' path.
- Delete examples/simple-server/ (bare WEBrick driving Mpp::Server::Charge).
- Delete examples/sinatra/{app,server,config}.rb (the Mpp::Sinatra helper demo).
- Move examples/pay-kit-sinatra/* to examples/sinatra/ (app.rb, config.ru,
pay_kit.rb, README.md).
- ExampleTest now boots PayKitSinatraExample with PAY_KIT_* env.
- README: 'Examples' section becomes 'Example', repo layout collapsed.
259 tests, 0 failures. Line 98.4% / branch 90.39%.
| use PayKit::Rack::PaymentRequired | ||
|
|
||
| get "/report" do | ||
| require_payment! :report |
There was a problem hiding this comment.
For this first snippet I would make things as concise as possible, and just use
require_payment! usd("0.10"), description: "Premium report"instead.
| PayKit.configure do |c| | ||
| c.pay_to = "AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj" | ||
| c.network = :solana_devnet | ||
| c.accept = %i[x402 mpp] | ||
| c.stablecoins = %i[USDC] | ||
| c.x402.facilitator = ENV.fetch("FACILITATOR_URL") | ||
| c.mpp.realm = "MyApp" | ||
| c.mpp.secret = ENV.fetch("MPP_SECRET") | ||
| end |
There was a problem hiding this comment.
Can we clean-up this block and use defaults values when we can?
we should use
c.network = :solana_localnet
…Ludo r3306898461) README Quick start: drop c.accept / c.stablecoins / c.x402.scheme / c.mpp.realm since config defaults cover them (%i[x402 mpp], %i[USDC], :exact, 'App'). Switch c.network to :solana_localnet to match how a local-dev demo is most likely to be run. Example pay_kit.rb: same cleanup so the demo and the README quick start agree on the minimal setup shape. The example still allows PAY_KIT_NETWORK to override at runtime via ENV when needed.
|
|
||
| server = Mpp.create( | ||
| method: Mpp::Methods::Solana.charge( | ||
| method: Mpp::Protocol::Solana.charge( |
There was a problem hiding this comment.
The harness is not testing x402 just yet, right?
| ├── examples/ # Simple server and Sinatra app examples | ||
| └── test/ # Minitest suite with line and branch coverage gates | ||
| ├── lib/solana_pay_kit.rb # Gem entry (require "solana_pay_kit") | ||
| ├── lib/pay_kit/ # PayKit v2 surface |
There was a problem hiding this comment.
I see a bunch of references to PayKit v2, we're working on wrapping v1 😅
Ludo flagged that this is PayKit v1 (the first wrap), not v2. The internal versioning was a workstream label that should not leak into public docs. - ruby/README.md: 'PayKit v2 surface' -> 'PayKit surface' - ruby/lib/pay_kit.rb: same in the umbrella doc comment - ruby/examples/sinatra/README.md: same in the example overview Left 'x402 v2' references alone where they refer to the x402 protocol version 2 (PAYMENT-REQUIRED header naming convention).
…rop harness prerequisite)
Two small adjustments to the Dispatcher so the cross-language harness
adapter can configure PayKit with literal mint pubkeys and localnet
networks the way the matrix orchestrator expects:
- `mint_for`: unknown coin symbols pass through as literal mints
instead of raising. This lets callers supply a base58 pubkey as the
stablecoin (`usd("1.00", "4zMMC9srt5...".to_sym)`) without
preregistering it in PayCore::Solana::Mints.
- `caip2_for`: localnet maps to devnet's CAIP-2 (Surfpool clones
devnet, so this is the de-facto convention on the wire).
- `mpp_network_label_for`: new helper returning the plain network
label (`mainnet`/`devnet`/`localnet`) for the MPP server
factory, which does not consume CAIP-2.
Proves the PayKit dual-protocol claim cross-language: one Ruby adapter binary serves both x402:exact and mpp:charge on /paid. The harness orchestrator sets either `X402_INTEROP_*` or `MPP_INTEROP_*` envs per scenario; the adapter auto-detects which one is active and configures PayKit accordingly. Pieces: - `harness/pay-kit-server/server.rb`: TCP loop matching the harness contract (ready JSON, /health probe, /paid gated through PayKit::Rack::Dispatcher). Reads both env namespaces, normalises amount / mint / network, configures PayKit, builds the gate. - `harness/src/implementations.ts`: registers `id: ruby-pay-kit-server` with `intents: ['charge', 'x402-exact']`, default off, opt-in via `PAY_KIT_INTEROP_SERVERS`. - `.github/workflows/ruby.yml`: new `interop-pay-kit-server` job running both the charge and x402-exact smokes against the same adapter binary. - `ruby/test/pay_kit/harness_adapter_test.rb`: spawns the adapter under both env modes, asserts ready payload, /health, the 402 challenge shape (asset / scheme / protocol), and rejects ambiguous dual-env or empty-env invocations. - `ruby/test/support_test.rb`: fix the Net::HTTP stub restoration so it forwards full arg/kwargs lists; the previous 2-arg restore broke any later test that called `Net::HTTP.get` (caught by the new harness adapter test). 263 tests, 0 failures, line 98.4% / branch 90.39%.
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.