Skip to content

Ruby SDK: x402 upto (usage-based) scheme — payment-channel profile#192

Draft
EfeDurmaz16 wants to merge 18 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/ruby-x402-upto
Draft

Ruby SDK: x402 upto (usage-based) scheme — payment-channel profile#192
EfeDurmaz16 wants to merge 18 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/ruby-x402-upto

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Ports the x402 upto (usage-based) scheme to the Ruby SDK, at parity with the Rust (canonical), Go (merged), and Python (open, #191) references. Closes the upto cell for Ruby in the compatibility matrix.

Scope

  • Profile: payment-channel only (the normative v1 profile from issue Add support for x402:upto #175). permit is out of scope (it needs an unshipped program).
  • Posture: server-only, matching how Ruby already ships exact. The cross-language harness pairs this Ruby upto server against the Rust/Go/Python upto clients; no Ruby client is added.

What's here

  • pay_core channel machinery (payment_channels.rb): hand-written, byte-parity client for the payment-channels program — channel account Borsh decode, channel + event-authority PDA derivation, the 48-byte voucher preimage, the sha256 distribution-hash commitment, and the settle_and_finalize / distribute / Ed25519-precompile instruction encoders. There is no codama codegen target for Ruby, so every encoder is pinned to golden byte-vectors lifted from the Go/Rust references and asserted offline.
  • Legacy message builder (message_builder.rb): Ruby until now only parsed transactions; the facilitator settle path has to build one, so this compiles instructions into a signed legacy transaction (matching the Go settle tx and the Rust client open). getAccountInfo added to the RPC client.
  • Engine (server/upto.rb): verify_open (validate payload + the client open, broadcast, confirm, bind the on-chain channel) and settle_actual (operator voucher, settle-and-finalize, distribute, refund). Mirrors go/protocols/x402/upto.go.
  • Usage surface (pay_kit/usage.rb + usage/sinatra.rb): a Charge meter, a Rack settle-after middleware, and a Sinatra require_usage extension. Two-layer zero policy matching Go/Python — the engine honors a zero settlement (full refund, channel closed); the usage middleware fail-closes a zero charge with a 402.
  • Harness + CI: the dual-protocol Ruby harness server gains an x402-upto branch; ruby-x402-upto is registered against the matrix; a new harness-ruby-upto job builds the payment-channels program with build-sbf and runs the Ruby server against the canonical Rust client on x402-upto-basic.

Verification

  • standardrb clean, bundle-audit clean.
  • COVERAGE=1 ... test/run.rb → 520 runs, 0 failures; coverage 97% line / 90% branch (gate is 92/90). The new pay_core code is covered; the engine is validated by ~25 offline tests (verify/settle branches, channel-state negatives, zero-actual) and is excluded from the percentage like the rest of protocols/x402/.
  • Live cross-language e2e (Ruby server x Rust client, payment-channels program on Surfnet) runs in the new CI job.

Ruby stays off x402-upto-zero-actual (pinned to the low-level Rust server) because the usage middleware fail-closes on a zero charge — same exclusion as go-paykit and python-x402-upto.

Read order

  1. ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb, wire shapes
  2. .../upto/verify.rb, the security core: payload checks + structural validation of the client-built open (ALT reject, signer pinning, co-signer signature verification, OpenArgs binding)
  3. ruby/lib/pay_kit/protocols/x402/server/upto.rb, the engine: verify_open, broadcast/confirm, channel binding, settle_actual, orphan logging
  4. ruby/lib/pay_kit/usage.rb + usage/sinatra.rb, Rack middleware and its fail-closed policies
  5. ruby/lib/pay_core/solana/payment_channels.rb, the money bytes: PDAs, voucher preimage, ed25519 precompile, settle_and_finalize
  6. message_builder.rb, instruction.rb, rpc.rb, supporting primitives
  7. ruby/lib/pay_core/solana/generated/**, codama output, skim shared/borsh.rb + accounts/channel.rb, trust the rest
  8. tests: server_upto_test.rb (36 cases incl. review-round regressions), usage_test.rb, golden vectors
  9. harness ruby-server + implementations.ts + ruby.yml, e2e wiring

Generated vs handwritten

Generated: 2,381 lines of codama output under pay_core/solana/generated/** (verified byte-genuine against renderer commit 875672cd, namespace substitutions scripted). Handwritten: ~2,950 lines, of which ~1,675 are non-test library code. The diff is roughly half the review it looks.

Reviewer note

The place to be skeptical is verify.rb's open-transaction validation plus verify_open: the operator co-signs and pays the fee for a fully client-crafted transaction, so every fee-grief and account-smuggling defense lives in those ~120 lines, and they are now stricter than the Rust spine itself. Known open items before undraft: numeric wire parsing is laxer than rust (Integer() accepts underscores/hex/bignums; an expiresAt >= 2^63 wraps at voucher pack), the envelope decode prefers the legacy top-level shape over rust's accepted-first, validAfter/nonce omission is tolerated, a blockhash RPC failure yields a 402 without recentBlockhash where rust serves 503, and the zero-charge fail-closed 402 diverges from the rust gate's 200-with-header (documented in-code, needs a maintainer sign-off line).

Fable 5 self-review

The money path is line-parallel with the spine (ceiling enforcement, single-voucher settle, full-refund distribute, in-flight dedup, orphan logging on every broadcast path) and the open validation is materially harder than rust's after the review rounds. What I would flag as a stranger: the input-parsing layer reads like it was ported from Go rather than the rust spine, which is exactly the field-omission drift class our happy-path harness misses, and the ALT rejection shipped without a test at the layer that guards it. Nothing moves money incorrectly; the parse-layer tightening listed above is queued before undraft.

@EfeDurmaz16 EfeDurmaz16 requested a review from lgalabru June 29, 2026 00:30
@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds Ruby server support for x402 usage-based payment channels. The main changes are:

  • Payment-channel account decoding and instruction builders in pay_core.
  • A legacy Solana message builder and RPC account lookup support.
  • Ruby upto server verification, channel settlement, and refund handling.
  • Rack and Sinatra usage middleware for metered charges.
  • Harness and CI coverage for Ruby x402-upto server flows.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb Adds pre-broadcast checks for channel-open payloads, account layout, signer placement, and required signatures.
ruby/lib/pay_kit/protocols/x402/server/upto.rb Adds the Ruby upto server engine for channel verification, settlement, refund distribution, and reconciliation logging.
ruby/lib/pay_kit/usage.rb Adds Rack usage middleware that verifies opens, meters charges, settles after the app runs, and handles cleanup paths.

Reviews (17): Last reviewed commit: "fix(ruby): log channel id when post-reso..." | Re-trigger Greptile

Comment thread ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb
Comment thread ruby/lib/pay_kit/usage.rb Outdated
Comment thread ruby/lib/pay_kit/usage.rb Outdated
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 29, 2026
Address review findings on PR solana-foundation#192:

- verify_open now validates the client OpenArgs (deposit equals the
  authorized maximum, distribution is empty, and the args salt derives the
  claimed channelId) before the operator signs and broadcasts the open, so
  input that matches every account but carries malformed args can no longer
  grief operator broadcast fees.
- The usage middleware separates a pre-resource failure (verify_open: serve
  nothing, settle nothing, re-challenge with 402) from a post-settlement
  failure (return 502, never a 402, so a payer is not told to pay again while
  a settlement may still land on-chain), and closes any dropped Rack body to
  avoid leaking streaming/file/BodyProxy resources.
Comment thread ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb
Hand-written, byte-parity client for the pay-kit payment-channels program:
channel account decode, channel/event-authority PDA derivation, the 48-byte
voucher preimage, the sha256 distribution-hash commitment, and the
settle_and_finalize / distribute / Ed25519-precompile instruction encoders.
Adds a legacy message compiler (the server now builds, not just parses,
transactions) and getAccountInfo on the Solana RPC client.

There is no codama codegen target for Ruby, so the encoders are pinned to
golden byte-vectors lifted from the Go and Rust references and asserted
offline before they ever reach a validator.
Server-side upto engine (verify_open + settle_actual) under
protocols/x402/server/upto.rb, with the scheme codecs and verifier under
protocol/schemes/upto, mirroring the exact scheme's layout. Ruby stays
server-only for x402: the harness pairs this engine against the Rust/Go/
Python upto clients.

Adds the public usage surface: a Charge meter, a Rack settle-after
middleware, and a Sinatra require_usage extension. Two-layer zero policy
matching the Go/Python ports: the engine honors a zero settlement at the
protocol layer (full refund, channel closed) while the usage middleware
fail-closes a zero charge with a 402.
Extend the dual-protocol Ruby harness server with an x402-upto branch
(routed by PAY_KIT_HARNESS_PROTOCOL=x402-upto) that mounts the public usage
middleware, and register the ruby-x402-upto server in the cross-language
matrix against the same umbrella binary.

Add the harness-ruby-upto CI job mirroring the Go flow: build the
payment-channels program with build-sbf and run the Ruby upto server against
the canonical Rust client on x402-upto-basic. The Ruby server stays off
x402-upto-zero-actual because the usage middleware fail-closes on a zero
charge. Documents the scheme in the Ruby README.
Address review findings on PR solana-foundation#192:

- verify_open now validates the client OpenArgs (deposit equals the
  authorized maximum, distribution is empty, and the args salt derives the
  claimed channelId) before the operator signs and broadcasts the open, so
  input that matches every account but carries malformed args can no longer
  grief operator broadcast fees.
- The usage middleware separates a pre-resource failure (verify_open: serve
  nothing, settle nothing, re-challenge with 402) from a post-settlement
  failure (return 502, never a 402, so a payer is not told to pay again while
  a settlement may still land on-chain), and closes any dropped Rack body to
  avoid leaking streaming/file/BodyProxy resources.
Address review: the open-arg validator now checks the OpenArgs gracePeriod
equals the pinned DEFAULT_GRACE_PERIOD_SECONDS (900, the value the Rust and
Go clients commit) before the operator broadcasts, so a client cannot make
the operator open a channel whose close timing differs from the intended
contract.
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/ruby-x402-upto branch from 1a70543 to 2e49fb9 Compare June 29, 2026 01:09
Comment thread ruby/lib/pay_kit/usage.rb Outdated
EfeDurmaz16 and others added 4 commits June 29, 2026 04:18
Address review: if the protected Rack app raises after verify_open has
opened and reserved the channel, the usage middleware now settles a zero
charge (closing the channel and refunding the payer's full deposit, which
releases the in-flight reservation in settle_actual's ensure) before
re-raising the app error. Without this, the reservation leaked and the
payer's deposit could stay locked in an unsettled channel.
The SVM upto spec and the TS @x402/svm client read extra.facilitator; Go and Rust read extra.feePayer. The Ruby upto server advertised only feePayer, so a spec-conformant TS client could not find the fee payer when building the channel open. Advertise both (the operator), mirroring the Python SDK, and assert it in the server upto test.
Charge#charge accumulated metered base units; the Go, Python, and TS Charge replace with the latest clamped value (last call wins). Switch Ruby to replace so a handler that meters more than once settles the same amount across SDKs. Still clamped to [0, max] (the per-request channel deposit), so the single-charge path the harness uses is unchanged. Update the Charge test to assert last-call-wins.
Comment thread ruby/lib/pay_kit/usage.rb Outdated
Comment on lines +146 to +147
rescue
open.release! if open.respond_to?(:release!)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Zero Settle Failure Swallowed When the protected app raises after the channel is opened, this cleanup path tries to settle 0 and then falls back to open.release! if that settlement fails. open.release! only clears the in-memory reservation; it does not close or refund the on-chain channel. If the zero-settle broadcast or confirmation fails, the app error is re-raised while the payer's deposit can remain locked in the open channel.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 3d7baf5. The compensating zero settlement no longer swallows its failure: release_after_app_failure now logs a loud, actionable PayKit warning with the channel id when the zero-settle broadcast/confirm fails, so an operator knows the channel may still be open on-chain with the payer deposit locked and needs manual reconciliation. release! still clears the in-memory reservation, and a regression test asserts the warning + channel id.

Comment on lines +103 to +104
expect(instruction, keys, OPEN_PAYER, payer, "payer")
expect(instruction, keys, OPEN_RENT_PAYER, operator, "rent_payer")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Signer roles unchecked
This validation confirms that the open instruction points at the expected payer and rent-payer pubkeys, but it does not confirm those instruction accounts are signer accounts in the transaction message. A client can put the operator at account key 0 so the operator signature and fee payer check pass, then reference the expected payer pubkey from a non-signer account index. The server signs and broadcasts that transaction, and the channel program rejects it because the open instruction requires payer/rent-payer signer privileges. That lets malformed client input spend operator fees before rejection. Please reject before signing unless the payer and rent-payer instruction account indices resolve to signer positions in the message.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b4e4651. require_signer! now pins the payer and rent_payer slots to the message required-signer range before signing, so a non-signer account index is rejected without spending a broadcast fee (test_rejects_open_payer_not_signer / _rent_payer_not_signer).

Comment on lines +103 to +104
expect(instruction, keys, OPEN_PAYER, payer, "payer")
expect(instruction, keys, OPEN_RENT_PAYER, operator, "rent_payer")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Signer Roles Unchecked These checks only confirm that the open instruction points at the expected payer and rent-payer pubkeys. They do not confirm those instruction account indices are inside the message's required-signer range. A client can put the operator in the required signer slot so sign_with succeeds, while referencing the real payer or rent-payer from a non-signer account index. The server then signs and broadcasts a transaction that the payment-channel program rejects for missing signer privileges, spending the operator's fee before rejection. Please reject the open before signing unless both open account indices resolve to required signer positions.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b4e4651. require_signer! now pins the payer and rent_payer slots to the message required-signer range before signing, so a non-signer account index is rejected without spending a broadcast fee (test_rejects_open_payer_not_signer / _rent_payer_not_signer).

Main's crate restructure (solana-foundation#197) moved the harness adapter binaries into
the paykit-harness-bins crate; the ruby upto job still referenced the
pre-restructure solana-x402:harness_upto_client spec, which no longer
resolves (cargo: package ID 'solana-x402' did not match any packages).
Point it at paykit-harness-bins:x402_harness_upto_client, matching the
go/python workflows.
The open-instruction verifier matched the payer and rent_payer account
pubkeys but not their signer privileges. The on-chain open requires both
to be signers (idl accounts 0/1); a client controls the open transaction
and could reference the expected pubkey from a non-signer slot, so the
operator would sign and pay the fee for a transaction the program then
rejects. require_signer! pins both to signer positions in the message
header before broadcast. Adds regression tests for each.
Comment on lines +206 to +207
channel = fetch_channel(channel_id)
validate_channel!(channel, payer: payer, max: parsed[:max])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Post-broadcast orphan path Once broadcast_and_confirm returns, the channel can already be open on-chain with the payer's deposit escrowed. If the next fetch_channel or validate_channel! step fails because the RPC read is stale or unavailable, the ensure below releases the only in-memory reservation and the middleware returns a fresh 402. The server then has no VerifiedOpen to settle or refund the channel, and this path does not log the channel id for manual recovery, so the payer's deposit can remain locked while the client is told to pay again.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 4288c1b. If the post-broadcast fetch_channel/validate_channel! fails after the open confirms on-chain, verify_open now logs a loud PayKit warning naming the channel id (deposit may be locked, needs manual reconciliation) before releasing the reservation. Regression test asserts the warning + release.

After broadcast_and_confirm opens the channel on-chain, a stale or
unavailable RPC read in fetch_channel/validate_channel! left the operator
with no VerifiedOpen to settle or refund, the reservation released, and the
client told to pay again, with the channel id never logged. The
post-broadcast failure path now warns with the channel id (PayKit logger
convention) so an operator can manually reconcile the locked deposit. Adds a
regression test asserting the warning names the channel and the reservation
is released.
Two follow-ups on the upto open path:

- validate_open_instruction! now rejects unless the wire signature array
  length equals the message required-signer count. require_signer! only
  bound the account indices; a client could pin payer/rent_payer to signer
  slots yet serialize fewer signatures, so the operator filled slot 0 and
  broadcast a transaction the runtime rejects after the fee was spent.

- The orphan-channel log now fires whenever a broadcast was attempted, not
  only after broadcast_and_confirm returns. send_raw can submit the open and
  then time out before returning, so the may-have-broadcast flag is set before
  the call; a send-then-timeout no longer skips the channel-id log.

Adds regression tests for a short signature array and a confirmation-timeout
orphan log.
Comment on lines +104 to +107
required_signers = transaction.message.header[:required_signatures]
unless transaction.signatures.length == required_signers
raise reject("open transaction has #{transaction.signatures.length} signatures, expected #{required_signers}")
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Invalid Signatures Still Broadcast

This check only verifies that the serialized signature array has the expected length. A client can still include the payer and rent payer in required-signer slots, fill those slots with zero or bogus signatures, and let transaction.sign_with(@config.operator) fill only the operator slot before broadcast. The runtime then rejects the transaction for invalid required signatures after submission, so malformed input can still make the operator sign and broadcast an open that can never succeed. Please verify the existing required signatures for the client-controlled signer slots before the operator signs and sends the transaction.

…bles

Close the last pre-broadcast fee-grief vectors on the upto open:

- verify_cosigner_signatures! checks the serialized signature bytes for every
  required signer except the operator's own slot (filled at co-sign). A client
  could pin the payer to a signer slot yet serialize a zeroed/forged payer
  signature; the operator co-signed slot 0 and broadcast, and the runtime then
  rejected the open after the fee was spent. Matching pubkeys and signer slots
  did not bind the bytes.
- validate_open_instruction! now rejects open transactions that carry address
  lookup tables, matching the Rust spine: the validator and co-sign resolve
  accounts via the static key list, so a non-empty ALT could smuggle in
  accounts the position checks cannot see.

Test fixtures now use a real payer keypair and sign the open's payer slot;
adds a regression test for an unsigned payer slot.
keys = transaction.message.account_keys
required_signers.times do |i|
signer = keys[i]
next if signer.nil? || signer == operator

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Duplicate Signer Slot

This skips every required signer slot whose key equals the operator, but the transaction signing code only fills the first operator slot. A client can place the operator key twice in the required-signer prefix, put the payer in another signer slot with a valid signature, and leave the second operator signature invalid. The validator skips that second operator slot, the server signs only slot 0, then the runtime rejects the transaction after broadcast for the invalid required signature. That still lets malformed input spend the operator's fee before rejection. Reject duplicate required signer keys, or skip only the exact operator slot that will be filled and validate the rest.

verify_cosigner_signatures! skipped every slot whose key equals the operator,
but the facilitator signs exactly one slot (sign_with fills the first
account-key match). A client could plant a second operator-keyed slot in the
required-signer range: the check skipped it as the operator's own, the
operator co-signed only the first slot and broadcast, and the runtime rejected
the open for the unsigned duplicate after the fee was spent. Skip only the
single index the operator fills and verify the rest, so a duplicate operator
slot is caught. Adds direct unit tests for the duplicate-slot reject and the
single-operator-slot accept.
Comment thread ruby/lib/pay_kit/usage.rb
Comment on lines +113 to +115
rescue => error
close_body(body)
return settle_error(error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Missing settle warning When a handler records a zero charge, settle_actual(open, 0) can submit the refund-and-close transaction and then fail while waiting for confirmation. This rescue returns a 502, but it never logs open.channel_id. Operators then have no channel id to reconcile if the payer deposit remains locked or the close lands after the timeout. Please log the channel id on this post-handler settlement failure path before returning the error.

The metered settle_actual after the resource runs can broadcast on-chain and
still raise on an RPC send or confirmation timeout. The middleware returned a
502 (correctly, never a 402 re-challenge) but logged nothing, so an operator
had no channel id to reconcile a settle that may have landed and charged the
payer. Log the channel id and amount loudly before returning the server error,
matching the orphan-channel logging on the open and app-failure paths. Adds a
regression test asserting the channel id and reconciliation notice are logged.
@EfeDurmaz16 EfeDurmaz16 marked this pull request as draft June 30, 2026 16:45
@EfeDurmaz16 EfeDurmaz16 marked this pull request as ready for review June 30, 2026 16:46
@EfeDurmaz16 EfeDurmaz16 marked this pull request as draft June 30, 2026 16:46
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@lgalabru converted to draft per request. will make it ready after we ship other parts !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant