Skip to content

feat(ruby): pay-kit v2 unified gate API + module restructure#138

Open
EfeDurmaz16 wants to merge 53 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/ruby-pay-kit-v2
Open

feat(ruby): pay-kit v2 unified gate API + module restructure#138
EfeDurmaz16 wants to merge 53 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/ruby-pay-kit-v2

Conversation

@EfeDurmaz16
Copy link
Copy Markdown
Contributor

@EfeDurmaz16 EfeDurmaz16 commented May 26, 2026

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 PayKit unified gate API that lets one Ruby app gate routes with both x402:exact and mpp:charge behind 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:

  1. Shared pay_core extraction (mirrors Rust workspace solana-pay-core). MPP and x402 now both consume PayCore::Solana::*, PayCore::Json (JCS RFC 8785), PayCore::Headers, PayCore::ErrorCodes rather than duplicating Solana wire primitives.
  2. Per-protocol Rust spine layout:
    • ruby/lib/x402/ mirrors rust/crates/x402/src/: constants.rb, error.rb, protocol/schemes/exact/{types,verify}.rb, server/exact.rb. X402::Interop namespace is gone from lib/; interop only lives at the bin boundary (ruby/bin/x402-interop-server) and harness adapters.

    • ruby/lib/mpp/ mirrors rust/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 public Mpp::Server::Charge with the nested Mpp::Server::Charge::Handler orchestrator). 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):

      Before After
      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 is unchanged. The harness ruby-server adapter and the existing Sinatra example were updated to the new names.

  3. PayKit v2 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

Surface Name
Gem (rubygems) solana-pay-kit
Require path require "solana_pay_kit"
Top-level constant PayKit
Sinatra helpers require "solana_pay_kit/sinatra" (opt-in)

Same pattern as activerecord / ActiveRecord and rack-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

Term Meaning
gate A protected unit. Has an amount, optional fees, accepted schemes.
amount The base amount a gate charges, before any fee_on_top.
total What the customer pays: amount + sum(fee_on_top). Derived.
price Value object returned by usd(...): number + denom + settlement.
fee_within Fee taken out of the amount. pay_to recipient nets less.
fee_on_top Fee added to the amount. Customer pays more; pay_to nets full.
payment Proof submitted by the client to pass a gate.
protocol :x402 or :mpp (top-level dispatch).
scheme x402 sub-form: :exact. MPP sub-form: :charge.
accept Ordered preference list (schemes and stablecoins both).
denom Fiat unit a price is quoted in (:USD, :EUR).
settlement On-chain asset that actually transfers (:USDC, :USDT).

payment.protocol is the protocol dispatcher; payment.scheme is the sub-form. This split keeps the x402 spec's vocabulary intact (in x402 a "scheme" is exact / upto / batch) while giving us a clean protocol-level discriminator.

Boot-time configuration

PayKit.configure do |c|
  c.pay_to      = ENV.fetch("PAY_TO")
  c.network     = :solana_devnet
  c.accept      = %i[x402 mpp]
  c.stablecoins = %i[USDC USDT]

  c.x402.facilitator = ENV.fetch("FACILITATOR_URL")
  c.x402.scheme      = :exact

  c.mpp.realm      = "MyApp"
  c.mpp.secret     = ENV.fetch("MPP_SECRET")
  c.mpp.expires_in = 300
end

Frozen at the end of the block. Network and scheme symbols validated on assignment.

Central gates registry

class Pricing < PayKit::Pricing
  def build_gates
    gate :report,   amount: usd("0.10"), description: "Premium report"
    gate :api_call, amount: usd("0.001", :USDC), accept: :x402

    # Inclusive fee. Customer pays $10, SELLER nets $9.70, PLATFORM nets $0.30.
    gate :marketplace_sale,
      amount:     usd("10.00"),
      pay_to:     SELLER,
      fee_within: { PLATFORM => usd("0.30") }

    # Surcharge. Customer pays $10.50, SELLER nets $10.00, PLATFORM nets $0.50.
    gate :ticket,
      amount:     usd("10.00"),
      pay_to:     SELLER,
      fee_on_top: { PLATFORM => usd("0.50") }

    # Dynamic per-request pricing.
    gate :tiered do |request|
      amount usd(request.params["tier"] == "premium" ? "5.00" : "0.10")
    end
  end
end

PayKit.pricing = Pricing.new   # registry frozen at assignment

Symbol shorthand expands against config defaults (:x402, :mpp for schemes; :USDC for stablecoins). Object form carries overrides per gate.

Controller surface (Clearance trio)

class App < Sinatra::Base
  helpers PayKit::Sinatra
  use PayKit::Rack::PaymentRequired

  get "/report" do
    require_payment! :report
    json ok: true, paid_by: payment.protocol
  end

  get "/stats" do
    json ok: true, premium: paid?(:bulk_report)
  end

  get "/oneoff" do
    require_payment! usd("0.25"), description: "One-off"
    json ok: true
  end

  before "/admin/*" do
    require_payment! :admin_access
  end
end

Three primitives, mirroring Clearance's require_login / signed_in? / current_user. The same trio is consumed in Rails through include PayKit::Controller.

Design rules (locked)

  1. A gate is the unit. Carries amount, accepted schemes, fees, description, optional dynamic logic.
  2. Symbols expand against config defaults; objects carry overrides. Same hybrid on both axes (schemes and stablecoins). Symbols cover the common case.
  3. Order is semantic. accept: and stablecoin lists are preference order. The 402 accepts[] body advertises in this order.
  4. Frozen value objects. Gate, Price, Settlement, Fee, Challenge, Payment are Data.define types frozen in their factories. Registry frozen at assignment.
  5. Denomination and settlement are separate. usd("0.10", :USDC, :USDT) means "$0.10 USD, settle in USDC or USDT". Merchants think fiat.
  6. Rack first, framework shims on top. Sinatra and Rails are thin layers over PayKit::Rack::PaymentRequired. No framework is special.
  7. Bang for gates, predicate for opportunism, accessor for the proof. require_payment! / paid? / payment.
  8. Errors are rescuable typed classes. PayKit::PaymentRequired and PayKit::InvalidProof can be rescue_from'd.
  9. One source of truth per axis. Stablecoin mints live in PayCore::Solana::Mints with mainnet/devnet/localnet defaults.
  10. Amount + fees, never opaque splits. Multi-recipient gates declare one amount plus fee_within / fee_on_top hash kwargs. Fixed amounts only. x402 auto-disabled on any gate carrying fees because stock x402 facilitators settle to one address; the resolver strips :x402 from accept silently, and explicitly setting accept: :x402 on a fee-bearing gate raises at boot.

Boot validations (all PayKit::ConfigurationError)

  • name must be a Symbol.
  • amount must be a Price (built via usd / eur / gbp).
  • pay_to must be a non-empty string (gate or config default).
  • Fee recipient must differ from pay_to. Fold the fee into the amount instead.
  • No duplicate fee recipient across fee_within + fee_on_top.
  • All fee prices share one denomination with the amount.
  • sum(fee_within values) <= amount.
  • accept must resolve to a non-empty list after auto-disable rules apply.
  • Setting accept: :x402 on a fee-bearing gate raises (defense in depth above the silent strip).

Fee math

gate.total returns the customer-facing total. gate.payout(to:) returns what a recipient nets:

  • pay_to recipient: amount - sum(fee_within).
  • A fee recipient: their fee.
  • Anyone else: raises (typos shouldn't return 0 silently).

Layers

PayKit::Rack::PaymentRequired   Rack middleware (protocol boundary)
PayKit::Sinatra                 Sinatra helpers (opt-in require)
PayKit::Controller              Rails controller shim
PayKit::Pricing                 base class for gates registry
PayKit::Gate, ::Price,
::Settlement, ::Fee,
::Challenge, ::Payment          frozen Data.define value objects
PayKit::Config                  PayKit.configure block
PayKit::Schemes::X402           wraps X402::Server::Exact
PayKit::Schemes::MPP            wraps Mpp::Server::Instance

Middleware is small: installs a dispatcher on the env, rescues PaymentRequired into 402, merges settlement headers from a verified Payment into 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 with curl:

GET /health                       200 {ok: true}
GET /report                       402 + WWW-Authenticate: Payment + accepts[] body
                                  amount=100000 (microUSDC), payTo from config
GET /marketplace/sale             402, splits=[seller=9700000, platform=300000]
                                  ($10 amount with fee_within=$0.30 to PLATFORM)
GET /ticket                       402, amount=10500000 ($10 + $0.50 fee_on_top)
GET /tiered?tier=premium          402, amount=5000000 (dynamic block result)
GET /oneoff                       402, amount=250000 (inline form, no registry entry)
GET /stats                        200 {premium: false} (paid?: never halts)

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

  • 259 runs, 811 assertions, 0 failures
  • Line coverage: 98.4% (1660 / 1687)
  • Branch coverage: 90.39% (527 / 583)
  • Existing 92 / 90 gate passes
  • standardrb clean
  • Coverage filters lib/pay_kit/rack/ and lib/pay_kit/schemes/ (live-RPC integration layers exercised via the example and the cross-language interop harness), mirroring the existing lib/x402/server/ exclusion

Test coverage by surface:

Area Tests Focus
Price + helpers 13 denom, helper fallback, BigDecimal precision, with_amount
Fee + Builder 5 hash shape, kind predicates, recipient/price validation
Gate validators 18 self-ref, denom mix, sum > amount, x402 auto-disable, duplicate recipients, frozen
Gate fee math 5 total, payout(to:), unknown recipient raises
DynamicGate 1 per-request resolution
Pricing DSL 8 known/unknown, frozen, coerce, duplicate gate
Config 9 freezing, invalid network/scheme/empty, idempotent setter
Challenge / Payment 2 shape + protocol predicates
Errors 3 message/code/detail propagation
Middleware (Rack::Test) 5 402 with both schemes, paid 200 + settlement headers, paid?, inline, dynamic
Pre-existing MPP + x402 186 unchanged, all green

Non-goals (per the design doc)

Out of scope for v1, deferred:

  • Multi-recipient splits beyond fixed-amount fees
  • Subscription / recurring billing
  • Custodial wallet management
  • Non-USD-pegged settlement (BTC/SOL-denominated pricing)
  • Chargeback / refund flows
  • Generator (rails generate solana_pay_kit:install) implementation. The shape is documented; the generator code itself lands in a follow-up

Follow-up planned after this lands

  • MPP session intent + multi-delegate client surface (Rust's protocol/intents/session.rs, client/session*.rs, client/multi_delegate.rs and friends). Out of scope here; Ruby is server-only.
  • Rails PayKit::Controller controller-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::X402 cleanly wrap X402::Server::Exact without re-importing internals. Closing #127 with a pointer; the branch is preserved.

EfeDurmaz16 and others added 30 commits May 26, 2026 18:52
…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.
Comment thread ruby/lib/pay_kit/schemes/mpp.rb Outdated
require_relative "../../mpp"

module PayKit
module Schemes
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
Comment thread ruby/examples/pay-kit-sinatra/app.rb Outdated
Comment on lines +13 to +28
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we move this to pricing.rb and rename pricing.rb to pay_kit.rb

Comment thread ruby/README.md
@@ -30,7 +30,7 @@ require "mpp/sinatra"
class App < Sinatra::Base
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment thread ruby/examples/simple-server/app.rb Outdated
# 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(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same - can we use PayKit instead?

Comment thread ruby/examples/sinatra/server.rb Outdated
@@ -3,11 +3,11 @@
require_relative "config"

module SinatraExample
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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%.
Comment thread ruby/README.md
use PayKit::Rack::PaymentRequired

get "/report" do
require_payment! :report
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this first snippet I would make things as concise as possible, and just use

  require_payment! usd("0.10"), description: "Premium report"

instead.

Comment thread ruby/README.md
Comment on lines +25 to +33
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness is not testing x402 just yet, right?

Comment thread ruby/README.md Outdated
├── 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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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%.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants