Skip to content

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

Merged
lgalabru merged 77 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/ruby-pay-kit-v2
May 27, 2026
Merged

feat(ruby): pay-kit v2 unified gate API + module restructure#138
lgalabru merged 77 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/ruby-pay-kit-v2

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented May 26, 2026

Copy link
Copy Markdown
Collaborator

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.
Bundles merchant identity (recipient + signer + fee_payer) into a
single configurable object so the configure block stays terse:

  c.operator do |op|
    op.recipient = ENV["PAY_KIT_OPERATOR_RECIPIENT"]
    op.signer    = PayKit::Signer.env("PAY_KIT_OPERATOR_KEY")
  end

Setters silently ignore nil so env-driven configuration composes
cleanly without 'if ENV[...]' guards (matches the DESIGN.md setter
convention). reset!(:field) is the escape hatch when a previously-set
value needs to be cleared.

Defaults:
  signer    = PayKit::Signer.demo
  fee_payer = true
  recipient = nil (resolves to signer.pubkey via effective_recipient)

Validation is loud (production-faithful): non-String recipients,
non-signer-shaped signers, and non-strict-boolean fee_payer values all
raise PayKit::ConfigurationError. Truthy coercion ("yes", 1, 0) is
explicitly rejected because flag-bug masking is exactly what
ConfigurationError is for.

Equality + hash are defined over the resolved tuple (effective
recipient, signer.pubkey, fee_payer flag) so the dispatcher can detect
config-equivalent operators.

This commit only adds the type; no other callers reference it yet.
PayKit::Config (next package) consumes the operator in its boot
block.

Tests: 25 new (operator_test.rb). Full suite: 321 runs, 961
assertions, 0 failures. Line coverage 98.49%, branch 90.73%.
… + facilitator_url + challenge_binding_secret)

Centralise everything on the new c.operator value (recipient + signer +
fee_payer). Split the historically conflated x402.facilitator (always a
Solana RPC URL) into c.rpc_url (chain endpoint) and
c.x402.facilitator_url (delegated mode). Rename c.mpp.secret to
c.mpp.challenge_binding_secret to track draft-httpauth-payment-00
vocabulary.

New surface:
- c.operator { |op| op.recipient = ...; op.signer = ...; op.fee_payer = ... }
- c.operator = PayKit::Operator.new(...) for direct assignment
- c.rpc_url (nil resolves to PUBLIC_RPC_URLS per network)
- c.x402.facilitator_url + c.x402.delegated? predicate
- c.x402.signer override (falls back to operator.signer)
- c.mpp.challenge_binding_secret

Safety:
- freeze! raises DemoSignerOnMainnetError when network=mainnet and
  operator.signer.demo? (refuses to boot with a published keypair)
- freeze! warns when network=mainnet and rpc_url falls back to the
  rate-limited public Solana RPC

Deprecation shims (warn-once per process, route to new surface):
- c.pay_to= -> c.operator.recipient
- c.x402.facilitator= -> c.rpc_url (was always a Solana RPC, never an x402 facilitator)
- c.x402.facilitator_secret_key= -> c.operator.signer via Signer.json
  (empty string and "[]" no-op so existing examples that boot without a real signer keep working)
- c.mpp.secret= -> c.mpp.challenge_binding_secret

Rack middleware reads the new fields:
- x402 dispatcher uses c.x402.effective_signer + c.effective_rpc_url
- MPP dispatcher uses c.operator.effective_recipient + c.mpp.challenge_binding_secret

test_helper.rb migrated to the new API so the suite stays warning-free
except in the few tests that explicitly exercise the shims.

35 new config tests cover defaults, rpc_url per network, operator
block + assignment + non-Operator rejection, mainnet+demo refusal,
mainnet+public-RPC warning, delegated predicate, x402 signer override,
challenge_binding_secret rename, and warn-once for every shim.
…cache across requests

Two long-lived caches now live on the Rack middleware and survive
across every request that middleware handles:

- @x402_settlement_cache (X402::Server::Exact::SettlementCache).
  Previously a fresh cache was allocated per-request inside
  build_x402_config, which defeated the purpose: duplicate signature
  detection only worked within a single request. Now one cache spans
  all requests through this middleware instance.

- @mpp_method_cache (new MppMethodCache, mutex-guarded Hash). The
  cache key is the full tuple that defines an on-chain charge intent:
  [recipient, currency, network, rpc, secret, realm]. Two gates with
  the same tuple share an Mpp::Server::Charge (and its ChallengeStore
  HMAC state); gates with different gate.pay_to get their own server.

Adapter changes:
- ::PayKit::Protocols::MPP.new now accepts server_for: ->(gate) { ... }
  in addition to the legacy fixed-server form (server: ...) which is
  preserved so existing fake-server tests keep working.
- Dispatcher.mpp_adapter wires server_for to mpp_server_for(gate).
- Dispatcher.build_x402_config threads the shared settlement_cache
  into the per-request X402::Server::Exact::Config.

Tests (test/pay_kit/dispatcher_test.rb, 4 new):
- SettlementCache shared across requests (put_if_absent returns false
  on the second hit)
- Same recipient/currency/network gates share an MPP server
- Distinct gate.pay_to values get distinct MPP servers (recipient
  differs on each method)
- MPP method cache survives across dispatcher instances on the same
  middleware (two different dispatchers hit the same cached server)
…-aligned)

The MPP Solana verifier computes
  primary = request.amount - sum(splits.amount)
and matches a transfer of `primary` to `request.recipient` (the
gate's pay_to) - see lib/mpp/protocol/solana/verifier.rb:75-87.
Including the primary recipient inside splits[] therefore
double-counts the principal and would fail verification with
"split amounts exceed total amount".

The previous splits_for built:
  [{recipient: gate.pay_to, amount: primary}, ...fees...]
which was wrong by spec. New behaviour:
  [...fees only...]   when gate.fees?
  nil                 when no fees

The 402 accepts entry still exposes the primary recipient via the
top-level payTo field, so clients see both the principal and the
fee-only splits.

4 new tests in test/pay_kit/mpp_adapter_test.rb pin:
- nil when no fees
- primary recipient never appears in splits[]
- fee order preserved + amounts converted to 6-decimal smallest units
- accepts_entry surfaces primary via payTo, splits carries only fees
PayKit.config.mpp.expires_in was previously a silently-ignored knob.
The new path:

  PayKit::Config.mpp.expires_in
    -> Dispatcher#mpp_server_for passes expires_in: to Mpp.create
    -> Mpp.create forwards to Mpp::Server::Charge.new(expires_in:)
    -> Charge.new constructs ChallengeStore with default_expires_seconds:
    -> ChallengeStore#create_challenge uses Expires.seconds(default)
       per call so the timestamp is "now + N", not store-construction-time.

ChallengeStore::DEFAULT_EXPIRES_SECONDS = 300 keeps existing call sites
on the same 5-minute default they had via Expires.minutes(5). The
previous create_challenge/create_challenge_header kwargs (`expires:`)
still work for callers that want to pin an exact RFC3339 timestamp.

Added Mpp::Expires.seconds(s, now:) helper so non-minute control
points have a clean expression (e.g. PayKit.config.mpp.expires_in
defaults to 300 seconds).

The MPP method cache key in PayKit's dispatcher now includes
expires_in so two configs that differ only on TTL get distinct
cached servers.

Tests:
- 5 new tests in test/mpp/expires_in_test.rb cover Expires.seconds,
  ChallengeStore default + custom expiry, create_challenge using the
  store default, and Mpp.create threading expires_in into the store.
- 1 new test in test/pay_kit/dispatcher_test.rb pins that
  c.mpp.expires_in propagates into the cached MPP server's
  ChallengeStore.
DESIGN.md rule: "pay_to: is optional and defaults to operator.recipient.
Most gates omit it; marketplace gates set it to route to a seller."

The Pricing DSL now resolves the default recipient from
PayKit.config.operator.effective_recipient (which itself falls back to
operator.signer.pubkey when no explicit recipient is set). The previous
default_pay_to: PayKit.config.pay_to path went through the deprecated
shim and emitted a warn on every gate construction.

Gate-level pay_to: overrides the operator default per gate (marketplace
seller flow). Inline coercion (require_payment! usd("0.10")) uses the
same fallback.

3 new tests in test/pay_kit/pricing_test.rb pin:
- default operator recipient flows into every gate built without
  explicit pay_to
- zero-config boot uses the demo signer pubkey as the default recipient
- gate-level pay_to still overrides the operator default
DynamicGate#fees? unconditionally returned true. The intent was
"play it safe with x402" (which can't settle multi-recipient fees),
but the side effect was that EVERY dynamic gate had x402 silently
disabled at the adapter level, even ones that resolve to zero fees
on a given request.

The actual contract is: a DynamicGate must be materialized into a
static Gate before any fees-aware code path inspects it. The Sinatra
helper already does this (resolve_gate at lib/pay_kit/sinatra.rb:71)
and Dispatcher#materialize is the explicit hook for non-Sinatra
callers. Removing the method exposes the contract: any code that
calls #fees? on an un-materialized DynamicGate now NoMethodErrors
loudly instead of silently misbehaving.

Pinned by a refute_respond_to test so this can't quietly reappear.
Mpp::Server::Charge#charge already accepts an external_id: kwarg (used
to correlate a settled charge with a merchant order ID, invoice number,
etc.). PayKit's MPP adapter previously dropped this on the floor.

New surface:
- Gate.new / Gate.build / pricing DSL accept optional external_id:
- DynamicGate's block supports `external_id req.params["order"]`
- MPP adapter.perform passes gate.external_id into server.charge

A static gate sets external_id at boot; a dynamic gate computes it per
request from the Rack request. Both wire through to the MPP receipt
header so downstream audit can correlate settlements with merchant
records.

Drive-by: config_test's setup now calls PayKit.reset! defensively.
Previously it relied on teardown for cleanup, but random seed orderings
could schedule a test that ran configure { ... } block AFTER another
test's frozen @config slot was restored to the global, hitting
FrozenError on c.x402.facilitator_url=.

Tests in test/pay_kit/mpp_adapter_test.rb (3 new):
- gate.external_id defaults to nil
- MPP adapter forwards external_id into server.charge kwargs
- DynamicGate evaluates external_id from the per-request block
…he 402 body

The MPP server returns a Challenge with body["code"] set to a
canonical L6 wire code (e.g. challenge_expired, replay,
amount_mismatch). The previous adapter dropped that on the floor and
raised InvalidProof.new(:payment_required, reason) so clients only
saw the generic PayKit code and a human message.

Now:
- InvalidProof carries an optional spec_code: kwarg alongside its
  PayKit-level code
- The MPP adapter reads body["code"] from the rejected Challenge and
  passes it through
- The Rack middleware includes spec_code in the 402 JSON body when
  present:
    {"error": "payment_required", "message": "...", "spec_code": "challenge_expired"}

Clients can now branch on either layer without parsing the message.

Test pins the chain: fake server emits a body["code"], adapter
raises InvalidProof, err.spec_code == "challenge_expired".
Mpp::Protocol::Solana::ChargeMethod#method_details emits the
feePayer/feePayerKey fields when fee_payer: is supplied at method
construction. The dispatcher previously omitted fee_payer: entirely,
so the operator.fee_payer? flag never reached the wire and the MPP
verifier saw method_details without a feePayer claim regardless of
config.

New path:
- PayKit::Signer::Local exposes its underlying PayCore::Solana::Account
  via #to_pay_core_account (PayKit-internal use only; ordinary code
  still consumes the duck-typed signer interface)
- Dispatcher#mpp_server_for reads operator.fee_payer? and, when true,
  passes operator.signer.to_pay_core_account to
  Mpp::Protocol::Solana.charge(fee_payer:)
- The MPP method cache key now includes the fee-payer pubkey so two
  operators that differ only on fee_payer get distinct cached servers

Spec alignment: when operator.fee_payer? is false the feePayer and
feePayerKey fields stay absent from method_details (the canonical
"feePayerKey absent" rule). When true they appear with the operator's
signer pubkey.

2 new tests in dispatcher_test.rb:
- fee_payer: true -> method.fee_payer_pubkey == operator.signer.pubkey
- fee_payer: false -> method.fee_payer is nil, fee_payer_pubkey is nil
c.x402.facilitator_url already configures the delegated x402 mode
(P3: PayKit POSTs verify/settle to the facilitator instead of touching
the chain locally). The HTTP client that drives those calls is a
follow-up; until it lands the dispatcher must not silently fall back
to self-hosted behaviour or quietly hand back a useless adapter.

x402_adapter now raises PayKit::NotImplementedError with a message
pointing at the three resolution paths:
- unset c.x402.facilitator_url to run x402 self-hosted (today)
- drop :x402 from c.accept to use MPP only
- wait for the delegated client follow-up

Self-hosted x402 (facilitator_url unset) keeps working unchanged.

2 new tests in dispatcher_test.rb pin both branches: delegated raises
with the expected message; self-hosted resolves the adapter cleanly.
DESIGN.md: "The gem auto-detects Sinatra and registers the helper
module when sinatra/base is loaded, so a single require
'solana_pay_kit' is enough." Previously the helper module and Rack
middleware had to be wired by hand (helpers PayKit::Sinatra +
use PayKit::Rack::PaymentRequired).

solana_pay_kit.rb now:
- Calls PayKit::SinatraAutoRegister.try_register! immediately,
  which fires when Sinatra::Base is already defined (require
  "sinatra/base"; require "solana_pay_kit").
- Falls back to a TracePoint on the :end event for Sinatra::Base
  when Sinatra hasn't been loaded yet (require "solana_pay_kit"
  before require "sinatra/base"). The :end event is critical -
  :class fires before the class body has run, so `helpers` is
  still undefined; :end fires after the body, when the Sinatra::Base
  class-level DSL methods are available. The TracePoint disables
  itself after firing so there is no ongoing tracing overhead.
- Registration is idempotent (@registered flag), so apps that
  explicitly write helpers/use lines still work without double-
  registering.

Tests:
- test/load_order/sinatra_first_test.rb - Sinatra loaded first
- test/load_order/sinatra_second_test.rb - solana_pay_kit loaded first
- test/pay_kit/load_order_test.rb spawns both subprocesses via Open3
  so the require state stays clean. test/run.rb excludes
  test/load_order/ from the in-process glob.
…udo r3306892787)

examples/sinatra/pay_kit.rb migrates to the post-DESIGN.md surface:
- c.operator { |op| op.recipient = ENV[...]; op.signer = Signer.env(...) }
  replaces c.pay_to= and c.x402.facilitator_secret_key=
- c.rpc_url replaces c.x402.facilitator (which was always a Solana RPC,
  never an x402 facilitator)
- c.mpp.challenge_binding_secret replaces c.mpp.secret
- Drops the no-longer-relevant PAY_KIT_X402_FACILITATOR_KEY=[] sentinel
  (the new operator default is the demo signer)

examples/sinatra/app.rb drops the explicit `helpers PayKit::Sinatra` and
`use PayKit::Rack::PaymentRequired` calls: a single
`require "solana_pay_kit"` now wires both via Sinatra auto-detect.

README first snippet rewritten to the form Ludo asked for in his
r3306892787 review: one configure line + one route in `get("/report")
{ require_payment!(usd("0.10")); "ok" }` shape. The expanded "Quick
start" copy points at zero-config boot, the demo signer, mainnet
refusal, and the auto-detect path. The longer Pricing class walkthrough
stays further down the README.
…llenge_binding_secret + push-mode fee-payer ban

The interop harness used the pre-DESIGN.md surface: c.pay_to=,
c.x402.facilitator= (which was always a Solana RPC URL), and
c.mpp.secret=. All four routed through the deprecation shims and
emitted warnings into the harness stdout, which the interop matrix
parsed and treated as noise.

New configuration:
- c.operator { |op| op.recipient = pay_to; op.signer = Signer.json(...) }
  replaces c.pay_to= and c.x402.facilitator_secret_key=
- c.rpc_url replaces c.x402.facilitator (which never was an x402
  facilitator)
- c.mpp.challenge_binding_secret replaces c.mpp.secret

MPP-mode push-mode fee-payer ban (R3): in the MPP harness flow the
server holds no Ed25519 keypair (verification uses the HMAC challenge-
binding secret; on-chain settlement is read-only). The client pays
its own SOL fee. operator.fee_payer = false now flips this on, so
MPP method_details emits no feePayer/feePayerKey claim - the verifier
correctly treats it as a push-mode charge.
Two README sections drifted from the post-DESIGN.md surface:

1. Rack-first - explained the `use PayKit::Rack::PaymentRequired`
   line as if every app needed it. The Sinatra auto-detect now wires
   it for you; the explicit form is only needed for raw Rack or
   non-Sinatra frameworks. The middleware blurb now also names the
   long-lived caches (x402 SettlementCache + MPP method cache)
   that survive across requests.

2. Example "Run it" - referenced the deleted
   PAY_KIT_X402_FACILITATOR_KEY env and the old "mpp-only default"
   guidance. Replaced with the zero-config boot + env overrides
   (PAY_KIT_PAY_TO, PAY_KIT_OPERATOR_KEY, PAY_KIT_RPC_URL) the
   updated example actually supports.
…test)

Branch coverage was 89.83% (583/649) after the DESIGN.md refactor,
below the 90% gate. The 4 missing branches were all on the
deprecation shim accessor paths in Config:

- c.x402.facilitator_secret_key= with nil (no-op short-circuit before
  empty-string branch)
- c.x402.facilitator_secret_key (reader)
- c.x402.facilitator (reader)
- c.mpp.secret (reader)

Adds focused tests for each so the shim accessors keep round-tripping
through the new operator + rpc_url + challenge_binding_secret surface
correctly. Branch coverage is now 90.14% (585/649).
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Pushed 17 atomic commits aligning the gem with the updated DESIGN.md surface.

Config: c.operator { |op| op.recipient/op.signer/op.fee_payer = ... }, c.rpc_url, c.x402.facilitator_url (delegated mode flag, raises NotImplementedError on use until the client lands), c.mpp.challenge_binding_secret. Deprecation shims for c.pay_to=, c.x402.facilitator=, c.x402.facilitator_secret_key=, c.mpp.secret= warn-once and route to the new fields. Boot refuses :solana_mainnet + Signer.demo via DemoSignerOnMainnetError; warns when mainnet falls back to the rate-limited public RPC.

Signer/Operator/Kms: PayKit::Signer.{demo,bytes,json,base58,hex,file,env,generate} factory family wrapping PayCore::Solana::Account. PayKit::Operator value object (recipient + signer + fee_payer) with nil-as-no-op setters. PayKit::Kms namespace reserved for remote signers (gcp/aws/vault all raise NotImplementedError).

Dispatcher: x402 SettlementCache and per-gate MPP method cache (keyed on recipient/currency/network/rpc/secret/realm/expires_in/fee_payer pubkey) now live on the Rack middleware so they survive across requests. MPP splits[] drops the primary recipient (verifier computes primary = total - sum(splits)); operator.fee_payer wires through to feePayerKey; c.mpp.expires_in plumbed end-to-end (Mpp.create -> ChallengeStore.default_expires_seconds); InvalidProof.spec_code propagates the L6 wire code into the 402 body.

Gate: pay_to: optional and defaults to operator.effective_recipient. external_id kwarg surfaces from static gates or dynamic blocks into MPP charge. Removed DynamicGate#fees? (was unconditionally true, silently disabling x402 for every dynamic gate).

Sinatra: auto-detect at gem boot in both load orders (TracePoint :end on Sinatra::Base, idempotent). One require "solana_pay_kit" is enough. Example + README first snippet rewritten to the concise form requested in r3306892787.

Harness: harness/pay-kit-server/server.rb migrated to the new surface; MPP path runs operator.fee_payer = false (push-mode: client pays its own SOL fee).

Tests: 381 runs / 1068 assertions / 0 failures, 98.33% line + 90.14% branch coverage, lint clean. Load-order tests run in fresh subprocesses to verify both require orderings (test/load_order/, driven by test/pay_kit/load_order_test.rb).

One callout: DESIGN.md cites the demo pubkey as AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj but no corresponding seed was published. Generated a fresh 64-byte keypair and pinned it as PayKit::Signer::Demo::SECRET_BYTES/PUBKEY = ALtYSsZuYyKrNSe6GnVCzxj1T2RPMTPzXMe51xhbmXEq. Happy to swap to your published seed if you have one.

…so interop env-set knobs land on the wire

The harness has been failing the PayKit interop CI since it landed
at b2a25fd: the orchestrator passes MPP_INTEROP_SETTLEMENT_HEADER,
MPP_INTEROP_SPLITS (with per-split recipient/amount/ataCreationRequired/memo),
MPP_INTEROP_DECIMALS, MPP_INTEROP_ASSET_KIND (sol vs spl),
MPP_INTEROP_REPLAY_SOURCE_{AMOUNT,PATH}, but the adapter routed the
MPP path through PayKit::Pricing + Gate + Dispatcher, which doesn't
model any of those (Gate has no per-fee ataCreationRequired/memo,
PayKit::Config has no settlement_header knob, the dispatcher's
gate.fees? gate is too coarse).

MPP path now bypasses PayKit's gate DSL and drives Mpp::Server::Charge
directly:
- settlement_header threads through Mpp.create
- splits forward as the splits: kwarg on each server.charge call,
  so per-split ataCreationRequired and memo land on method_details
- decimals come from MPP_INTEROP_DECIMALS so literal mint pubkeys
  (which aren't in PayCore::Solana::Mints) don't crash decimals_for
- asset_kind=sol routes currency="SOL" so the verifier's
  SOL-native branch fires
- replay-source scenarios bind a second logical resource to the
  same server instance, so the per-instance replay store gives the
  idempotent-resubmit + cross-server-portability contracts for free

x402 path keeps using PayKit (Pricing + inline gate + dispatcher);
the x402 wire format doesn't have the per-scenario knobs MPP does.

InvalidProof / Mpp::Error rescues now also surface the canonical
code so the G39 fault matrix can lock in cross-SDK code agreement.
… + drop empty x402 interop step

Two related changes:

1. Loosen Types.accepted_requirement_matches? to TS-reference semantics.
   The previous left == right strict equality rejected credentials whose
   accepted object omits amount/maxTimeoutSeconds (the v2 wire shape
   used by ts-x402 and rust-x402 clients) or carries unknown extra
   fields. Now matches on the identity tuple (scheme/network/asset/payTo
   + canonical extra keys feePayer/tokenProgram/memo) and ignores
   informational fields, mirroring
   harness/src/fixtures/typescript/exact-server.ts:141-143 and the
   spine accept logic in
   rust/crates/x402/src/protocol/schemes/exact/types.rs:337-339.

2. Drop the placeholder x402 CI step. The previous workflow shelled out
   to harness/test/x402-exact.e2e.test.ts which is gated behind
   X402_INTEROP_MATRIX=1 and has no surfpool bootstrap of its own;
   without those, it reported "1 test | 1 skipped" - a green-but-empty
   signal that misled review. Cross-language x402 interop against this
   adapter still has multiple field-name asymmetries to resolve (amount
   vs maxAmountRequired in offer JSON, ts-x402's wire-only payload that
   lacks payload.transaction); those land in a follow-up. The dual-
   protocol adapter still BOOTS in x402 mode (proven by
   test/pay_kit/harness_adapter_test.rb).

Two existing tests that pinned the old strict-equality semantics
(test_settlement_rejects_accepted_extra_drift,
test_settlement_rejects_accepted_max_timeout_drift) are renamed to
test_settlement_tolerates_* and now assert "matching did not block",
catching only the specific "No matching" rejection.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

x402 interop step was reporting "1 test | 1 skipped" because it shelled out to the standalone test/x402-exact.e2e.test.ts which is gated behind X402_INTEROP_MATRIX=1 and has no surfpool bootstrap of its own. Pushed two changes:

  1. Dropped the placeholder x402 step. Cross-language x402 against this adapter has multiple field-name asymmetries I want to resolve in a focused follow-up: spine to_accepted_value emits amount (consistent with Ruby), ts-x402 client reads maxAmountRequired, and payload.transaction is missing from ts-x402's wire-only payload. Mixing those into this PR would balloon scope. The dual-protocol adapter still BOOTS in x402 mode (test/pay_kit/harness_adapter_test.rb covers that).

  2. Loosened Ruby's Types.accepted_requirement_matches? from strict left == right to identity-tuple match (scheme/network/asset/payTo + canonical extra keys). The old behaviour rejected any v2 credential whose accepted object omitted amount/maxTimeoutSeconds or carried unknown extras. New behaviour mirrors the TS reference at harness/src/fixtures/typescript/exact-server.ts:141-143. Two existing tests that pinned the strict shape renamed to test_settlement_tolerates_*.

Local MPP interop still green (8 scenarios). Pushed 70ad820.

Three issues blocked rust-x402 client from parsing this server's
challenge:

1. Body missing both spellings of the offer amount. ts-x402 client
   reads `offer.maxAmountRequired`, the Rust spine parser accepts
   `amount` OR `maxAmountRequired`. Now emit both: `amount` stays
   the canonical wire (matches spine `to_accepted_value`) and
   `maxAmountRequired` is added so TS-style clients deserialize the
   offer correctly.

2. PaymentRequired envelope's `resource` field was emitted as
   `{type, uri}` but Rust deserialises it as `ResourceInfo {url,
   description?, mimeType?}`. Now emit both `url` and `uri` keys so
   either client parser accepts the envelope shape.

3. PayKit's x402 dispatcher passed `gate.total.amount` ("0.001")
   straight into X402::Server::Exact::Config. The v2 wire amount is
   smallest-units u64 (Rust spine parses `requirement.amount` as
   `u64`; decimal forms trip `Invalid amount: 0.001`). Added
   `to_smallest_units_string(price)` to the dispatcher that converts
   "0.001" -> "1000" using the canonical 6-decimal default.

All three are server-side fixes; client code unaffected.

Verified locally: rust-x402 client -> ruby-pay-kit-server (x402-exact-basic)
now passes after these changes. MPP interop unchanged (8 charge
scenarios still green).
…l-protocol adapter

The PayKit interop CI step previously shelled out to a placeholder
x402 test that reported "1 test | 1 skipped". The actual cross-
language x402 matrix is `test/e2e.test.ts`, which already boots
surfpool + funds keypairs but used to hard-skip every `x402-exact`
scenario with the comment "MPP runner builds MPP_INTEROP_* env which
x402 adapters do not consume."

Lifting that restriction so the same surfpool fixtures back both
protocols:

- environmentForScenario emits X402_INTEROP_* shadows alongside
  MPP_INTEROP_* for x402-exact scenarios (same payTo / client secret
  / facilitator secret / RPC URL), plus a PAY_KIT_INTEROP_PROTOCOL=
  x402|mpp hint that auto-detect adapters can read instead of
  probing env namespaces (ambiguous when both are populated).
- The hard-skip on `scenario.intent === "x402-exact"` is removed.
  The existing pair filter already gates on
  `impl.intents.includes(scenario.intent)`, so charge-only adapters
  skip x402 scenarios automatically. The intent default for impls
  without an explicit `intents` field is now `["charge"]` (was "all
  intents") so legacy charge-only adapters stay charge-only.
- runClient injects X402_INTEROP_TARGET_URL alongside
  MPP_INTEROP_TARGET_URL so x402 clients find their expected env var.
- ruby-pay-kit-server reads PAY_KIT_INTEROP_PROTOCOL first; falls back
  to namespace probing when unset (existing single-protocol callers
  unchanged).

CI workflow merges the two previously-separate steps into one, and
restricts X402_INTEROP_CLIENTS to rust-x402 (the wire-only ts-x402
client cannot pair against a real settle server because its payload
omits the on-chain transaction).

Verified locally: 9 scenarios pass (8 mpp charge + 1 x402-exact
rust-x402 -> ruby-pay-kit-server).
…ignature_consumed

Solana RPC rejects a duplicate signature with "This transaction has
already been processed" before the per-instance replay store fires
(MPP server consumes the signature AFTER broadcast in pull mode).
Without this regex, idempotent-resubmit credentials surfaced as
generic payment_invalid instead of the canonical signature_consumed
code the G39 cross-SDK matrix pins.

Pinned by harness/test/e2e.test.ts charge-idempotent-resubmit
scenario against ruby-pay-kit-server, which now emits the expected
canonical code on resubmit.
…arios

The dual-protocol adapter already had the SDK plumbing for every
scenario class the matrix exercises - the gate was the per-scenario
`serverIds` whitelist that opted SDKs into each test only after
their canonical-code wiring and protocol-mode coverage were proven.
Verified locally and adding ruby-pay-kit-server to:

- charge-push: pull-default operator with fee_payer:false routes
  the credential through Mpp::Server::Charge's push path
- charge-network-mismatch: MPP server emits wrong_network when the
  surfpool blockhash claims a network that differs from the server's
  configured network
- charge-cross-route-replay: challenge_store verify_expected fires
  charge_request_mismatch when the credential's pinned amount /
  recipient does not match the served route's expected charge
- charge-idempotent-resubmit: signature_consumed now lands via the
  new "already been processed" classifier
- charge-decimals-9: harness threads MPP_INTEROP_DECIMALS straight
  into Mpp::Protocol::Solana.charge, so the wire amount stays
  env-driven instead of being recomputed by the SDK
- charge-sol-native: harness maps MPP_INTEROP_ASSET_KIND=sol to
  currency="SOL" so the MPP verifier's SOL-native branch fires
- charge-cross-server-portability: two ruby-pay-kit-server instances
  with distinct MPP secrets correctly reject portability attempts
  with challenge_verification_failed

ruby-pay-kit-server scenario coverage: 9 -> 17 (now exceeds rust's 12
and matches typescript's 14, plus 3 cross-server / x402 pairs).
Comment thread harness/src/intents/charge.ts Outdated
expectedStatus: 200,
clientIds: ["typescript"],
serverIds: ["typescript", "rust", "php", "ruby"],
serverIds: ["typescript", "rust", "php", "ruby", "ruby-pay-kit-server"],

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.

Should ruby-pay-kit-server just become ruby?

… ruby adapter (Ludo r3311413501)

The dual-protocol PayKit adapter is a strict superset of the previous
MPP-only ruby-server, so there is no reason to carry two distinct
harness impls. Drop the old harness/ruby-server/server.rb (189 lines,
Mpp-only) and rename harness/pay-kit-server/server.rb to
harness/ruby-server/server.rb. The impl id collapses from
{ruby, ruby-pay-kit-server} to a single `ruby` that supports both
intents.

- harness/src/implementations.ts: drop the separate ruby-pay-kit-server
  entry, give the canonical `ruby` impl the dual-protocol label and
  intents: ["charge", "x402-exact"].
- harness/src/intents/charge.ts: every scenario serverIds list that
  previously named ruby-pay-kit-server now names ruby (push,
  network-mismatch, cross-route-replay, decimals-9, sol-native,
  cross-server-portability, idempotent-resubmit).
- harness/test/e2e.test.ts: comment update.
- .github/workflows/ruby.yml: job renamed to interop-ruby, drops
  PAY_KIT_INTEROP_SERVERS env (MPP_INTEROP_SERVERS=ruby,typescript
  enables the cross-server-portability pair), test name pattern
  switched to "ruby".
- harness/ruby-server/server.rb: ready payload `implementation` field
  is now "ruby" and the error-prefix strings stop saying
  "pay-kit-server".
- ruby/test/pay_kit/harness_adapter_test.rb: ADAPTER path and ready
  payload assertion follow the rename.

Local matrix: 17 scenarios still green (8 charge + 1 x402-exact + 2
cross-server + 1 idempotent + the 5 added in 340fb3b).
@lgalabru lgalabru merged commit 4e2bd55 into solana-foundation:main May 27, 2026
25 checks passed
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 27, 2026
…s/ruby-server)

The earlier harness/lua-server/server.lua was MPP-only. With the
resty.pay_kit umbrella + the 11-rule x402 verifier port now in
place, the Lua side gets the same dual-protocol harness adapter
shape Ruby ships: one binary, two settle paths, picked per scenario
by which env namespace the orchestrator sets (or the explicit
PAY_KIT_INTEROP_PROTOCOL hint that the matrix uses when both
X402_INTEROP_* and MPP_INTEROP_* are populated against the same
surfpool fixtures).

- harness/lua-server/server.lua            New dual-protocol adapter.
                                            Reads either MPP_INTEROP_*
                                            or X402_INTEROP_*; configures
                                            resty.pay_kit with the right
                                            operator/signer/secret;
                                            registers a single gate; runs
                                            a raw socket loop that routes
                                            GET /<resource> through
                                            pay_kit.try_payment. Honours
                                            *_INTEROP_SETTLEMENT_HEADER
                                            so per-scenario custom header
                                            names land on the wire.
- harness/lua-server/server.legacy.lua     The previous MPP-only adapter
                                            (370 LOC), preserved as a
                                            reference. Drop after the
                                            matrix's lua x402 step is
                                            green.

- harness/src/implementations.ts            `lua` impl now declares
                                            intents: ["charge",
                                            "x402-exact"], so the matrix
                                            will pair it against x402
                                            scenarios alongside MPP ones.
                                            Label updated to "Lua PayKit
                                            server (dual protocol)".

Local: both modes boot cleanly (verified via PAY_KIT_INTEROP_PROTOCOL
hint smoke tests); 449 tests pass / 0 fail in the Lua suite; luacheck
clean; harness pnpm typecheck clean.

Matrix wiring: with this adapter, MPP_INTEROP_SERVERS=lua +
MPP_INTEROP_INTENTS=charge,x402-exact in the cross-language matrix
will exercise Lua against both protocols end-to-end (mirrors the
Ruby pay-kit-server flow that landed via PR solana-foundation#138). The remaining
piece - adding ruby-style serverIds entries that include `lua` for
the negative-path scenarios (network-mismatch, cross-route-replay,
idempotent-resubmit) - is a focused follow-up once a lua matrix
scenario lands green.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 27, 2026
…oadcast

Phase 5 complete. Ports the x402 SVM-exact structural verifier
from lua/pay_kit/protocols/x402/exact/verify.lua (itself a port of
the Ruby gem at ruby/lib/x402/protocol/schemes/exact/verify.rb and
the Rust spine at rust/crates/x402/src/protocol/schemes/exact/verify.rs).

Raises {@see InvalidProofException} with the same canonical reject
strings the cross-language harness substring-matches against:

  1. Instruction count 3..=6
  2. ix[0] = ComputeBudget SetComputeUnitLimit
  3. ix[1] = ComputeBudget SetComputeUnitPrice <= MAX (50000)
  4. ix[2] = SPL TransferChecked
  5. Authority guard (no fee-payer in transfer auth)
  6. Mint match
  7. Destination ATA match (re-derived via Mints::deriveAta)
  8. Amount match (u64 LE at data offset 1)
  9. ix[3..6] in allowlist (memo + lighthouse + optional ATA-create)
 10. Memo binding (exactly one if extra.memo set)
 11. Token program strict bind to extra.tokenProgram

Schemes\\X402\\Adapter::verifyAndSettle now:
- decodes the PAYMENT-SIGNATURE base64+JSON envelope
- runs the identity-key match (scheme/network/asset/payTo +
  feePayer/tokenProgram/memo extras, matching Ruby PR solana-foundation#138's
  accepted_requirement_matches)
- runs the 11-rule Verifier
- cosigns the transaction with the operator's signer
- broadcasts via solana-php RpcClient::sendTransaction
- reserves the signature in the replay store (signature_consumed
  on duplicate submit)
- returns a Payment with the on-chain signature in
  transaction + settlementHeaders (PAYMENT-RESPONSE +
  x-payment-settlement-signature)

Delegated x402 mode (X402Config::$facilitatorUrl set) raises
InvalidProofException at adapter construction; the dispatcher
won't bind the adapter in that mode. Self-hosted is the only x402
path that ships in v1, matching Lua PR solana-foundation#141.
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