Ruby SDK: x402 upto (usage-based) scheme — payment-channel profile#192
Ruby SDK: x402 upto (usage-based) scheme — payment-channel profile#192EfeDurmaz16 wants to merge 18 commits into
Conversation
Greptile SummaryThis PR adds Ruby server support for x402 usage-based payment channels. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (17): Last reviewed commit: "fix(ruby): log channel id when post-reso..." | Re-trigger Greptile |
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.
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.
1a70543 to
2e49fb9
Compare
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.
| rescue | ||
| open.release! if open.respond_to?(:release!) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| expect(instruction, keys, OPEN_PAYER, payer, "payer") | ||
| expect(instruction, keys, OPEN_RENT_PAYER, operator, "rent_payer") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
| expect(instruction, keys, OPEN_PAYER, payer, "payer") | ||
| expect(instruction, keys, OPEN_RENT_PAYER, operator, "rent_payer") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| channel = fetch_channel(channel_id) | ||
| validate_channel!(channel, payer: payer, max: parsed[:max]) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| rescue => error | ||
| close_body(body) | ||
| return settle_error(error) |
There was a problem hiding this comment.
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.
|
@lgalabru converted to draft per request. will make it ready after we ship other parts ! |
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 theuptocell for Ruby in the compatibility matrix.Scope
payment-channelonly (the normative v1 profile from issue Add support for x402:upto #175).permitis out of scope (it needs an unshipped program).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_corechannel 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 thesettle_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.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 clientopen).getAccountInfoadded to the RPC client.server/upto.rb):verify_open(validate payload + the clientopen, broadcast, confirm, bind the on-chain channel) andsettle_actual(operator voucher, settle-and-finalize, distribute, refund). Mirrorsgo/protocols/x402/upto.go.pay_kit/usage.rb+usage/sinatra.rb): aChargemeter, a Rack settle-after middleware, and a Sinatrarequire_usageextension. 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.x402-uptobranch;ruby-x402-uptois registered against the matrix; a newharness-ruby-uptojob builds the payment-channels program withbuild-sbfand runs the Ruby server against the canonical Rust client onx402-upto-basic.Verification
standardrbclean,bundle-auditclean.COVERAGE=1 ... test/run.rb→ 520 runs, 0 failures; coverage 97% line / 90% branch (gate is 92/90). The newpay_corecode 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 ofprotocols/x402/.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 asgo-paykitandpython-x402-upto.Read order
ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb, wire shapes.../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)ruby/lib/pay_kit/protocols/x402/server/upto.rb, the engine: verify_open, broadcast/confirm, channel binding, settle_actual, orphan loggingruby/lib/pay_kit/usage.rb+usage/sinatra.rb, Rack middleware and its fail-closed policiesruby/lib/pay_core/solana/payment_channels.rb, the money bytes: PDAs, voucher preimage, ed25519 precompile, settle_and_finalizemessage_builder.rb,instruction.rb,rpc.rb, supporting primitivesruby/lib/pay_core/solana/generated/**, codama output, skimshared/borsh.rb+accounts/channel.rb, trust the restserver_upto_test.rb(36 cases incl. review-round regressions),usage_test.rb, golden vectorsimplementations.ts+ruby.yml, e2e wiringGenerated 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 plusverify_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; anexpiresAt >= 2^63wraps at voucher pack), the envelope decode prefers the legacy top-level shape over rust'saccepted-first,validAfter/nonceomission is tolerated, a blockhash RPC failure yields a 402 withoutrecentBlockhashwhere 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.