From 0e0ab9ed09e24e16a83f2789a49f8adf769205a8 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:29:11 +0300 Subject: [PATCH 01/17] feat(ruby): payment-channels client and legacy message builder 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. --- ruby/lib/pay_core.rb | 3 + ruby/lib/pay_core/solana/instruction.rb | 37 +++ ruby/lib/pay_core/solana/message_builder.rb | 94 ++++++ ruby/lib/pay_core/solana/payment_channels.rb | 280 ++++++++++++++++++ ruby/lib/pay_core/solana/rpc.rb | 20 ++ ruby/test/pay_core/instruction_test.rb | 35 +++ ruby/test/pay_core/message_builder_test.rb | 89 ++++++ ruby/test/pay_core/payment_channels_test.rb | 236 +++++++++++++++ .../pay_core/rpc_get_account_info_test.rb | 49 +++ 9 files changed, 843 insertions(+) create mode 100644 ruby/lib/pay_core/solana/instruction.rb create mode 100644 ruby/lib/pay_core/solana/message_builder.rb create mode 100644 ruby/lib/pay_core/solana/payment_channels.rb create mode 100644 ruby/test/pay_core/instruction_test.rb create mode 100644 ruby/test/pay_core/message_builder_test.rb create mode 100644 ruby/test/pay_core/payment_channels_test.rb create mode 100644 ruby/test/pay_core/rpc_get_account_info_test.rb diff --git a/ruby/lib/pay_core.rb b/ruby/lib/pay_core.rb index 01f05fb58..597706391 100644 --- a/ruby/lib/pay_core.rb +++ b/ruby/lib/pay_core.rb @@ -22,6 +22,9 @@ require_relative "pay_core/solana/ata" require_relative "pay_core/solana/account" require_relative "pay_core/solana/transaction" +require_relative "pay_core/solana/instruction" +require_relative "pay_core/solana/message_builder" +require_relative "pay_core/solana/payment_channels" require_relative "pay_core/solana/rpc" module PayCore diff --git a/ruby/lib/pay_core/solana/instruction.rb b/ruby/lib/pay_core/solana/instruction.rb new file mode 100644 index 000000000..a3a4f052a --- /dev/null +++ b/ruby/lib/pay_core/solana/instruction.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module PayCore + module Solana + # A single account reference inside a compiled instruction, carrying the + # signer / writable roles the legacy message compiler folds into the + # message header. `pubkey` is a base58 String; the builders in + # `PaymentChannels` emit these and `MessageBuilder` consumes them. + AccountMeta = Struct.new(:pubkey, :signer, :writable) do + def self.signer_writable(pubkey) + new(pubkey, true, true) + end + + def self.signer_readonly(pubkey) + new(pubkey, true, false) + end + + def self.writable(pubkey) + new(pubkey, false, true) + end + + def self.readonly(pubkey) + new(pubkey, false, false) + end + end + + # An un-compiled Solana instruction: the program it targets (base58 + # `program_id`), its ordered `accounts` (each an `AccountMeta`), and the + # raw binary instruction `data`. `MessageBuilder` turns a list of these + # into a signed legacy transaction. Mirrors the shape the Rust/Go SDKs + # feed their message compilers (`solana::Instruction`). + # + # Distinct from `Transaction`'s `Instruction`, which is the already-compiled + # form (program-id INDEX + account INDEXES) produced when parsing wire bytes. + PreparedInstruction = Struct.new(:program_id, :accounts, :data) + end +end diff --git a/ruby/lib/pay_core/solana/message_builder.rb b/ruby/lib/pay_core/solana/message_builder.rb new file mode 100644 index 000000000..9b4821f3c --- /dev/null +++ b/ruby/lib/pay_core/solana/message_builder.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require_relative "base58" +require_relative "instruction" +require_relative "transaction" + +module PayCore + module Solana + # Compiles a list of `Instruction`s into an unsigned legacy Solana + # transaction. The x402 `upto` settle path (settle_and_finalize + ATA + # creates + distribute) is built and broadcast by the facilitator, so Ruby + # — which until now only parsed transactions — needs a message compiler. + # + # Legacy (not v0) to match the references: the Go settle tx uses + # `solana.NewTransaction` (legacy) and the Rust client's `open` tx uses + # `Message::new_with_blockhash` (legacy). The compiled key ordering only has + # to be internally consistent (instructions index into the key table); it is + # not byte-pinned across SDKs, so we follow the canonical compaction rule: + # writable-signers, readonly-signers, writable-nonsigners, readonly-nonsigners, + # fee payer first. + module MessageBuilder + module_function + + # Build an unsigned legacy `Transaction` paying `fee_payer` (base58) over + # `recent_blockhash` (base58) executing `instructions`. The returned + # transaction has zeroed signature slots; the caller fills them with + # `Transaction#sign_with` for each required signer. + def build_legacy(fee_payer:, recent_blockhash:, instructions:) + roles = collect_roles(fee_payer, instructions) + ordered = order_accounts(roles) + index_of = ordered.each_with_index.to_h { |pubkey, index| [pubkey, index] } + + header = [ + roles.count { |_pubkey, role| role[:signer] }, + roles.count { |_pubkey, role| role[:signer] && !role[:writable] }, + roles.count { |_pubkey, role| !role[:signer] && !role[:writable] } + ] + + message = header.pack("C3") + message += Transaction.compact_u16(ordered.length) + message += ordered.map { |pubkey| Base58.decode(pubkey) }.join + message += Base58.decode(recent_blockhash) + message += Transaction.compact_u16(instructions.length) + instructions.each do |instruction| + message += [index_of.fetch(instruction.program_id)].pack("C") + message += Transaction.compact_u16(instruction.accounts.length) + message += instruction.accounts.map { |meta| index_of.fetch(meta.pubkey) }.pack("C*") + message += Transaction.compact_u16(instruction.data.bytesize) + message += instruction.data + end + + signature_count = header.first + unsigned = Transaction.compact_u16(signature_count) + ("\x00".b * 64 * signature_count) + message + Transaction.from_bytes(unsigned) + end + + # Merge every account reference (and program id) into role flags, + # preserving first-seen order. The fee payer is seeded first as a + # writable signer so it compiles to account index 0. + def collect_roles(fee_payer, instructions) + roles = {} + upsert(roles, fee_payer, signer: true, writable: true) + instructions.each do |instruction| + instruction.accounts.each do |meta| + upsert(roles, meta.pubkey, signer: meta.signer, writable: meta.writable) + end + # A program id is a readonly, non-signer account; `upsert` keeps any + # stronger role the same key already earned as a writable/signer. + upsert(roles, instruction.program_id, signer: false, writable: false) + end + roles + end + + def upsert(roles, pubkey, signer:, writable:) + role = roles[pubkey] ||= {signer: false, writable: false} + role[:signer] ||= signer + role[:writable] ||= writable + end + + def order_accounts(roles) + buckets = {sw: [], sr: [], nw: [], nr: []} + roles.each do |pubkey, role| + bucket = if role[:signer] + role[:writable] ? :sw : :sr + else + role[:writable] ? :nw : :nr + end + buckets[bucket] << pubkey + end + buckets[:sw] + buckets[:sr] + buckets[:nw] + buckets[:nr] + end + end + end +end diff --git a/ruby/lib/pay_core/solana/payment_channels.rb b/ruby/lib/pay_core/solana/payment_channels.rb new file mode 100644 index 000000000..451911502 --- /dev/null +++ b/ruby/lib/pay_core/solana/payment_channels.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +require "digest" + +require_relative "base58" +require_relative "public_key" +require_relative "ata" +require_relative "mints" +require_relative "instruction" + +module PayCore + module Solana + # Hand-written, byte-identical client for the pay-kit payment-channels + # program — the SVM `upto` (usage-based) settlement primitive. Ruby has no + # codama codegen target, so the channel account decoder, PDA derivations, + # voucher preimage, distribution-hash commitment, and the settle / + # distribute / Ed25519-precompile instruction encoders are written here by + # hand and pinned to golden byte-vectors lifted from the Go/Rust references + # (`go/paycore/paymentchannels/*`, `rust/crates/core/src/payment_channels.rs`). + # + # The bytes emitted here MUST stay identical across the language SDKs or the + # on-chain program rejects them; the offline parity tests are the guard, the + # live Surfpool e2e matrix is the proof. + module PaymentChannels + module_function + + # Canonical mainnet program id; matches the codama-generated default and + # every PDA derivation pinned to it (go/paycore/paymentchannels/ + # paymentchannels.go:27). The issue draft's `GuoKrza…` is stale. + PROGRAM_ID = "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX" + + # Ed25519 signature-verification native precompile (settlement.go:22). + ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111" + # Instructions sysvar the settle_and_finalize ix reads the voucher from. + INSTRUCTIONS_SYSVAR = "Sysvar1nstructions1111111111111111111111111" + RENT_SYSVAR = "SysvarRent111111111111111111111111111111111" + SYSTEM_PROGRAM = "11111111111111111111111111111111" + ASSOCIATED_TOKEN_PROGRAM = Mints::ASSOCIATED_TOKEN_PROGRAM + + # Treasury owner baked into the deployed (mainnet-build) program; + # distribute checks the treasury ATA against ATA(TreasuryOwner, mint, + # token_program) (settlement.go:35-37). + TREASURY_OWNER = "Cs2zdfUNonRdRGsiZUQQLdTxzxVvJZmgiX2mpLYKuEqP" + + CHANNEL_SEED = "channel" + EVENT_AUTHORITY_SEED = "event_authority" + + # One-byte instruction discriminators (idl/payment-channels.json + + # go/protocols/programs/paymentchannels/instruction_*.go). + DISCRIMINATOR_SETTLE_AND_FINALIZE = 4 + DISCRIMINATOR_DISTRIBUTE = 7 + + # Channel.status enum (idl channelStatus): 0 = open, 1 = finalized, 2 = closing. + STATUS_OPEN = 0 + + # Borsh `Channel` account size: a 1-byte account discriminator followed by + # the fixed fields below. The on-chain account keeps the discriminator at + # offset 0 (unlike the codama-py dropped-discriminator quirk), so Ruby + # decodes from offset 0. + CHANNEL_ACCOUNT_SIZE = 248 + + # sha256 over the empty distribution preimage (u32 LE count = 0). Pinned + # to the Go `EmptyDistributionHash` constant (protocols/x402/upto.go:794); + # `distribution_hash([])` reproduces it. + EMPTY_DISTRIBUTION_HASH = [ + 223, 63, 97, 152, 4, 169, 47, 219, 64, 87, 25, 45, 196, 61, 215, 72, + 234, 119, 138, 220, 82, 188, 73, 140, 232, 5, 36, 192, 20, 184, 17, 25 + ].pack("C*").freeze + + # Decoded payment-channel account. Only the fields the facilitator checks + # at verify_open + settle are surfaced; the byte offsets are the verified + # Borsh layout (idl/payment-channels.json `channel`). + Channel = Struct.new( + :discriminator, :version, :bump, :status, :salt, :deposit, + :settled, :payout_watermark, :closure_started_at, :payer_withdrawn_at, + :grace_period, :distribution_hash, :payer, :payee, :authorized_signer, + :mint, :rent_payer, keyword_init: true + ) + + # ---- little-endian Borsh primitives ---------------------------------- + def u16_le(value) = [value].pack("v") + + def u32_le(value) = [value].pack("V") + + def u64_le(value) = [value].pack("Q<") + + def i64_le(value) = [value].pack("q<") + + def read_u32_le(bytes, offset) = bytes.byteslice(offset, 4).unpack1("V") + + def read_u64_le(bytes, offset) = bytes.byteslice(offset, 8).unpack1("Q<") + + def read_i64_le(bytes, offset) = bytes.byteslice(offset, 8).unpack1("q<") + + # ---- PDA derivations ------------------------------------------------- + # Derive the channel PDA from + # ["channel", payer, payee, mint, authorizedSigner, salt(u64 LE)] against + # the channel program (paymentchannels.go:170-188). Returns base58. + def find_channel_pda(payer:, payee:, mint:, authorized_signer:, salt:, program_id: PROGRAM_ID) + seeds = [ + CHANNEL_SEED.b, + Base58.decode(payer), + Base58.decode(payee), + Base58.decode(mint), + Base58.decode(authorized_signer), + u64_le(salt) + ] + PublicKey.find_program_address(seeds, program_id).first.to_s + end + + # Derive the event-authority PDA from ["event_authority"] + # (paymentchannels.go:198-207). Returns base58. + def find_event_authority_pda(program_id: PROGRAM_ID) + PublicKey.find_program_address([EVENT_AUTHORITY_SEED.b], program_id).first.to_s + end + + # ---- voucher + distribution commitment ------------------------------- + # 48-byte voucher preimage signed by the authorized signer: + # channelId(32) || cumulativeAmount(u64 LE) || expiresAt(i64 LE). + # Exactly the Borsh VoucherArgs layout (paymentchannels.go:149-159). + def voucher_message_bytes(channel_id, cumulative, expires_at) + Base58.decode(channel_id) + u64_le(cumulative) + i64_le(expires_at) + end + + # sha256 of the distribution preimage + # count(u32 LE) || [recipient(32) || bps(u16 LE)]…, byte-for-byte what the + # on-chain program commits at open (rust payment_channels.rs:244-257). + # MUST stay sha256: the program rejects a mismatch with + # InvalidDistributionHash. `recipients` is an Array of + # `{recipient: base58, bps: Integer}`. + def distribution_hash(recipients) + preimage = u32_le(recipients.length) + recipients.each do |entry| + preimage += Base58.decode(entry.fetch(:recipient)) + u16_le(entry.fetch(:bps)) + end + Digest::SHA256.digest(preimage) + end + + # ---- account decode -------------------------------------------------- + # Decode a payment-channel account from raw on-chain bytes. + def decode_channel(data) + unless data.is_a?(String) && data.bytesize >= CHANNEL_ACCOUNT_SIZE + raise ArgumentError, "channel account is #{data.respond_to?(:bytesize) ? data.bytesize : "?"} bytes, want >= #{CHANNEL_ACCOUNT_SIZE}" + end + + Channel.new( + discriminator: data.getbyte(0), + version: data.getbyte(1), + bump: data.getbyte(2), + status: data.getbyte(3), + salt: read_u64_le(data, 4), + deposit: read_u64_le(data, 12), + settled: read_u64_le(data, 20), + payout_watermark: read_u64_le(data, 28), + closure_started_at: read_i64_le(data, 36), + payer_withdrawn_at: read_i64_le(data, 44), + grace_period: read_u32_le(data, 52), + distribution_hash: data.byteslice(56, 32), + payer: Base58.encode(data.byteslice(88, 32)), + payee: Base58.encode(data.byteslice(120, 32)), + authorized_signer: Base58.encode(data.byteslice(152, 32)), + mint: Base58.encode(data.byteslice(184, 32)), + rent_payer: Base58.encode(data.byteslice(216, 32)) + ) + end + + # ---- instruction encoders -------------------------------------------- + # Ed25519 precompile instruction verifying `signature` over `message` + # against `authorized_signer`, with the signature material embedded in + # the instruction data (every instruction-index field is 0xFFFF, "current + # instruction"). Fixed header: pubkey @16, signature @48, message @112 + # (settlement.go:45-69). + def ed25519_verify_instruction(authorized_signer, signature, message) + public_key_offset = 16 + signature_offset = public_key_offset + 32 # 48 + message_data_offset = signature_offset + 64 # 112 + current_instruction = 0xFFFF + + if message.bytesize > 0xFFFF + raise ArgumentError, "voucher message too long: #{message.bytesize} bytes" + end + + header = [ + 1, 0, # num_signatures, padding + signature_offset, current_instruction, + public_key_offset, current_instruction, + message_data_offset, message.bytesize, current_instruction + ].pack("CCv7") + + data = header + Base58.decode(authorized_signer) + signature + message + PreparedInstruction.new(ED25519_PROGRAM, [], data) + end + + # Build the settle_and_finalize sequence. When a voucher signature is + # present an Ed25519 precompile instruction over the canonical 48-byte + # voucher message precedes settle_and_finalize (which reads it through the + # instructions sysvar) and hasVoucher = 1; otherwise a single + # voucherless settle_and_finalize closes the channel with a full refund + # (settlement.go:155-188). + def settle_and_finalize_instructions(merchant:, channel:, authorized_signer:, signature:, cumulative:, expires_at:, program_id: PROGRAM_ID) + instructions = [] + has_voucher = 0 + unless signature.nil? + message = voucher_message_bytes(channel, cumulative, expires_at) + instructions << ed25519_verify_instruction(authorized_signer, signature, message) + has_voucher = 1 + end + + # disc(4) || SettleAndFinalizeArgs{ hasVoucher: u8 }. + settle = PreparedInstruction.new( + program_id, + [ + AccountMeta.signer_readonly(merchant), + AccountMeta.writable(channel), + AccountMeta.readonly(INSTRUCTIONS_SYSVAR) + ], + [DISCRIMINATOR_SETTLE_AND_FINALIZE, has_voucher].pack("C2") + ) + instructions << settle + instructions + end + + # Build the distribute instruction: the 11 fixed accounts in the exact + # order the program expects, plus one writable token account per split + # recipient. `upto` settles empty-recipient channels, so `recipients` + # defaults to none — the payer is refunded the unsettled remainder and the + # payee/treasury receive the settled split (settlement.go:230-296). + def distribute_instruction(channel:, payer:, rent_payer:, payee:, mint:, token_program:, treasury: TREASURY_OWNER, recipients: [], program_id: PROGRAM_ID) + channel_token = ATA.derive(owner: channel, mint: mint, token_program: token_program) + payer_token = ATA.derive(owner: payer, mint: mint, token_program: token_program) + payee_token = ATA.derive(owner: payee, mint: mint, token_program: token_program) + treasury_token = ATA.derive(owner: treasury, mint: mint, token_program: token_program) + event_authority = find_event_authority_pda(program_id: program_id) + + accounts = [ + AccountMeta.writable(channel), + AccountMeta.writable(payer), + AccountMeta.writable(rent_payer), + AccountMeta.writable(channel_token), + AccountMeta.writable(payer_token), + AccountMeta.writable(payee_token), + AccountMeta.writable(treasury_token), + AccountMeta.readonly(mint), + AccountMeta.readonly(token_program), + AccountMeta.readonly(event_authority), + AccountMeta.readonly(program_id) + ] + + data = [DISCRIMINATOR_DISTRIBUTE].pack("C") + u32_le(recipients.length) + recipients.each do |entry| + recipient = entry.fetch(:recipient) + accounts << AccountMeta.writable(ATA.derive(owner: recipient, mint: mint, token_program: token_program)) + data += Base58.decode(recipient) + u16_le(entry.fetch(:bps)) + end + + PreparedInstruction.new(program_id, accounts, data) + end + + # Idempotent associated-token-account create. The settle transaction + # creates the payee + treasury ATAs before distribute so the transfer + # targets exist; idempotent (data = [1]) so an already-present ATA is a + # no-op rather than a failure (solanatx.go:73-91). + def create_idempotent_ata_instruction(payer:, owner:, mint:, token_program:) + ata = ATA.derive(owner: owner, mint: mint, token_program: token_program) + PreparedInstruction.new( + ASSOCIATED_TOKEN_PROGRAM, + [ + AccountMeta.signer_writable(payer), + AccountMeta.writable(ata), + AccountMeta.readonly(owner), + AccountMeta.readonly(mint), + AccountMeta.readonly(SYSTEM_PROGRAM), + AccountMeta.readonly(token_program) + ], + [1].pack("C") + ) + end + end + end +end diff --git a/ruby/lib/pay_core/solana/rpc.rb b/ruby/lib/pay_core/solana/rpc.rb index f0efd1144..3fcf53783 100644 --- a/ruby/lib/pay_core/solana/rpc.rb +++ b/ruby/lib/pay_core/solana/rpc.rb @@ -106,6 +106,26 @@ def account_owner(pubkey) value["owner"] end + # Fetch full account info (base64-encoded). Returns a Hash with the + # decoded binary `:data`, the `:owner` program id, and `:lamports`, or + # nil when the account does not exist. The x402 `upto` facilitator reads + # the on-chain channel account through this to confirm the open. + def get_account_info(pubkey) + value = call("getAccountInfo", [ + pubkey, + {"encoding" => "base64", "commitment" => "confirmed"} + ]).fetch("value") + return nil if value.nil? + + encoded = value["data"] + encoded = encoded.first if encoded.is_a?(Array) + { + data: (encoded.nil? || encoded.empty?) ? "".b : Base64.strict_decode64(encoded), + owner: value["owner"], + lamports: value["lamports"] + } + end + # Fetch a confirmed transaction by signature using base64 encoding. def transaction_base64(signature) call("getTransaction", [ diff --git a/ruby/test/pay_core/instruction_test.rb b/ruby/test/pay_core/instruction_test.rb new file mode 100644 index 000000000..036913b84 --- /dev/null +++ b/ruby/test/pay_core/instruction_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +class InstructionTest < Minitest::Test + AM = ::PayCore::Solana::AccountMeta + PI = ::PayCore::Solana::PreparedInstruction + + def test_account_meta_factories + assert_equal [true, true], role(AM.signer_writable("k")) + assert_equal [true, false], role(AM.signer_readonly("k")) + assert_equal [false, true], role(AM.writable("k")) + assert_equal [false, false], role(AM.readonly("k")) + end + + def test_account_meta_carries_pubkey + meta = AM.writable("SomeKey") + assert_equal "SomeKey", meta.pubkey + end + + def test_prepared_instruction_fields + accounts = [AM.writable("a")] + ix = PI.new("program", accounts, "data".b) + + assert_equal "program", ix.program_id + assert_equal accounts, ix.accounts + assert_equal "data".b, ix.data + end + + private + + def role(meta) + [meta.signer, meta.writable] + end +end diff --git a/ruby/test/pay_core/message_builder_test.rb b/ruby/test/pay_core/message_builder_test.rb new file mode 100644 index 000000000..12e18f7f2 --- /dev/null +++ b/ruby/test/pay_core/message_builder_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +class MessageBuilderTest < Minitest::Test + include RubyMppTestHelpers + + MB = ::PayCore::Solana::MessageBuilder + AM = ::PayCore::Solana::AccountMeta + PI = ::PayCore::Solana::PreparedInstruction + + def test_builds_signable_legacy_transaction + fee_payer = pubkey(1) + ix = PI.new(PROGRAMS::SYSTEM_PROGRAM, [AM.writable(pubkey(2))], "\x01\x02".b) + tx = MB.build_legacy(fee_payer: fee_payer, recent_blockhash: pubkey(9), instructions: [ix]) + + assert_equal "legacy", tx.version + assert_equal 1, tx.signatures.length + assert_equal "\x00".b * 64, tx.signatures.first + assert_equal fee_payer, tx.message.account_keys.first + # pubkey(2) is writable-nonsigner; only the program id is readonly-unsigned. + assert_equal({required_signatures: 1, readonly_signed: 0, readonly_unsigned: 1}, tx.message.header) + # round-trips through the canonical parser + assert_equal tx.message.account_keys, ::PayCore::Solana::Transaction.from_bytes(tx.to_bytes).message.account_keys + end + + def test_merges_roles_across_instructions + fee_payer = pubkey(1) + shared = pubkey(2) + # shared appears readonly in ix1 and writable in ix2 -> must end writable + ix1 = PI.new(PROGRAMS::SYSTEM_PROGRAM, [AM.readonly(shared)], "\x00".b) + ix2 = PI.new(PROGRAMS::MEMO_PROGRAM, [AM.writable(shared)], "\x00".b) + tx = MB.build_legacy(fee_payer: fee_payer, recent_blockhash: pubkey(9), instructions: [ix1, ix2]) + + # account table is unique + assert_equal tx.message.account_keys, tx.message.account_keys.uniq + # shared is writable-nonsigner -> not in the readonly_unsigned tail bucket only if writable + shared_index = tx.message.account_keys.index(shared) + # writable nonsigners come before readonly nonsigners and after signers + refute_nil shared_index + # both program ids present as readonly nonsigners + assert_includes tx.message.account_keys, PROGRAMS::SYSTEM_PROGRAM + assert_includes tx.message.account_keys, PROGRAMS::MEMO_PROGRAM + end + + def test_fee_payer_stays_signer_writable_even_when_used_as_account + fee_payer = pubkey(1) + # fee payer also referenced read-only inside an instruction + ix = PI.new(PROGRAMS::SYSTEM_PROGRAM, [AM.readonly(fee_payer), AM.writable(pubkey(2))], "\x00".b) + tx = MB.build_legacy(fee_payer: fee_payer, recent_blockhash: pubkey(9), instructions: [ix]) + + assert_equal fee_payer, tx.message.account_keys.first + assert_equal 1, tx.message.header[:required_signatures] + assert_equal 0, tx.message.header[:readonly_signed] + end + + def test_instruction_indices_resolve + fee_payer = pubkey(1) + ix = PI.new(PROGRAMS::SYSTEM_PROGRAM, [AM.writable(pubkey(2)), AM.readonly(pubkey(3))], "data".b) + tx = MB.build_legacy(fee_payer: fee_payer, recent_blockhash: pubkey(9), instructions: [ix]) + + compiled = tx.message.instructions.first + keys = tx.message.account_keys + assert_equal PROGRAMS::SYSTEM_PROGRAM, keys[compiled.program_id_index] + assert_equal [pubkey(2), pubkey(3)], compiled.accounts.map { |i| keys[i] } + assert_equal "data".b, compiled.data + end + + def test_signer_readonly_account_counts_in_header + fee_payer = pubkey(1) + # a co-signer that does not write (e.g. settle_and_finalize's merchant) + ix = PI.new(PROGRAMS::SYSTEM_PROGRAM, [AM.signer_readonly(pubkey(2)), AM.writable(pubkey(3))], "\x00".b) + tx = MB.build_legacy(fee_payer: fee_payer, recent_blockhash: pubkey(9), instructions: [ix]) + + assert_equal 2, tx.message.header[:required_signatures] + assert_equal 1, tx.message.header[:readonly_signed] + # signer-readonly is ordered after writable signers, before nonsigners + assert_equal pubkey(2), tx.message.account_keys[1] + end + + def test_two_signers_yield_two_signature_slots + fee_payer = pubkey(1) + ix = PI.new(PROGRAMS::SYSTEM_PROGRAM, [AM.signer_writable(pubkey(2))], "\x00".b) + tx = MB.build_legacy(fee_payer: fee_payer, recent_blockhash: pubkey(9), instructions: [ix]) + + assert_equal 2, tx.signatures.length + assert_equal 2, tx.message.header[:required_signatures] + end +end diff --git a/ruby/test/pay_core/payment_channels_test.rb b/ruby/test/pay_core/payment_channels_test.rb new file mode 100644 index 000000000..6b2332747 --- /dev/null +++ b/ruby/test/pay_core/payment_channels_test.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +# Byte-level parity tests for the hand-written payment-channels client. The +# golden vectors are lifted from the Go/Rust references so a Ruby encoder drift +# fails here, offline, before it ever reaches a validator. +class PaymentChannelsTest < Minitest::Test + include RubyMppTestHelpers + + PC = ::PayCore::Solana::PaymentChannels + ZERO_PUBKEY = "11111111111111111111111111111111" # 32 zero bytes + + # ---- voucher preimage -------------------------------------------------- + def test_voucher_message_bytes_matches_go_golden + # go/protocols/programs/paymentchannels_parity_test/parity_test.go:123 + voucher = PC.voucher_message_bytes(ZERO_PUBKEY, 1_234_567, 4_102_444_800) + expected = "000000000000000000000000000000000000000000000000000000000000000087d6120000000000005786f400000000" + + assert_equal 48, voucher.bytesize + assert_equal expected, voucher.unpack1("H*") + end + + def test_voucher_message_layout + channel = pubkey(7) + voucher = PC.voucher_message_bytes(channel, 500, 4_102_444_800) + + assert_equal ::PayCore::Solana::Base58.decode(channel), voucher.byteslice(0, 32) + assert_equal 500, voucher.byteslice(32, 8).unpack1("Q<") + assert_equal 4_102_444_800, voucher.byteslice(40, 8).unpack1("q<") + end + + # ---- distribution hash ------------------------------------------------- + def test_empty_distribution_hash_matches_go_constant + assert_equal 32, PC::EMPTY_DISTRIBUTION_HASH.bytesize + assert_equal PC::EMPTY_DISTRIBUTION_HASH, PC.distribution_hash([]) + assert_equal Digest::SHA256.digest(u32(0)), PC.distribution_hash([]) + assert_equal "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119", + PC::EMPTY_DISTRIBUTION_HASH.unpack1("H*") + end + + def test_distribution_hash_preimage_layout + recipients = [{recipient: pubkey(1), bps: 7_500}, {recipient: pubkey(2), bps: 2_500}] + preimage = u32(2) + + ::PayCore::Solana::Base58.decode(pubkey(1)) + [7_500].pack("v") + + ::PayCore::Solana::Base58.decode(pubkey(2)) + [2_500].pack("v") + + assert_equal Digest::SHA256.digest(preimage), PC.distribution_hash(recipients) + end + + # ---- settle_and_finalize ---------------------------------------------- + def test_settle_and_finalize_voucherless + ixs = PC.settle_and_finalize_instructions( + merchant: pubkey(5), channel: pubkey(6), authorized_signer: pubkey(5), + signature: nil, cumulative: 0, expires_at: 100 + ) + + assert_equal 1, ixs.length + settle = ixs.first + assert_equal PC::PROGRAM_ID, settle.program_id + assert_equal "0400", settle.data.unpack1("H*") + assert_equal [[true, false], [false, true], [false, false]], + settle.accounts.map { |a| [a.signer, a.writable] } + assert_equal [pubkey(5), pubkey(6), PC::INSTRUCTIONS_SYSVAR], settle.accounts.map(&:pubkey) + end + + def test_settle_and_finalize_with_voucher + signature = "\xAB".b * 64 + ixs = PC.settle_and_finalize_instructions( + merchant: pubkey(5), channel: ZERO_PUBKEY, authorized_signer: pubkey(4), + signature: signature, cumulative: 500, expires_at: 4_102_444_800 + ) + + assert_equal 2, ixs.length + ed, settle = ixs + assert_equal PC::ED25519_PROGRAM, ed.program_id + assert_empty ed.accounts + assert_equal "0401", settle.data.unpack1("H*") + + # Ed25519 precompile header: pubkey @16, signature @48, message @112. + assert_equal 112 + 48, ed.data.bytesize + header = ed.data.byteslice(0, 16).unpack("CCv7") + assert_equal [1, 0, 48, 0xFFFF, 16, 0xFFFF, 112, 48, 0xFFFF], header + assert_equal ::PayCore::Solana::Base58.decode(pubkey(4)), ed.data.byteslice(16, 32) + assert_equal signature, ed.data.byteslice(48, 64) + assert_equal PC.voucher_message_bytes(ZERO_PUBKEY, 500, 4_102_444_800), ed.data.byteslice(112, 48) + end + + # ---- distribute -------------------------------------------------------- + def test_distribute_empty_recipients + ix = PC.distribute_instruction( + channel: ZERO_PUBKEY, payer: pubkey(1), rent_payer: pubkey(5), payee: pubkey(3), + mint: usdc_mint, token_program: PROGRAMS::TOKEN_PROGRAM + ) + + assert_equal PC::PROGRAM_ID, ix.program_id + assert_equal "0700000000", ix.data.unpack1("H*") + assert_equal 11, ix.accounts.length + # First 7 accounts writable, last 4 readonly (idl distribute order). + assert_equal [true] * 7 + [false] * 4, ix.accounts.map(&:writable) + refute(ix.accounts.any?(&:signer)) + assert_equal PC::PROGRAM_ID, ix.accounts.last.pubkey # self_program + end + + def test_distribute_with_recipients_appends_token_accounts + recipients = [{recipient: pubkey(8), bps: 1_000}, {recipient: pubkey(9), bps: 250}] + ix = PC.distribute_instruction( + channel: ZERO_PUBKEY, payer: pubkey(1), rent_payer: pubkey(5), payee: pubkey(3), + mint: usdc_mint, token_program: PROGRAMS::TOKEN_PROGRAM, recipients: recipients + ) + + assert_equal 13, ix.accounts.length + expected_data = "07" + "02000000" + + ::PayCore::Solana::Base58.decode(pubkey(8)).unpack1("H*") + "e803" + + ::PayCore::Solana::Base58.decode(pubkey(9)).unpack1("H*") + "fa00" + assert_equal expected_data, ix.data.unpack1("H*") + assert(ix.accounts.last(2).all?(&:writable)) + end + + # ---- idempotent ATA create -------------------------------------------- + def test_create_idempotent_ata_instruction + ix = PC.create_idempotent_ata_instruction( + payer: pubkey(5), owner: pubkey(3), mint: usdc_mint, token_program: PROGRAMS::TOKEN_PROGRAM + ) + + assert_equal PC::ASSOCIATED_TOKEN_PROGRAM, ix.program_id + assert_equal "01", ix.data.unpack1("H*") + assert_equal [[true, true], [false, true], [false, false], [false, false], [false, false], [false, false]], + ix.accounts.map { |a| [a.signer, a.writable] } + expected_ata = ::PayCore::Solana::ATA.derive(owner: pubkey(3), mint: usdc_mint, token_program: PROGRAMS::TOKEN_PROGRAM) + assert_equal expected_ata, ix.accounts[1].pubkey + end + + # ---- PDA derivation ---------------------------------------------------- + def test_find_channel_pda_seeds + expected = ::PayCore::Solana::PublicKey.find_program_address( + [ + "channel".b, ::PayCore::Solana::Base58.decode(pubkey(1)), ::PayCore::Solana::Base58.decode(pubkey(3)), + ::PayCore::Solana::Base58.decode(usdc_mint), ::PayCore::Solana::Base58.decode(pubkey(5)), [7].pack("Q<") + ], + PC::PROGRAM_ID + ).first.to_s + + assert_equal expected, PC.find_channel_pda( + payer: pubkey(1), payee: pubkey(3), mint: usdc_mint, authorized_signer: pubkey(5), salt: 7 + ) + end + + def test_find_channel_pda_salt_changes_address + a = PC.find_channel_pda(payer: pubkey(1), payee: pubkey(3), mint: usdc_mint, authorized_signer: pubkey(5), salt: 1) + b = PC.find_channel_pda(payer: pubkey(1), payee: pubkey(3), mint: usdc_mint, authorized_signer: pubkey(5), salt: 2) + + refute_equal a, b + end + + def test_find_event_authority_pda_seeds + expected = ::PayCore::Solana::PublicKey.find_program_address(["event_authority".b], PC::PROGRAM_ID).first.to_s + + assert_equal expected, PC.find_event_authority_pda + end + + # ---- channel decode ---------------------------------------------------- + def test_decode_channel_round_trip + data = [9, 1, 254, PC::STATUS_OPEN].pack("C4") + + [7].pack("Q<") + [1_000_000].pack("Q<") + [50_000].pack("Q<") + [40_000].pack("Q<") + + [123].pack("q<") + [0].pack("q<") + [900].pack("V") + + PC::EMPTY_DISTRIBUTION_HASH + + ::PayCore::Solana::Base58.decode(pubkey(1)) + ::PayCore::Solana::Base58.decode(pubkey(3)) + + ::PayCore::Solana::Base58.decode(pubkey(5)) + ::PayCore::Solana::Base58.decode(usdc_mint) + + ::PayCore::Solana::Base58.decode(pubkey(5)) + + channel = PC.decode_channel(data) + + assert_equal 9, channel.discriminator + assert_equal PC::STATUS_OPEN, channel.status + assert_equal 7, channel.salt + assert_equal 1_000_000, channel.deposit + assert_equal 50_000, channel.settled + assert_equal 40_000, channel.payout_watermark + assert_equal 123, channel.closure_started_at + assert_equal 900, channel.grace_period + assert_equal PC::EMPTY_DISTRIBUTION_HASH, channel.distribution_hash + assert_equal pubkey(1), channel.payer + assert_equal pubkey(3), channel.payee + assert_equal pubkey(5), channel.authorized_signer + assert_equal usdc_mint, channel.mint + assert_equal pubkey(5), channel.rent_payer + end + + def test_decode_channel_rejects_short_data + assert_raises(ArgumentError) { PC.decode_channel("\x00".b * 100) } + end + + def test_decode_channel_rejects_non_string + error = assert_raises(ArgumentError) { PC.decode_channel(42) } + assert_includes error.message, "? bytes" + end + + def test_ed25519_rejects_oversized_message + assert_raises(ArgumentError) do + PC.ed25519_verify_instruction(pubkey(4), "\x00".b * 64, "\x00".b * 70_000) + end + end + + def test_decode_channel_tolerates_trailing_bytes + data = ("\x00".b * PC::CHANNEL_ACCOUNT_SIZE) + ("\xFF".b * 16) + + assert_equal PC::CHANNEL_ACCOUNT_SIZE, PC::CHANNEL_ACCOUNT_SIZE + assert_instance_of PC::Channel, PC.decode_channel(data) + end + + # ---- constants + LE helpers ------------------------------------------- + def test_constants + assert_equal "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX", PC::PROGRAM_ID + assert_equal "Cs2zdfUNonRdRGsiZUQQLdTxzxVvJZmgiX2mpLYKuEqP", PC::TREASURY_OWNER + assert_equal 4, PC::DISCRIMINATOR_SETTLE_AND_FINALIZE + assert_equal 7, PC::DISCRIMINATOR_DISTRIBUTE + assert_equal 0, PC::STATUS_OPEN + assert_equal 248, PC::CHANNEL_ACCOUNT_SIZE + end + + def test_little_endian_helpers + assert_equal "0100", PC.u16_le(1).unpack1("H*") + assert_equal "01000000", PC.u32_le(1).unpack1("H*") + assert_equal "0100000000000000", PC.u64_le(1).unpack1("H*") + assert_equal(-1, PC.read_i64_le(PC.i64_le(-1), 0)) + assert_equal 65_535, PC.read_u32_le(PC.u32_le(65_535), 0) + assert_equal 2**63, PC.read_u64_le(PC.u64_le(2**63), 0) + end + + private + + def usdc_mint + PROGRAMS::MINTS.fetch("USDC").fetch("devnet") + end +end diff --git a/ruby/test/pay_core/rpc_get_account_info_test.rb b/ruby/test/pay_core/rpc_get_account_info_test.rb new file mode 100644 index 000000000..25fc71d04 --- /dev/null +++ b/ruby/test/pay_core/rpc_get_account_info_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "base64" + +require_relative "../test_helper" + +class RpcGetAccountInfoTest < Minitest::Test + def setup + @rpc = ::PayCore::Solana::Rpc.new("http://localhost:8899") + end + + def test_decodes_base64_account_data + raw = "\x01\x02\x03\x04".b + value = {"value" => {"data" => [Base64.strict_encode64(raw), "base64"], "owner" => "Owner111", "lamports" => 4_242}} + + @rpc.stub(:call, value) do + info = @rpc.get_account_info("Acct111") + assert_equal raw, info[:data] + assert_equal "Owner111", info[:owner] + assert_equal 4_242, info[:lamports] + end + end + + def test_returns_nil_when_account_missing + @rpc.stub(:call, {"value" => nil}) do + assert_nil @rpc.get_account_info("Acct111") + end + end + + def test_handles_empty_data + @rpc.stub(:call, {"value" => {"data" => ["", "base64"], "owner" => "Owner111"}}) do + info = @rpc.get_account_info("Acct111") + assert_equal "".b, info[:data] + end + end + + def test_handles_missing_data_key + @rpc.stub(:call, {"value" => {"owner" => "Owner111"}}) do + assert_equal "".b, @rpc.get_account_info("Acct111")[:data] + end + end + + def test_handles_string_data + raw = "\x09\x08".b + @rpc.stub(:call, {"value" => {"data" => Base64.strict_encode64(raw), "owner" => "Owner111"}}) do + assert_equal raw, @rpc.get_account_info("Acct111")[:data] + end + end +end From 1bb5399e0ff6e88f75f517052db78eae2112b119 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:29:29 +0300 Subject: [PATCH 02/17] feat(ruby): x402 upto (usage-based) scheme, payment-channel profile 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. --- .../x402/protocol/schemes/upto/types.rb | 140 +++++++ .../x402/protocol/schemes/upto/verify.rb | 151 ++++++++ ruby/lib/pay_kit/protocols/x402/runtime.rb | 6 + .../lib/pay_kit/protocols/x402/server/upto.rb | 358 ++++++++++++++++++ ruby/lib/pay_kit/usage.rb | 124 ++++++ ruby/lib/pay_kit/usage/sinatra.rb | 49 +++ .../protocols/x402/server_upto_test.rb | 220 +++++++++++ ruby/test/pay_kit/usage_sinatra_test.rb | 64 ++++ ruby/test/pay_kit/usage_test.rb | 150 ++++++++ 9 files changed, 1262 insertions(+) create mode 100644 ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb create mode 100644 ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb create mode 100644 ruby/lib/pay_kit/protocols/x402/server/upto.rb create mode 100644 ruby/lib/pay_kit/usage.rb create mode 100644 ruby/lib/pay_kit/usage/sinatra.rb create mode 100644 ruby/test/pay_kit/protocols/x402/server_upto_test.rb create mode 100644 ruby/test/pay_kit/usage_sinatra_test.rb create mode 100644 ruby/test/pay_kit/usage_test.rb diff --git a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb new file mode 100644 index 000000000..51fcfdc1a --- /dev/null +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "base64" +require "json" + +require_relative "../../../constants" +require_relative "../../../error" + +module PayKit::Protocols::X402 + module Protocol + module Schemes + # `Upto` is the SVM usage-based (`upto`) payment scheme, + # `payment-channel` profile. The client authorizes a ceiling by opening a + # payment channel whose `deposit` is the maximum and whose + # `authorizedSigner` is the operator; the operator (facilitator) settles + # the actual metered amount with a single voucher after the resource is + # served. Ruby ships SERVER support only — the open-transaction builder + # and the client envelope live in the TS/Rust/Go/Python adapters; the + # cross-language harness pairs this server against those clients. + # + # Mirrors the Rust spine `rust/crates/x402/src/protocol/schemes/upto/*` + # and the Go engine `go/protocols/x402/upto.go`. + module Upto + module_function + + # Scheme value advertised in PaymentRequirements.scheme (issue #175 §4.1). + UPTO_SCHEME = "upto" + # The single normative v1 profile (issue #175 §3.1). + PROFILE_PAYMENT_CHANNEL = "payment-channel" + + # Scheme-specific reject token: actual settlement exceeds the signed + # ceiling (issue #175 §6). Substring-matched by the harness. + ERROR_SETTLEMENT_EXCEEDS_AMOUNT = "invalid_upto_svm_payload_settlement_exceeds_amount" + + Constants = ::PayKit::Protocols::X402::Constants + + # ---- Envelope decode ------------------------------------------------- + # Decode a base64 PAYMENT-SIGNATURE header into an envelope hash with a + # symbolized top level and the raw `payload` hash. Falls back to the + # `accepted` object for scheme/network when the envelope omits them, + # matching the Go reference (upto.go:384-405). + def parse_payment_signature(header) + decoded = Base64.strict_decode64(header) + envelope = JSON.parse(decoded) + raise ArgumentError, "payment signature must be a JSON object" unless envelope.is_a?(Hash) + + scheme = envelope["scheme"] + network = envelope["network"] + accepted = envelope["accepted"] + if (scheme.nil? || scheme.empty?) && accepted.is_a?(Hash) + scheme = accepted["scheme"] + network = accepted["network"] + end + unless scheme == UPTO_SCHEME + raise ::PayKit::Protocols::X402::Error::InvalidPayloadType, "invalid payload type: #{scheme}" + end + + { + x402_version: envelope["x402Version"], + scheme: scheme, + network: network, + accepted: accepted, + payload: envelope["payload"] + } + rescue ArgumentError => error + raise ::PayKit::Protocols::X402::Error::InvalidPaymentRequired, "invalid 402 response: #{error.message}" + rescue JSON::ParserError => error + raise ::PayKit::Protocols::X402::Error::InvalidPaymentRequired, "invalid 402 response: #{error.message}" + end + + # Parse a base-unit u64 string field, raising a stable message on a + # malformed value. + def parse_base_units(value, field) + Integer(value, 10) + rescue ArgumentError, TypeError + raise ::PayKit::Protocols::X402::Error::PaymentInvalid, "invalid upto #{field} #{value.inspect}" + end + + # ---- Requirement + challenge build ----------------------------------- + # Build the route-pinned upto requirement advertised in + # PAYMENT-REQUIRED.accepts[] (issue #175 §4.1). + def requirement(network:, amount:, asset:, pay_to:, max_timeout_seconds:, decimals:, token_program:, fee_payer:, channel_program:, recent_blockhash: nil) + extra = { + "profiles" => [PROFILE_PAYMENT_CHANNEL], + "decimals" => decimals, + "tokenProgram" => token_program, + "feePayer" => fee_payer, + "channelProgram" => channel_program + } + extra["recentBlockhash"] = recent_blockhash unless recent_blockhash.nil? + { + "scheme" => UPTO_SCHEME, + "network" => network, + "amount" => amount.to_s, + "asset" => asset, + "payTo" => pay_to, + "maxTimeoutSeconds" => max_timeout_seconds, + "extra" => extra + } + end + + # Build the full PAYMENT-REQUIRED envelope for an upto challenge. + def challenge(requirement, resource: nil) + envelope = { + "x402Version" => Constants::X402_VERSION_V2, + "accepts" => [requirement] + } + if resource.is_a?(String) && !resource.empty? + envelope["resource"] = {"type" => "http", "url" => resource, "uri" => resource} + end + envelope + end + + def encode_payment_required(envelope) + Base64.strict_encode64(JSON.generate(envelope)) + end + + # ---- Settlement response --------------------------------------------- + # Build the PAYMENT-RESPONSE settlement body (issue #175 §4.3). The + # transaction signature is the empty string when no token moved + # (zero-actual), and `amount` is the actual base units charged. + def settlement_response(success:, network:, amount:, payer: nil, transaction: "", error_reason: nil) + body = { + "success" => success, + "payer" => payer, + "transaction" => transaction.to_s, + "network" => network, + "amount" => amount.to_s + } + body["errorReason"] = error_reason unless error_reason.nil? + body.compact + end + + def encode_settlement_response(body) + Base64.strict_encode64(JSON.generate(body)) + end + end + end + end +end diff --git a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb new file mode 100644 index 000000000..1c3094ad1 --- /dev/null +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "pay_core/solana/ata" +require "pay_core/solana/payment_channels" +require "pay_core/solana/transaction" + +require_relative "types" + +module PayKit::Protocols::X402 + module Protocol + module Schemes + module Upto + # Stateless verification for the x402 `upto` payment-channel profile: + # the off-chain payload checks (issue #175 §3 Phase 3) and the + # structural validation of the client-built `open` transaction the + # facilitator broadcasts in pull mode. Mirrors the Go reference + # `VerifyUptoPayload` + `validateUptoOpenInstruction` (upto.go:149-199, + # 660-747). + module Verifier + module_function + + PaymentChannels = ::PayCore::Solana::PaymentChannels + ATA = ::PayCore::Solana::ATA + Transaction = ::PayCore::Solana::Transaction + + # Open-instruction account layout the on-chain program expects, by + # index (idl/payment-channels.json `open`). Indices 6/7 (payer/channel + # token accounts) are derived, so they are validated separately. + OPEN_PAYER = 0 + OPEN_RENT_PAYER = 1 + OPEN_PAYEE = 2 + OPEN_MINT = 3 + OPEN_AUTHORIZED_SIGNER = 4 + OPEN_CHANNEL = 5 + OPEN_PAYER_TOKEN = 6 + OPEN_CHANNEL_TOKEN = 7 + OPEN_TOKEN_PROGRAM = 8 + OPEN_SYSTEM_PROGRAM = 9 + OPEN_RENT = 10 + OPEN_ASSOCIATED_TOKEN_PROGRAM = 11 + OPEN_EVENT_AUTHORITY = 12 + OPEN_SELF_PROGRAM = 13 + + # Verify the off-chain payload against the route-pinned requirement + # (issue #175 §3 Phase 3 steps 1, 5; §5 Phase 2). Raises a stable + # reject message on any failure; mirrors VerifyUptoPayload. + def verify_payload!(payload, requirement, operator, now) + profile = payload["profile"] + unless profile == PROFILE_PAYMENT_CHANNEL + raise reject("invalid payload type: #{profile}") + end + advertised = Array(requirement.dig("extra", "profiles")).include?(profile) + raise reject("profile #{profile} not advertised by the server") unless advertised + + max = Upto.parse_base_units(requirement["amount"], "amount") + signed_max = Upto.parse_base_units(payload["maxAmount"], "maxAmount") + raise reject("amount mismatch: expected #{max}, got #{signed_max}") unless signed_max == max + + deposit = Upto.parse_base_units(payload["deposit"], "deposit") + raise reject("channel deposit #{deposit} must equal the authorized maximum #{max}") unless deposit == max + + valid_after = Integer(payload["validAfter"] || 0) + expires_at = Integer(payload["expiresAt"]) + raise reject("authorization not yet active (validAfter #{valid_after} > now #{now})") if now < valid_after + raise reject("authorization expired (expiresAt #{expires_at} < now #{now})") if now > expires_at + + unless payload["authorizedSigner"] == operator + raise reject("voucher authorized_signer must be the operator for the payment-channel profile") + end + + {max: max, expires_at: expires_at, valid_after: valid_after} + end + + # Enforce actual <= signed ceiling at settlement (issue #175 §4 Phase 4 + # step 2). Raises the scheme reject token on violation. + def assert_within_ceiling!(actual, max) + raise reject(ERROR_SETTLEMENT_EXCEEDS_AMOUNT) if actual > max + end + + # Structurally validate the client-built `open` transaction before the + # facilitator broadcasts it (pull mode): exactly one channel-open + # instruction targeting the advertised channel program, with all 14 + # accounts in the exact program order. Mirrors + # validateUptoOpenInstruction (upto.go:660-747). + def validate_open_instruction!(transaction, program_id:, operator:, payer:, payee:, mint:, token_program:, channel_id:) + keys = transaction.message.account_keys + instructions = transaction.message.instructions + unless instructions.length == 1 + raise reject("open transaction must contain exactly one instruction, found #{instructions.length}") + end + + instruction = instructions.first + program = key_at(keys, instruction.program_id_index) + raise reject("open transaction targets an unexpected program") unless program == program_id + if instruction.data.empty? || instruction.data.getbyte(0) != 1 + raise reject("open transaction is not a channel-open instruction") + end + + payer_token = ATA.derive(owner: payer, mint: mint, token_program: token_program) + channel_token = ATA.derive(owner: channel_id, mint: mint, token_program: token_program) + event_authority = PaymentChannels.find_event_authority_pda(program_id: program_id) + + expect(instruction, keys, OPEN_PAYER, payer, "payer") + expect(instruction, keys, OPEN_RENT_PAYER, operator, "rent_payer") + expect(instruction, keys, OPEN_PAYEE, payee, "payee") + expect(instruction, keys, OPEN_MINT, mint, "mint") + expect(instruction, keys, OPEN_AUTHORIZED_SIGNER, operator, "authorized_signer") + expect(instruction, keys, OPEN_CHANNEL, channel_id, "channel") + expect(instruction, keys, OPEN_PAYER_TOKEN, payer_token, "payer_token_account") + expect(instruction, keys, OPEN_CHANNEL_TOKEN, channel_token, "channel_token_account") + expect(instruction, keys, OPEN_TOKEN_PROGRAM, token_program, "token_program") + expect(instruction, keys, OPEN_SYSTEM_PROGRAM, PaymentChannels::SYSTEM_PROGRAM, "system_program") + expect(instruction, keys, OPEN_RENT, PaymentChannels::RENT_SYSVAR, "rent_sysvar") + expect(instruction, keys, OPEN_ASSOCIATED_TOKEN_PROGRAM, PaymentChannels::ASSOCIATED_TOKEN_PROGRAM, "associated_token_program") + expect(instruction, keys, OPEN_EVENT_AUTHORITY, event_authority, "event_authority") + expect(instruction, keys, OPEN_SELF_PROGRAM, program_id, "self_program") + end + + # Verify the operator is the fee payer (account index 0) on the open + # transaction, so a single operator signature covers the fee + # (upto.go:477, 749-751). + def fee_payer?(transaction, operator) + keys = transaction.message.account_keys + !keys.empty? && keys.first == operator + end + + def expect(instruction, keys, position, want, label) + got = account_at(instruction, keys, position) + raise reject("open transaction #{label} mismatch: expected #{want}, got #{got || ""}") unless got == want + end + + def account_at(instruction, keys, position) + return nil if position >= instruction.accounts.length + + key_at(keys, instruction.accounts[position]) + end + + def key_at(keys, index) + return nil if index.nil? || index >= keys.length + + keys[index] + end + + def reject(message) + ::PayKit::Protocols::X402::Error::PaymentInvalid.new(message) + end + end + end + end + end +end diff --git a/ruby/lib/pay_kit/protocols/x402/runtime.rb b/ruby/lib/pay_kit/protocols/x402/runtime.rb index 8a1572b3b..e5c0ea8e4 100644 --- a/ruby/lib/pay_kit/protocols/x402/runtime.rb +++ b/ruby/lib/pay_kit/protocols/x402/runtime.rb @@ -11,6 +11,9 @@ # protocols/x402/protocol/schemes/exact/types.rb -> protocol/schemes/exact/types.rs # protocols/x402/protocol/schemes/exact/verify.rb -> protocol/schemes/exact/verify.rs # protocols/x402/server/exact.rb -> server/exact.rs +# protocols/x402/protocol/schemes/upto/types.rb -> protocol/schemes/upto/types.rs +# protocols/x402/protocol/schemes/upto/verify.rb -> protocol/schemes/upto/verify.rs +# protocols/x402/server/upto.rb -> protocol/schemes/upto (engine) # # Ruby is server-only: no client surface is exposed. @@ -21,6 +24,9 @@ require_relative "protocol/schemes/exact/types" require_relative "protocol/schemes/exact/verify" require_relative "server/exact" +require_relative "protocol/schemes/upto/types" +require_relative "protocol/schemes/upto/verify" +require_relative "server/upto" module PayKit::Protocols::X402 module Protocol diff --git a/ruby/lib/pay_kit/protocols/x402/server/upto.rb b/ruby/lib/pay_kit/protocols/x402/server/upto.rb new file mode 100644 index 000000000..39031fe1e --- /dev/null +++ b/ruby/lib/pay_kit/protocols/x402/server/upto.rb @@ -0,0 +1,358 @@ +# frozen_string_literal: true + +require "pay_core/solana/account" +require "pay_core/solana/caip2" +require "pay_core/solana/mints" +require "pay_core/solana/message_builder" +require "pay_core/solana/payment_channels" +require "pay_core/solana/rpc" +require "pay_core/solana/transaction" + +require_relative "../constants" +require_relative "../error" +require_relative "../protocol/schemes/upto/types" +require_relative "../protocol/schemes/upto/verify" + +module PayKit::Protocols::X402 + module Server + # Server-side x402 `upto` payment-channel engine (facilitator). Validates + # and broadcasts the client's channel `open`, binds the on-chain channel, + # then settles the metered actual amount with a single operator voucher and + # a settle_and_finalize + distribute that refunds the unused remainder. + # + # Ruby is server-only for x402: the open-transaction builder and client + # envelope live in the Rust/Go/Python adapters, and the cross-language + # harness pairs this engine against those clients. Mirrors the Go engine + # `go/protocols/x402/upto.go` (`X402Upto`) and the Rust spine. + # + # The engine HONORS a zero-actual settlement at the protocol layer (full + # refund, channel closed, empty transaction signature). The fail-closed + # treatment of a zero charge is an app-layer policy that lives one level up + # in the usage middleware (`PayKit::Usage`), not here. + class Upto + Types = ::PayKit::Protocols::X402::Protocol::Schemes::Upto + Verifier = ::PayKit::Protocols::X402::Protocol::Schemes::Upto::Verifier + Constants = ::PayKit::Protocols::X402::Constants + PaymentChannels = ::PayCore::Solana::PaymentChannels + MessageBuilder = ::PayCore::Solana::MessageBuilder + Account = ::PayCore::Solana::Account + Rpc = ::PayCore::Solana::Rpc + + DEFAULT_MAX_TIMEOUT_SECONDS = 300 + DEFAULT_DECIMALS = 6 + DEFAULT_RESOURCE_PATH = "/usage" + DEFAULT_TOKEN_PROGRAM = ::PayCore::Solana::Mints::TOKEN_PROGRAM + DEFAULT_NETWORK = ::PayCore::Solana::Caip2::DEVNET + DEFAULT_CONFIRMATION_ATTEMPTS = 40 + DEFAULT_CONFIRMATION_DELAY_SECONDS = 0.25 + CONFIRMED_STATUSES = ["confirmed", "finalized"].freeze + + CAPABILITY_PAYLOAD = { + implementation: "ruby", + role: "server", + capabilities: ["upto"] + }.freeze + + # A confirmed channel open, carried from `verify_open` into + # `settle_actual`. `release` frees the in-flight reservation. + VerifiedOpen = Struct.new( + :channel_id, :payer, :rent_payer, :mint, :token_program, :program_id, + :deposit, :max_amount, :expires_at, :network, :release, keyword_init: true + ) do + def release! + release&.call + end + end + + # `Config` holds the resolved RPC URL, operator (facilitator) keypair, + # advertised mint / ceiling / network, and the channel program. Test + # callers inject `transaction_sender`, `signature_confirmer`, + # `channel_fetcher`, and `recent_blockhash_provider` to exercise the + # engine without a live validator; production leaves them nil. + class Config + attr_reader :rpc_url, :pay_to, :amount, :mint, :network, :decimals, + :token_program, :channel_program, :max_timeout_seconds, :resource_path, + :settlement_header, :operator, :operator_pubkey + attr_accessor :transaction_sender, :signature_confirmer, :channel_fetcher, + :recent_blockhash_provider + + def initialize( + rpc_url:, + pay_to:, + facilitator_secret_key:, + amount:, + mint:, + network: DEFAULT_NETWORK, + decimals: DEFAULT_DECIMALS, + token_program: DEFAULT_TOKEN_PROGRAM, + channel_program: PaymentChannels::PROGRAM_ID, + max_timeout_seconds: DEFAULT_MAX_TIMEOUT_SECONDS, + resource_path: DEFAULT_RESOURCE_PATH, + settlement_header: "x-payment-settlement-signature", + transaction_sender: nil, + signature_confirmer: nil, + channel_fetcher: nil, + recent_blockhash_provider: nil + ) + raise ArgumentError, "rpc_url is required" if blank?(rpc_url) + raise ArgumentError, "pay_to is required" if blank?(pay_to) + raise ArgumentError, "facilitator_secret_key is required" if blank?(facilitator_secret_key) + raise ArgumentError, "amount is required" if blank?(amount.to_s) + raise ArgumentError, "mint is required" if blank?(mint) + + @rpc_url = rpc_url + @pay_to = pay_to + @amount = amount.to_s + @mint = mint + @network = network + @decimals = decimals + @token_program = token_program + @channel_program = blank?(channel_program) ? PaymentChannels::PROGRAM_ID : channel_program + @max_timeout_seconds = max_timeout_seconds + @resource_path = blank?(resource_path) ? DEFAULT_RESOURCE_PATH : resource_path + @settlement_header = settlement_header + @operator = Account.from_json_array(facilitator_secret_key) + @operator_pubkey = @operator.public_key.to_s + @transaction_sender = transaction_sender + @signature_confirmer = signature_confirmer + @channel_fetcher = channel_fetcher + @recent_blockhash_provider = recent_blockhash_provider + end + + def rpc + @rpc ||= Rpc.new(@rpc_url) + end + + private + + def blank?(value) + value.nil? || value.empty? + end + end + + def initialize(config) + @config = config + @in_flight = {} + @mutex = Mutex.new + end + + # Operator / facilitator public key (base58). + def operator + @config.operator_pubkey + end + + # Build the route-pinned upto requirement, embedding a freshly fetched + # recent blockhash so the client can build its `open` against the chain + # the server settles on (issue #175 §4.1, extra.recentBlockhash). + def requirement(recent_blockhash: fetch_recent_blockhash) + Types.requirement( + network: @config.network, + amount: @config.amount, + asset: @config.mint, + pay_to: @config.pay_to, + max_timeout_seconds: @config.max_timeout_seconds, + decimals: @config.decimals, + token_program: @config.token_program, + fee_payer: operator, + channel_program: @config.channel_program, + recent_blockhash: recent_blockhash + ) + end + + # Base64-encoded PAYMENT-REQUIRED header value for an upto challenge. + def payment_required(resource: @config.resource_path) + Types.encode_payment_required(Types.challenge(requirement, resource: resource)) + end + + # Phase 3: validate the payload + the on-chain channel open, broadcast and + # confirm the open, and return a `VerifiedOpen` bound to the channel. The + # in-flight reservation guards against a concurrent settle on the same + # channel and is released on any failure or once settlement completes. + def verify_open(header, now: Time.now.to_i) + envelope = Types.parse_payment_signature(header) + payload = envelope[:payload] + raise reject("payment signature is missing payload") unless payload.is_a?(Hash) + + req = requirement(recent_blockhash: nil) + parsed = Verifier.verify_payload!(payload, req, operator, now) + + unless envelope[:network] == req["network"] + raise reject("network mismatch: payload #{envelope[:network].inspect}, expected #{req["network"].inspect}") + end + raise reject("extra.feePayer is not this server's key") unless req.dig("extra", "feePayer") == operator + + program_id = @config.channel_program + channel_id = payload["channelId"] + payer = payload["from"] + raise reject("payment-channel profile requires openTransaction (pull)") if blank?(payload["openTransaction"]) + + reserve_channel(channel_id) + released = false + begin + transaction = ::PayCore::Solana::Transaction.from_base64(payload["openTransaction"]) + Verifier.validate_open_instruction!( + transaction, + program_id: program_id, operator: operator, payer: payer, + payee: @config.pay_to, mint: @config.mint, token_program: @config.token_program, + channel_id: channel_id + ) + unless Verifier.fee_payer?(transaction, operator) + raise reject("open transaction fee payer must be the advertised operator") + end + + transaction.sign_with(@config.operator) + broadcast_and_confirm(transaction.to_base64) + + channel = fetch_channel(channel_id) + validate_channel!(channel, payer: payer, max: parsed[:max]) + + verified = VerifiedOpen.new( + channel_id: channel_id, payer: payer, rent_payer: channel.rent_payer, + mint: @config.mint, token_program: @config.token_program, program_id: program_id, + deposit: channel.deposit, max_amount: parsed[:max], expires_at: parsed[:expires_at], + network: req["network"], release: -> { release_channel(channel_id) } + ) + released = true + verified + ensure + release_channel(channel_id) unless released + end + end + + # Phase 4: settle the metered `actual` amount against a verified open. The + # operator signs one voucher for `actual`, settle_and_finalize closes the + # channel, and distribute pays the payee + refunds the payer. A zero + # actual settles with no voucher and an empty transaction signature is + # returned (issue #175 §4 Phase 4 step 4). + def settle_actual(open, actual) + raise ArgumentError, "verified open is required" if open.nil? + + begin + Verifier.assert_within_ceiling!(actual, open.max_amount) + instructions = settlement_instructions(open, actual) + blockhash = fetch_recent_blockhash || raise(reject("blockhash fetch failed")) + transaction = MessageBuilder.build_legacy( + fee_payer: operator, recent_blockhash: blockhash, instructions: instructions + ) + transaction.sign_with(@config.operator) + signature = broadcast_and_confirm(transaction.to_base64) + + Types.settlement_response( + success: true, network: open.network, amount: actual, + payer: open.payer, transaction: signature + ) + ensure + open.release! + end + end + + # ---- internals ------------------------------------------------------- + def settlement_instructions(open, actual) + signature = nil + if actual.positive? + message = PaymentChannels.voucher_message_bytes(open.channel_id, actual, open.expires_at) + signature = @config.operator.sign(message) + unless signature.bytesize == 64 + raise reject("voucher signature length #{signature.bytesize}, want 64") + end + end + + instructions = PaymentChannels.settle_and_finalize_instructions( + merchant: operator, channel: open.channel_id, authorized_signer: operator, + signature: signature, cumulative: actual, expires_at: open.expires_at, + program_id: open.program_id + ) + instructions << PaymentChannels.create_idempotent_ata_instruction( + payer: operator, owner: @config.pay_to, mint: open.mint, token_program: open.token_program + ) + instructions << PaymentChannels.create_idempotent_ata_instruction( + payer: operator, owner: PaymentChannels::TREASURY_OWNER, mint: open.mint, token_program: open.token_program + ) + instructions << PaymentChannels.distribute_instruction( + channel: open.channel_id, payer: open.payer, rent_payer: open.rent_payer, + payee: @config.pay_to, mint: open.mint, token_program: open.token_program, + program_id: open.program_id + ) + instructions + end + + def validate_channel!(channel, payer:, max:) + raise reject("channel is not open after broadcast") unless channel.status == PaymentChannels::STATUS_OPEN + raise reject("token mint mismatch: expected #{@config.mint}, got #{channel.mint}") unless channel.mint == @config.mint + raise reject("recipient mismatch: expected #{@config.pay_to}, got #{channel.payee}") unless channel.payee == @config.pay_to + unless channel.distribution_hash == PaymentChannels::EMPTY_DISTRIBUTION_HASH + raise reject("x402 upto currently supports only empty-recipient payment channels") + end + raise reject("channel authorized_signer is not the operator") unless channel.authorized_signer == operator + raise reject("channel rent_payer is not the operator") unless channel.rent_payer == operator + raise reject("on-chain deposit #{channel.deposit} must equal authorized maximum #{max}") unless channel.deposit == max + raise reject("channel payer #{channel.payer} does not match payload.from #{payer}") unless channel.payer == payer + end + + def reserve_channel(channel_id) + @mutex.synchronize do + if @in_flight.key?(channel_id) + raise reject("channel is already being processed (concurrent request)") + end + + @in_flight[channel_id] = true + end + end + + def release_channel(channel_id) + @mutex.synchronize { @in_flight.delete(channel_id) } + end + + def broadcast_and_confirm(transaction_base64) + signature = if @config.transaction_sender + @config.transaction_sender.call(@config, transaction_base64) + else + @config.rpc.send_raw_transaction(transaction_base64) + end + if @config.signature_confirmer + @config.signature_confirmer.call(@config, signature) + else + await_confirmation(signature) + end + signature + end + + def await_confirmation(signature, attempts: DEFAULT_CONFIRMATION_ATTEMPTS, delay: DEFAULT_CONFIRMATION_DELAY_SECONDS) + attempts.times do + status = Array(@config.rpc.signature_statuses([signature])).first + if status.is_a?(Hash) + raise reject("transaction #{signature} failed on-chain: #{status["err"].inspect}") unless status["err"].nil? + return signature if CONFIRMED_STATUSES.include?(status["confirmationStatus"]) + end + sleep(delay) + end + raise reject("timed out awaiting confirmation for #{signature}") + end + + def fetch_channel(channel_id) + return @config.channel_fetcher.call(@config, channel_id) if @config.channel_fetcher + + info = @config.rpc.get_account_info(channel_id) + raise reject("channel account fetch failed: missing account data") if info.nil? || info[:data].empty? + + PaymentChannels.decode_channel(info[:data]) + end + + def fetch_recent_blockhash + return @config.recent_blockhash_provider.call if @config.recent_blockhash_provider + + @config.rpc.latest_blockhash + rescue Rpc::RpcError + nil + end + + def blank?(value) + value.nil? || value.empty? + end + + def reject(message) + ::PayKit::Protocols::X402::Error::PaymentInvalid.new(message) + end + end + end +end diff --git a/ruby/lib/pay_kit/usage.rb b/ruby/lib/pay_kit/usage.rb new file mode 100644 index 000000000..48443385f --- /dev/null +++ b/ruby/lib/pay_kit/usage.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "json" +require "rack" + +require_relative "protocols/x402/runtime" + +module PayKit + # Usage-based (x402 `upto`) billing surface: a `Charge` meter the protected + # handler records consumption into, and a Rack middleware that runs the + # canonical open-verify → serve → settle-after sequence around it. + # + # Two-layer zero policy, matching the Go/Python ports: + # * the engine (`Server::Upto`) HONORS a zero settlement at the protocol + # layer — it closes the channel and refunds the full deposit; + # * this middleware FAIL-CLOSES a zero charge at the app layer — it still + # settles 0 on-chain (closing the channel) but withholds the resource + # body and returns 402. + # That is why the Ruby upto server runs `x402-upto-basic` but stays off the + # live `x402-upto-zero-actual` scenario (which is pinned to the low-level + # Rust server); the honors-zero engine path is proven by unit tests. + module Usage + # Rack env key the middleware exposes the request's `Charge` under. + CHARGE_ENV_KEY = "pay_kit.usage.charge" + + # Meters consumption for a single upto authorization. `charge` accumulates + # metered base units, clamped to `[0, max]` so a handler can never settle + # above the signed ceiling. Mirrors the Go `Charge` test helper. + class Charge + attr_reader :max_base_units + + def initialize(max_base_units) + @max_base_units = max_base_units + @settled = 0 + end + + # Record additional metered base units. Negative deltas floor at 0; the + # running total is capped at the ceiling. + def charge(amount) + @settled = (@settled + amount.to_i).clamp(0, @max_base_units) + end + + # The clamped amount to settle on-chain. + def settled_base_units + @settled + end + end + + module_function + + # Fail-closed finalize policy: a zero settled charge yields no resource. + def deliver?(settled_base_units) + settled_base_units.positive? + end + + # Rack middleware that gates a single resource path behind an x402 `upto` + # authorization. On the protected path it returns a 402 upto challenge when + # no credential is present, otherwise it verifies + broadcasts the channel + # open, runs the wrapped app (which meters into the request's `Charge`), + # then settles the metered actual amount and either returns the buffered + # response with settlement headers (nonzero) or fail-closes with a 402 + # (zero). + class Middleware + Constants = ::PayKit::Protocols::X402::Constants + UptoTypes = ::PayKit::Protocols::X402::Protocol::Schemes::Upto + + def initialize(app, engine:, resource_path:, settlement_header: nil) + @app = app + @engine = engine + @resource_path = resource_path + @settlement_header = settlement_header + end + + def call(env) + request = ::Rack::Request.new(env) + return @app.call(env) unless request.path == @resource_path + + header = payment_header(env) + return challenge(env) if header.nil? || header.empty? + + open = @engine.verify_open(header) + charge = Charge.new(open.max_amount) + env[CHARGE_ENV_KEY] = charge + + status, headers, body = @app.call(env) + settled = charge.settled_base_units + settlement = @engine.settle_actual(open, settled) + + return challenge(env) unless Usage.deliver?(settled) + + [status, settlement_headers(headers, settlement), body] + rescue => error + challenge(env, error: error.message) + end + + private + + def payment_header(env) + env["HTTP_" + Constants::PAYMENT_SIGNATURE_HEADER.upcase.tr("-", "_")] || + env["HTTP_X_PAYMENT"] + end + + def settlement_headers(headers, settlement) + result = headers.dup + result[Constants::PAYMENT_RESPONSE_HEADER] = UptoTypes.encode_settlement_response(settlement) + result[@settlement_header] = settlement["transaction"] if @settlement_header && !@settlement_header.empty? + result + end + + def challenge(env, error: nil) + body = {"error" => "payment_required"} + body["invalidReason"] = error unless error.nil? + [ + 402, + { + Constants::PAYMENT_REQUIRED_HEADER => @engine.payment_required(resource: @resource_path), + "content-type" => "application/json" + }, + [JSON.generate(body)] + ] + end + end + end +end diff --git a/ruby/lib/pay_kit/usage/sinatra.rb b/ruby/lib/pay_kit/usage/sinatra.rb new file mode 100644 index 000000000..607181267 --- /dev/null +++ b/ruby/lib/pay_kit/usage/sinatra.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "sinatra/base" + +require_relative "../usage" + +module PayKit + module Usage + # Sinatra integration for the x402 `upto` usage gate. Register the + # extension and call `require_usage` to mount the settle-after middleware, + # then read the per-request `Charge` with the `usage_charge` helper: + # + # class App < Sinatra::Base + # register PayKit::Usage::Sinatra + # require_usage engine: engine, resource_path: "/usage", + # settlement_header: "x-payment-settlement-signature" + # + # get "/usage" do + # usage_charge.charge(metered_base_units) + # json(ok: true) + # end + # end + # + # The middleware verifies + broadcasts the channel open before the route + # runs, then settles the metered amount after it returns. + module Sinatra + # Mount the upto usage middleware in front of the app's routes. + def require_usage(engine:, resource_path:, settlement_header: nil) + use ::PayKit::Usage::Middleware, + engine: engine, + resource_path: resource_path, + settlement_header: settlement_header + end + + # Per-request helpers available inside routes. + module Helpers + # The `Charge` meter for the in-flight upto authorization, or nil when + # the current route is not gated by `require_usage`. + def usage_charge + request.env[::PayKit::Usage::CHARGE_ENV_KEY] + end + end + + def self.registered(app) + app.helpers(Helpers) + end + end + end +end diff --git a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb new file mode 100644 index 000000000..4ef454047 --- /dev/null +++ b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require "base64" +require "json" +require "ed25519" + +require_relative "../../../test_helper" + +# Offline engine + verifier tests for the x402 `upto` payment-channel server. +# A synthesized client `open` transaction and an injected channel fetcher let +# the full verify_open -> settle_actual path run without a validator. +class ServerUptoTest < Minitest::Test + include RubyMppTestHelpers + + PC = ::PayCore::Solana::PaymentChannels + MB = ::PayCore::Solana::MessageBuilder + AM = ::PayCore::Solana::AccountMeta + Upto = ::PayKit::Protocols::X402::Server::Upto + UptoTypes = ::PayKit::Protocols::X402::Protocol::Schemes::Upto + NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + MAX = 100_000 + + def setup + seed = "\x07".b * 32 + signing = ::Ed25519::SigningKey.new(seed) + @operator = base58(signing.verify_key.to_bytes.bytes) + @operator_secret = JSON.generate(seed.bytes + signing.verify_key.to_bytes.bytes) + @payer = pubkey(2) + @payee = pubkey(3) + @mint = PROGRAMS::MINTS.fetch("USDC").fetch("devnet") + @token_program = PROGRAMS::TOKEN_PROGRAM + end + + # ---- happy paths ------------------------------------------------------- + def test_verify_open_binds_channel + open = verify(salt: 1) + assert_equal channel_id(1), open.channel_id + assert_equal MAX, open.deposit + assert_equal MAX, open.max_amount + assert_equal @payer, open.payer + assert_equal @operator, open.rent_payer + end + + def test_settle_actual_nonzero + engine, header, = build_case(salt: 2) + response = engine.settle_actual(engine.verify_open(header, now: now), 50_000) + + assert_equal true, response["success"] + assert_equal "50000", response["amount"] + assert_equal @payer, response["payer"] + refute_empty response["transaction"] + end + + def test_settle_actual_zero_is_honored + engine, header, = build_case(salt: 3) + response = engine.settle_actual(engine.verify_open(header, now: now), 0) + + assert_equal true, response["success"] + assert_equal "0", response["amount"] + end + + def test_settle_actual_rejects_above_ceiling + engine, header, = build_case(salt: 4) + open = engine.verify_open(header, now: now) + error = assert_raises(::PayKit::Protocols::X402::Error::PaymentInvalid) { engine.settle_actual(open, MAX + 1) } + + assert_equal UptoTypes::ERROR_SETTLEMENT_EXCEEDS_AMOUNT, error.message + end + + # ---- payload rejects --------------------------------------------------- + def test_rejects_wrong_profile + assert_reject("invalid payload type") { verify(salt: 5, payload: {"profile" => "permit"}) } + end + + def test_rejects_amount_mismatch + assert_reject("amount mismatch") { verify(salt: 6, payload: {"maxAmount" => "999"}) } + end + + def test_rejects_deposit_not_equal_max + assert_reject("must equal the authorized maximum") { verify(salt: 7, payload: {"deposit" => "50000"}) } + end + + def test_rejects_expired + assert_reject("expired") { verify(salt: 8, now_override: 5_000_000_000) } + end + + def test_rejects_not_yet_valid + assert_reject("not yet active") { verify(salt: 9, payload: {"validAfter" => now + 10_000}) } + end + + def test_rejects_authorized_signer_not_operator + assert_reject("authorized_signer must be the operator") { verify(salt: 10, payload: {"authorizedSigner" => pubkey(99)}) } + end + + def test_rejects_missing_open_transaction + assert_reject("requires openTransaction") { verify(salt: 11, payload: {"openTransaction" => ""}) } + end + + def test_rejects_network_mismatch + assert_reject("network mismatch") { verify(salt: 12, network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") } + end + + # ---- open-instruction rejects ----------------------------------------- + def test_rejects_wrong_open_program + assert_reject("unexpected program") { verify(salt: 13, open_program: PROGRAMS::SYSTEM_PROGRAM) } + end + + def test_rejects_wrong_open_account + assert_reject("payee mismatch") { verify(salt: 14, open_payee: pubkey(77)) } + end + + # ---- channel rejects --------------------------------------------------- + def test_rejects_channel_not_open + assert_reject("not open") { verify(salt: 15, channel: {status: 1}) } + end + + def test_rejects_mint_mismatch + assert_reject("token mint mismatch") { verify(salt: 16, channel: {mint: pubkey(50)}) } + end + + def test_rejects_payee_mismatch + assert_reject("recipient mismatch") { verify(salt: 17, channel: {payee: pubkey(51)}) } + end + + def test_rejects_non_empty_distribution + assert_reject("empty-recipient") { verify(salt: 18, channel: {distribution_hash: "\x01".b * 32}) } + end + + def test_rejects_channel_authorized_signer_mismatch + assert_reject("authorized_signer is not the operator") { verify(salt: 19, channel: {authorized_signer: pubkey(52)}) } + end + + def test_rejects_deposit_mismatch + assert_reject("on-chain deposit") { verify(salt: 20, channel: {deposit: 1}) } + end + + def test_rejects_payer_mismatch + assert_reject("does not match payload.from") { verify(salt: 21, channel: {payer: pubkey(53)}) } + end + + # ---- envelope + concurrency ------------------------------------------- + def test_rejects_non_upto_scheme + engine, = build_case(salt: 22) + bad = Base64.strict_encode64(JSON.generate({"scheme" => "exact", "payload" => {}})) + assert_raises(::PayKit::Protocols::X402::Error::InvalidPayloadType) { engine.verify_open(bad, now: now) } + end + + def test_in_flight_guard_blocks_concurrent_same_channel + engine, header, = build_case(salt: 23) + engine.verify_open(header, now: now) # holds the reservation (not settled) + assert_reject("already being processed") { engine.verify_open(header, now: now) } + end + + private + + def now = 1_000_000 + + def channel_id(salt) + PC.find_channel_pda(payer: @payer, payee: @payee, mint: @mint, authorized_signer: @operator, salt: salt) + end + + # Build [engine, header, channel] for a fresh salt. Knobs let each test + # corrupt exactly one input. + def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil) + cid = channel_id(salt) + header = open_header(salt: salt, cid: cid, network: network, payload: payload, + open_program: open_program, open_payee: open_payee || @payee) + fake = fake_channel(channel) + engine = ::PayKit::Protocols::X402::Server::Upto.new( + Upto::Config.new( + rpc_url: "http://localhost:8899", pay_to: @payee, facilitator_secret_key: @operator_secret, + amount: MAX.to_s, mint: @mint, network: NETWORK, token_program: @token_program, + transaction_sender: ->(_c, _b) { "SiGnAtUrE1111111111111111111111111111111111" }, + signature_confirmer: ->(_c, sig) { sig }, channel_fetcher: ->(_c, _id) { fake }, + recent_blockhash_provider: -> { pubkey(9) } + ) + ) + [engine, header, fake] + end + + def verify(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, now_override: nil) + engine, header, = build_case(salt: salt, payload: payload, channel: channel, network: network, + open_program: open_program, open_payee: open_payee) + engine.verify_open(header, now: now_override || now) + end + + def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:) + payer_token = ::PayCore::Solana::ATA.derive(owner: @payer, mint: @mint, token_program: @token_program) + channel_token = ::PayCore::Solana::ATA.derive(owner: cid, mint: @mint, token_program: @token_program) + ea = PC.find_event_authority_pda + accounts = [ + AM.signer_writable(@payer), AM.signer_writable(@operator), AM.readonly(open_payee), AM.readonly(@mint), + AM.readonly(@operator), AM.writable(cid), AM.writable(payer_token), AM.writable(channel_token), + AM.readonly(@token_program), AM.readonly(PC::SYSTEM_PROGRAM), AM.readonly(PC::RENT_SYSVAR), + AM.readonly(PC::ASSOCIATED_TOKEN_PROGRAM), AM.readonly(ea), AM.readonly(PC::PROGRAM_ID) + ] + data = [1].pack("C") + PC.u64_le(salt) + PC.u64_le(MAX) + PC.u32_le(900) + PC.u32_le(0) + tx = MB.build_legacy(fee_payer: @operator, recent_blockhash: pubkey(9), + instructions: [::PayCore::Solana::PreparedInstruction.new(open_program, accounts, data)]) + body = { + "profile" => "payment-channel", "from" => @payer, "maxAmount" => MAX.to_s, "expiresAt" => 4_102_444_800, + "validAfter" => 0, "nonce" => "n#{salt}", "channelId" => cid, "deposit" => MAX.to_s, + "authorizedSigner" => @operator, "openTransaction" => tx.to_base64 + }.merge(payload) + Base64.strict_encode64(JSON.generate({"x402Version" => 2, "scheme" => "upto", "network" => network, "payload" => body})) + end + + def fake_channel(overrides) + defaults = { + status: 0, deposit: MAX, mint: @mint, payee: @payee, distribution_hash: PC::EMPTY_DISTRIBUTION_HASH, + authorized_signer: @operator, rent_payer: @operator, payer: @payer, settled: 0 + } + PC::Channel.new(**defaults.merge(overrides)) + end + + def assert_reject(fragment) + error = assert_raises(::PayKit::Protocols::X402::Error::PaymentInvalid) { yield } + assert_includes error.message, fragment + end +end diff --git a/ruby/test/pay_kit/usage_sinatra_test.rb b/ruby/test/pay_kit/usage_sinatra_test.rb new file mode 100644 index 000000000..4a936eaff --- /dev/null +++ b/ruby/test/pay_kit/usage_sinatra_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "base64" +require "json" +require "rack/test" + +require_relative "../test_helper" +require "pay_kit/usage/sinatra" + +class UsageSinatraTest < Minitest::Test + include Rack::Test::Methods + + # Engine double mounted by the Sinatra extension. + class StubEngine + Open = Struct.new(:max_amount) + + def verify_open(_header) + Open.new(100_000) + end + + def settle_actual(_open, actual) + {"success" => true, "transaction" => "SiG#{actual}", "network" => "n", "amount" => actual.to_s} + end + + def payment_required(resource:) + "CHALLENGE:#{resource}" + end + end + + ENGINE = StubEngine.new + + class DemoApp < ::Sinatra::Base + set :environment, :test + set :show_exceptions, false + register ::PayKit::Usage::Sinatra + require_usage engine: ENGINE, resource_path: "/usage", settlement_header: "x-payment-settlement-signature" + + get "/usage" do + content_type :json + usage_charge.charge(50_000) + JSON.generate(ok: true, paid: true) + end + end + + def app + DemoApp + end + + def test_challenge_without_payment + get "/usage" + assert_equal 402, last_response.status + assert_equal "CHALLENGE:/usage", last_response.headers[::PayKit::Protocols::X402::Constants::PAYMENT_REQUIRED_HEADER] + end + + def test_settles_and_serves_with_payment + get "/usage", {}, {"HTTP_PAYMENT_SIGNATURE" => "HDR"} + + assert_equal 200, last_response.status + assert_equal "SiG50000", last_response.headers["x-payment-settlement-signature"] + settlement = JSON.parse(Base64.strict_decode64(last_response.headers[::PayKit::Protocols::X402::Constants::PAYMENT_RESPONSE_HEADER])) + assert_equal "50000", settlement["amount"] + assert_equal true, JSON.parse(last_response.body)["paid"] + end +end diff --git a/ruby/test/pay_kit/usage_test.rb b/ruby/test/pay_kit/usage_test.rb new file mode 100644 index 000000000..87c582d04 --- /dev/null +++ b/ruby/test/pay_kit/usage_test.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "base64" +require "json" +require "stringio" + +require_relative "../test_helper" +require "pay_kit/usage" + +class UsageChargeTest < Minitest::Test + Charge = ::PayKit::Usage::Charge + + def test_starts_at_zero + assert_equal 0, Charge.new(100).settled_base_units + end + + def test_accumulates + charge = Charge.new(100) + charge.charge(30) + charge.charge(20) + assert_equal 50, charge.settled_base_units + end + + def test_clamps_to_ceiling + charge = Charge.new(100) + charge.charge(250) + assert_equal 100, charge.settled_base_units + end + + def test_floors_at_zero + charge = Charge.new(100) + charge.charge(-10) + assert_equal 0, charge.settled_base_units + end + + def test_exposes_max + assert_equal 100, Charge.new(100).max_base_units + end + + def test_deliver_policy_fail_closes_on_zero + refute ::PayKit::Usage.deliver?(0) + assert ::PayKit::Usage.deliver?(1) + end +end + +class UsageMiddlewareTest < Minitest::Test + Constants = ::PayKit::Protocols::X402::Constants + + # Minimal engine double: records verify_open / settle_actual calls. + class FakeEngine + attr_reader :settled_with + attr_accessor :raise_on_verify + + Open = Struct.new(:max_amount) + + def initialize(raise_on_verify: nil) + @raise_on_verify = raise_on_verify + @settled_with = [] + end + + def verify_open(header) + raise @raise_on_verify if @raise_on_verify + + @verified_header = header + Open.new(100_000) + end + + def settle_actual(_open, actual) + @settled_with << actual + {"success" => true, "transaction" => "SiG#{actual}", "network" => "n", "amount" => actual.to_s} + end + + def payment_required(resource:) + "CHALLENGE:#{resource}" + end + end + + def setup + @engine = FakeEngine.new + @app = ->(env) { + env[::PayKit::Usage::CHARGE_ENV_KEY]&.charge(env.fetch("test.meter", 0)) + [200, {"content-type" => "application/json"}, [JSON.generate({ok: true})]] + } + @mw = ::PayKit::Usage::Middleware.new(@app, engine: @engine, resource_path: "/usage", + settlement_header: "x-payment-settlement-signature") + end + + def test_no_payment_returns_402_challenge + status, headers, = @mw.call(env_for("/usage")) + assert_equal 402, status + assert_equal "CHALLENGE:/usage", headers[Constants::PAYMENT_REQUIRED_HEADER] + end + + def test_paid_nonzero_settles_and_delivers + status, headers, body = @mw.call(env_for("/usage", header: "HDR", meter: 50_000)) + + assert_equal 200, status + assert_equal [50_000], @engine.settled_with + assert headers.key?(Constants::PAYMENT_RESPONSE_HEADER) + assert_equal "SiG50000", headers["x-payment-settlement-signature"] + settlement = JSON.parse(Base64.strict_decode64(headers[Constants::PAYMENT_RESPONSE_HEADER])) + assert_equal "50000", settlement["amount"] + assert_equal JSON.generate({ok: true}), body.join + end + + def test_zero_charge_fails_closed_but_still_settles + status, headers, = @mw.call(env_for("/usage", header: "HDR", meter: 0)) + + assert_equal 402, status + assert_equal [0], @engine.settled_with, "engine still settles 0 on-chain to close the channel" + assert_equal "CHALLENGE:/usage", headers[Constants::PAYMENT_REQUIRED_HEADER] + end + + def test_meter_above_ceiling_settles_at_ceiling + @mw.call(env_for("/usage", header: "HDR", meter: 10_000_000)) + assert_equal [100_000], @engine.settled_with + end + + def test_passes_through_other_paths + status, _, body = @mw.call(env_for("/other")) + assert_equal 200, status + assert_equal JSON.generate({ok: true}), body.join + end + + def test_detects_legacy_x_payment_header + status, = @mw.call(env_for("/usage", legacy_header: "HDR", meter: 1)) + assert_equal 200, status + end + + def test_verify_failure_returns_402 + @engine.raise_on_verify = ::PayKit::Protocols::X402::Error::PaymentInvalid.new("bad open") + status, headers, body = @mw.call(env_for("/usage", header: "HDR")) + + assert_equal 402, status + assert_equal "bad open", JSON.parse(body.join)["invalidReason"] + assert headers.key?(Constants::PAYMENT_REQUIRED_HEADER) + end + + private + + def env_for(path, header: nil, legacy_header: nil, meter: 0) + env = { + "REQUEST_METHOD" => "GET", "PATH_INFO" => path, "QUERY_STRING" => "", + "rack.input" => StringIO.new(""), "test.meter" => meter + } + env["HTTP_PAYMENT_SIGNATURE"] = header if header + env["HTTP_X_PAYMENT"] = legacy_header if legacy_header + env + end +end From 52024dac1f84a20f794f33c1b79e10f94de87b78 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:29:40 +0300 Subject: [PATCH 03/17] feat(ruby): wire x402 upto into the harness and CI 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. --- .github/workflows/ruby.yml | 77 ++++++++++++++++++++++++++++++++++ harness/ruby-server/server.rb | 60 ++++++++++++++++++++++++-- harness/src/implementations.ts | 19 +++++++++ ruby/README.md | 42 ++++++++++++++++++- 4 files changed, 193 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 04d1ec99a..59aee3c4a 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -77,3 +77,80 @@ jobs: X402_HARNESS_CLIENTS: rust-x402 X402_HARNESS_SERVERS: "" run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "ruby" --testTimeout 180000 + + harness-ruby-upto: + name: "Harness: Ruby x402 upto (payment-channel)" + needs: test-ruby + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: ruby + - uses: ./.github/actions/setup-harness + with: + # Ruby is server-only for x402; the canonical Rust client builds and + # broadcasts the channel `open` this server settles against. + cargo-bins: "solana-x402:harness_upto_client" + cargo-cache-key: ruby + - name: Checkout payment channels program + uses: actions/checkout@v5 + with: + repository: Moonsong-Labs/solana-payment-channels + ref: d1dee6b34d45d4e4a1ed3174ef421ca2e801aaea + path: _payment-channels + fetch-depth: 1 + - name: Install Anza CLI + run: | + sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)" + echo "$HOME/.local/share/solana/install/active_release/bin" >> "$GITHUB_PATH" + - name: Patch payment channels localnet artifact + working-directory: _payment-channels + run: | + old_program_id="CQAyft83tN1w2bRofB5PZ79eVDU2xZUVo43LU1qL4zRg" + new_program_id="CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX" + sentinel_file="program/payment_channels/src/constants.rs" + sentinel_prefix=" 0xb0, 0x41, 0xd9, 0xd3, 0x37, 0xb7, 0x21, 0xbe, 0x57, 0x89, 0x4e, 0xb6, 0x9c, 0x3b, 0x68, 0x09," + sentinel_suffix=" 0xa5, 0x3a, 0x0e, 0x2b, 0x6a, 0x23, 0x99, 0xfc, 0x7d, 0x5b, 0x7e, 0xda, 0x8c, 0xac, 0x89, 0xaa," + grep_args=(--exclude-dir=.git --exclude-dir=target) + mapfile -t id_files < <(grep -RIl "${grep_args[@]}" "$old_program_id" .) + if [ "${#id_files[@]}" -gt 0 ]; then + sed -i "s/$old_program_id/$new_program_id/g" "${id_files[@]}" + fi + perl -0pi -e 's/const TREASURY_OWNER_SENTINEL: \[u8; 32\] = \[\n.*?\n\];/const TREASURY_OWNER_SENTINEL: [u8; 32] = [\n 0xb0, 0x41, 0xd9, 0xd3, 0x37, 0xb7, 0x21, 0xbe, 0x57, 0x89, 0x4e, 0xb6, 0x9c, 0x3b, 0x68, 0x09,\n 0xa5, 0x3a, 0x0e, 0x2b, 0x6a, 0x23, 0x99, 0xfc, 0x7d, 0x5b, 0x7e, 0xda, 0x8c, 0xac, 0x89, 0xaa,\n];/s' "$sentinel_file" + if ! grep -R "${grep_args[@]}" "$new_program_id" . >/dev/null; then + echo "::error::payment-channels program id patch did not write $new_program_id" + exit 1 + fi + if grep -R "${grep_args[@]}" "$old_program_id" . >/dev/null; then + echo "::error::payment-channels program id patch left $old_program_id in the checkout" + exit 1 + fi + if ! grep -Fq "$sentinel_prefix" "$sentinel_file" || ! grep -Fq "$sentinel_suffix" "$sentinel_file"; then + echo "::error::payment-channels treasury sentinel patch did not apply" + exit 1 + fi + - name: Build payment channels program + working-directory: _payment-channels/program/payment_channels + run: cargo build-sbf --tools-version v1.52 --arch v1 + - name: Run Ruby x402 upto harness matrix + working-directory: harness + env: + # Ruby upto server under test, paired against the canonical Rust upto + # client. The usage middleware fail-closes on a zero charge, so the + # Ruby server runs only x402-upto-basic; x402-upto-zero-actual stays + # pinned to the low-level Rust server in the Go/Rust jobs. + MPP_HARNESS_INTENTS: x402-upto + MPP_HARNESS_SCENARIOS: x402-upto-basic + MPP_HARNESS_CLIENTS: rust-x402-upto + MPP_HARNESS_SERVERS: ruby-x402-upto + X402_HARNESS_CLIENTS: rust-x402-upto + X402_HARNESS_SERVERS: ruby-x402-upto + # Localnet SBF artifact: the mainnet deployment uses BPF instructions + # unsupported by the embedded surfnet. + PAYMENT_CHANNELS_PROGRAM_SO: ../_payment-channels/target/deploy/payment_channels.so + PAYMENT_CHANNELS_PROGRAM_ID: CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX + SURFPOOL_DATASOURCE_RPC_URL: ${{ secrets.SURFPOOL_DATASOURCE_RPC_URL }} + run: pnpm exec vitest run test/e2e.test.ts --testTimeout 180000 diff --git a/harness/ruby-server/server.rb b/harness/ruby-server/server.rb index 7448f8126..bdc30a5c0 100644 --- a/harness/ruby-server/server.rb +++ b/harness/ruby-server/server.rb @@ -21,6 +21,7 @@ require "stringio" require_relative "../../ruby/lib/solana_pay_kit" +require "pay_kit/usage" # --- env helpers ------------------------------------------------------- @@ -46,7 +47,12 @@ def optional_env(name, default) # namespace probing alone is ambiguous). Otherwise the adapter falls # back to "exactly one namespace must be populated". explicit_protocol = ENV["PAY_KIT_HARNESS_PROTOCOL"].to_s.strip.downcase +upto_active = false case explicit_protocol +when "x402-upto" + x402_active = false + mpp_active = false + upto_active = true when "x402" x402_active = true mpp_active = false @@ -57,11 +63,10 @@ def optional_env(name, default) x402_active = !ENV["X402_HARNESS_RPC_URL"].to_s.empty? mpp_active = !ENV["MPP_HARNESS_RPC_URL"].to_s.empty? if x402_active == mpp_active - warn "ruby-server: set exactly one of X402_HARNESS_RPC_URL / MPP_HARNESS_RPC_URL, or set PAY_KIT_HARNESS_PROTOCOL=x402|mpp" + warn "ruby-server: set exactly one of X402_HARNESS_RPC_URL / MPP_HARNESS_RPC_URL, or set PAY_KIT_HARNESS_PROTOCOL=x402|x402-upto|mpp" exit 2 end end -protocol = x402_active ? :x402 : :mpp # --- per-protocol setup ------------------------------------------------- @@ -102,6 +107,46 @@ def optional_env(name, default) PayKit.pricing = pricing_class.new dispatcher = PayKit::Rack::Dispatcher.new(config: PayKit.config, pricing: PayKit.pricing) +elsif upto_active + # --- x402 upto (payment-channel) wiring ----------------------------- + # One umbrella binary, third protocol. The orchestrator sets + # PAY_KIT_HARNESS_PROTOCOL=x402-upto and overrides X402_HARNESS_PAY_TO / + # FACILITATOR_SECRET_KEY to the channel-custody keypair; we mount the + # public Usage middleware (channel open-verify + voucher settle-after) + # exactly as an application would. + rpc_url = require_env("X402_HARNESS_RPC_URL") + pay_to = require_env("X402_HARNESS_PAY_TO") + facilitator_secret = require_env("X402_HARNESS_FACILITATOR_SECRET_KEY") + amount_raw = require_env("X402_HARNESS_AMOUNT") + mint_raw = require_env("X402_HARNESS_MINT") + network_raw = optional_env("X402_HARNESS_NETWORK", ::PayCore::Solana::Caip2::DEVNET) + resource_path = optional_env("X402_HARNESS_RESOURCE_PATH", "/usage") + settlement_header = optional_env("X402_HARNESS_SETTLEMENT_HEADER", "x-payment-settlement-signature") + channel_program = optional_env("PAYMENT_CHANNELS_PROGRAM_ID", ::PayCore::Solana::PaymentChannels::PROGRAM_ID) + # The metered actual the handler bills; the e2e scenario sets it below the + # ceiling so settle lowers the charge from the authorized maximum. + upto_actual_amount = Integer(optional_env("X402_HARNESS_ACTUAL_AMOUNT", amount_raw), 10) + + upto_engine = ::PayKit::Protocols::X402::Server::Upto.new( + ::PayKit::Protocols::X402::Server::Upto::Config.new( + rpc_url: rpc_url, + pay_to: pay_to, + facilitator_secret_key: facilitator_secret, + amount: amount_raw, + mint: mint_raw, + network: network_raw, + channel_program: channel_program, + resource_path: resource_path, + settlement_header: settlement_header + ) + ) + upto_app = lambda do |env| + env[::PayKit::Usage::CHARGE_ENV_KEY]&.charge(upto_actual_amount) + [200, {"content-type" => "application/json"}, [JSON.generate({ok: true, paid: true, protocol: "x402-upto"})]] + end + upto_middleware = ::PayKit::Usage::Middleware.new( + upto_app, engine: upto_engine, resource_path: resource_path, settlement_header: settlement_header + ) else # --- MPP direct-mode wiring ----------------------------------------- @@ -218,7 +263,7 @@ def rack_env_for(req, port) implementation: "ruby", role: "server", port: port, - capabilities: [x402_active ? "exact" : "charge"] + capabilities: [x402_active ? "exact" : (upto_active ? "upto" : "charge")] }) + "\n") $stdout.flush @@ -279,6 +324,13 @@ def rack_env_for(req, port) end end +# Per-request handler for the x402 upto path (PayKit::Usage middleware). +serve_upto = proc do |conn, req| + status, headers, body = upto_middleware.call(rack_env_for(req, port)) + payload = body.respond_to?(:join) ? body.join : body.to_s + write_response(conn, status, headers, payload) +end + loop do begin conn = listener.accept @@ -314,6 +366,8 @@ def rack_env_for(req, port) if x402_active serve_x402.call(conn, req) + elsif upto_active + serve_upto.call(conn, req) else serve_mpp.call(conn, req) end diff --git a/harness/src/implementations.ts b/harness/src/implementations.ts index b57dfd95e..fe79a6397 100644 --- a/harness/src/implementations.ts +++ b/harness/src/implementations.ts @@ -481,4 +481,23 @@ export const serverImplementations: ImplementationDefinition[] = [ intents: ["x402-upto"], reportsAs: "python", }, + { + id: "ruby-x402-upto", + label: "Ruby PayKit x402 upto server", + role: "server", + // Same umbrella binary as the `ruby` entry; the orchestrator sets + // PAY_KIT_HARNESS_PROTOCOL=x402-upto for the upto intent so it mounts the + // PayKit::Usage middleware (channel open + voucher settle). The usage + // middleware fail-closes on a zero charge, so the `x402-upto-zero-actual` + // scenario (serverIds: ["rust-x402-upto"]) excludes this server, matching + // go-paykit and python-x402-upto. + command: [ + "sh", + "-c", + "cd ../ruby && bundle exec ruby ../harness/ruby-server/server.rb", + ], + enabled: isEnabled("ruby-x402-upto", "X402_HARNESS_SERVERS", false), + intents: ["x402-upto"], + reportsAs: "ruby", + }, ]; diff --git a/ruby/README.md b/ruby/README.md index a9417a228..055f17924 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -12,7 +12,7 @@ and the [Machine Payments Protocol](https://paymentauth.org). Sinatra and Rails ride on top of a pure Rack middleware. [![Ruby](https://img.shields.io/badge/ruby-3.2%2B-red)]() -[![Coverage](https://img.shields.io/badge/coverage-96%25-brightgreen)]() +[![Coverage](https://img.shields.io/badge/coverage-97%25-brightgreen)]() [![Branch coverage](https://img.shields.io/badge/branch%20coverage-90%25-brightgreen)]() --- @@ -192,9 +192,47 @@ Supported on the Ruby server: | Intent | Status | |--------------------|--------| | `exact` | ✅ | -| `upto` | — | +| `upto` | ✅ | | `batch-settlement` | — | +### x402 `upto` (usage-based) + +`upto` lets a client authorize a **maximum** while the server settles the +**actual** metered amount (`actual ≤ max`) after the resource is consumed — +LLM token billing, per-byte metering, dynamic compute pricing. Ruby implements +the normative `payment-channel` profile: the client opens a channel whose +escrow `deposit` is the ceiling and whose `authorizedSigner` is the operator; +the facilitator broadcasts the open, serves the resource, then settles a single +voucher for the actual amount and refunds the unused remainder when the channel +is finalized. + +Mount it with the usage middleware and meter consumption into the per-request +`Charge`: + +```ruby +require "pay_kit/usage" + +engine = PayKit::Protocols::X402::Server::Upto.new( + PayKit::Protocols::X402::Server::Upto::Config.new( + rpc_url: rpc_url, pay_to: recipient, facilitator_secret_key: operator_secret, + amount: "100000", mint: usdc_mint, network: network + ) +) + +class App < Sinatra::Base + register PayKit::Usage::Sinatra + require_usage engine: ENGINE, resource_path: "/usage" + + get "/usage" do + usage_charge.charge(metered_base_units) # actual ≤ the authorized ceiling + json(ok: true) + end +end +``` + +A zero charge fail-closes (the channel still settles 0 on-chain and closes, but +the response is a 402 with no resource body). + ## MPP The [Machine Payments Protocol](https://paymentauth.org) is the broader From 96e76d01e061c9518e335461eb449fc8f35915bb Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:40:27 +0300 Subject: [PATCH 04/17] fix(ruby): harden upto open-arg validation and usage settle-after Address review findings on PR #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. --- .../x402/protocol/schemes/upto/verify.rb | 31 +++++++++- .../lib/pay_kit/protocols/x402/server/upto.rb | 2 +- ruby/lib/pay_kit/usage.rb | 48 +++++++++++++-- .../protocols/x402/server_upto_test.rb | 30 ++++++++-- ruby/test/pay_kit/usage_test.rb | 59 ++++++++++++++++++- 5 files changed, 155 insertions(+), 15 deletions(-) diff --git a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb index 1c3094ad1..8a03b6972 100644 --- a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb @@ -82,7 +82,7 @@ def assert_within_ceiling!(actual, max) # instruction targeting the advertised channel program, with all 14 # accounts in the exact program order. Mirrors # validateUptoOpenInstruction (upto.go:660-747). - def validate_open_instruction!(transaction, program_id:, operator:, payer:, payee:, mint:, token_program:, channel_id:) + def validate_open_instruction!(transaction, program_id:, operator:, payer:, payee:, mint:, token_program:, channel_id:, max:) keys = transaction.message.account_keys instructions = transaction.message.instructions unless instructions.length == 1 @@ -114,6 +114,35 @@ def validate_open_instruction!(transaction, program_id:, operator:, payer:, paye expect(instruction, keys, OPEN_ASSOCIATED_TOKEN_PROGRAM, PaymentChannels::ASSOCIATED_TOKEN_PROGRAM, "associated_token_program") expect(instruction, keys, OPEN_EVENT_AUTHORITY, event_authority, "event_authority") expect(instruction, keys, OPEN_SELF_PROGRAM, program_id, "self_program") + + validate_open_args!(instruction.data, max: max, payer: payer, payee: payee, mint: mint, + operator: operator, channel_id: channel_id, program_id: program_id) + end + + # Validate the OpenArgs the client signed before the operator spends a + # fee broadcasting them. The account list passing does not bind the + # args: a client could match every account yet sign a bad salt, + # deposit, or distribution, forcing the operator to broadcast a + # channel that verify_open then rejects on-chain (operator fee grief). + # OpenArgs Borsh: salt(u64) deposit(u64) gracePeriod(u32) recipients(vec). + def validate_open_args!(data, max:, payer:, payee:, mint:, operator:, channel_id:, program_id:) + raise reject("open instruction data is too short for OpenArgs") if data.bytesize < 25 + + salt = PaymentChannels.read_u64_le(data, 1) + deposit = PaymentChannels.read_u64_le(data, 9) + recipients_count = PaymentChannels.read_u32_le(data, 21) + unless deposit == max + raise reject("open deposit #{deposit} must equal the authorized maximum #{max}") + end + unless recipients_count.zero? + raise reject("x402 upto requires an empty-recipient open (got #{recipients_count} splits)") + end + derived = PaymentChannels.find_channel_pda( + payer: payer, payee: payee, mint: mint, authorized_signer: operator, salt: salt, program_id: program_id + ) + unless derived == channel_id + raise reject("open salt does not derive the payload channelId") + end end # Verify the operator is the fee payer (account index 0) on the open diff --git a/ruby/lib/pay_kit/protocols/x402/server/upto.rb b/ruby/lib/pay_kit/protocols/x402/server/upto.rb index 39031fe1e..62344a340 100644 --- a/ruby/lib/pay_kit/protocols/x402/server/upto.rb +++ b/ruby/lib/pay_kit/protocols/x402/server/upto.rb @@ -194,7 +194,7 @@ def verify_open(header, now: Time.now.to_i) transaction, program_id: program_id, operator: operator, payer: payer, payee: @config.pay_to, mint: @config.mint, token_program: @config.token_program, - channel_id: channel_id + channel_id: channel_id, max: parsed[:max] ) unless Verifier.fee_payer?(transaction, operator) raise reject("open transaction fee payer must be the advertised operator") diff --git a/ruby/lib/pay_kit/usage.rb b/ruby/lib/pay_kit/usage.rb index 48443385f..c477c2cbe 100644 --- a/ruby/lib/pay_kit/usage.rb +++ b/ruby/lib/pay_kit/usage.rb @@ -78,19 +78,38 @@ def call(env) header = payment_header(env) return challenge(env) if header.nil? || header.empty? - open = @engine.verify_open(header) + # Phase 3 (before the resource): nothing was served and nothing + # settled, so a 402 re-challenge is the correct response to a failure. + begin + open = @engine.verify_open(header) + rescue => error + return challenge(env, error: error.message) + end + charge = Charge.new(open.max_amount) env[CHARGE_ENV_KEY] = charge - status, headers, body = @app.call(env) settled = charge.settled_base_units - settlement = @engine.settle_actual(open, settled) - return challenge(env) unless Usage.deliver?(settled) + # Phase 4 (after the resource): settlement may broadcast on-chain, so a + # failure here must NOT tell the client to pay again — that risks a + # double charge if the settle transaction later lands. It is a server + # error, and the buffered resource body is dropped (and closed). + begin + settlement = @engine.settle_actual(open, settled) + rescue => error + close_body(body) + return settle_error(error) + end + + # Fail-closed on a zero charge: the channel still settled 0 on-chain + # (closed, full refund), but no resource body is delivered. + unless Usage.deliver?(settled) + close_body(body) + return challenge(env) + end [status, settlement_headers(headers, settlement), body] - rescue => error - challenge(env, error: error.message) end private @@ -100,6 +119,23 @@ def payment_header(env) env["HTTP_X_PAYMENT"] end + # Close a Rack body we are about to drop so streaming/file/BodyProxy + # bodies do not leak their underlying resources. + def close_body(body) + body.close if body.respond_to?(:close) + end + + # A post-resource settlement failure: report a server error, never a 402, + # so the client is not told to retry a payment that may already be + # settling on-chain. + def settle_error(error) + [ + 502, + {"content-type" => "application/json"}, + [JSON.generate({"error" => "settlement_failed", "message" => error.message})] + ] + end + def settlement_headers(headers, settlement) result = headers.dup result[Constants::PAYMENT_RESPONSE_HEADER] = UptoTypes.encode_settlement_response(settlement) diff --git a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb index 4ef454047..440987273 100644 --- a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb +++ b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb @@ -109,6 +109,19 @@ def test_rejects_wrong_open_account assert_reject("payee mismatch") { verify(salt: 14, open_payee: pubkey(77)) } end + # ---- open-args rejects (pre-broadcast, operator-fee-grief guard) ------- + def test_rejects_open_deposit_below_max + assert_reject("open deposit") { verify(salt: 30, open_deposit: 50_000) } + end + + def test_rejects_open_with_recipients + assert_reject("empty-recipient open") { verify(salt: 31, open_recipients_count: 1) } + end + + def test_rejects_open_salt_not_deriving_channel + assert_reject("does not derive the payload channelId") { verify(salt: 32, open_salt: 999) } + end + # ---- channel rejects --------------------------------------------------- def test_rejects_channel_not_open assert_reject("not open") { verify(salt: 15, channel: {status: 1}) } @@ -161,10 +174,12 @@ def channel_id(salt) # Build [engine, header, channel] for a fresh salt. Knobs let each test # corrupt exactly one input. - def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil) + def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, + open_salt: nil, open_deposit: nil, open_recipients_count: 0) cid = channel_id(salt) header = open_header(salt: salt, cid: cid, network: network, payload: payload, - open_program: open_program, open_payee: open_payee || @payee) + open_program: open_program, open_payee: open_payee || @payee, + open_salt: open_salt || salt, open_deposit: open_deposit || MAX, open_recipients_count: open_recipients_count) fake = fake_channel(channel) engine = ::PayKit::Protocols::X402::Server::Upto.new( Upto::Config.new( @@ -178,13 +193,15 @@ def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: [engine, header, fake] end - def verify(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, now_override: nil) + def verify(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, + now_override: nil, open_salt: nil, open_deposit: nil, open_recipients_count: 0) engine, header, = build_case(salt: salt, payload: payload, channel: channel, network: network, - open_program: open_program, open_payee: open_payee) + open_program: open_program, open_payee: open_payee, + open_salt: open_salt, open_deposit: open_deposit, open_recipients_count: open_recipients_count) engine.verify_open(header, now: now_override || now) end - def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:) + def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:, open_salt:, open_deposit:, open_recipients_count:) payer_token = ::PayCore::Solana::ATA.derive(owner: @payer, mint: @mint, token_program: @token_program) channel_token = ::PayCore::Solana::ATA.derive(owner: cid, mint: @mint, token_program: @token_program) ea = PC.find_event_authority_pda @@ -194,7 +211,8 @@ def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:) AM.readonly(@token_program), AM.readonly(PC::SYSTEM_PROGRAM), AM.readonly(PC::RENT_SYSVAR), AM.readonly(PC::ASSOCIATED_TOKEN_PROGRAM), AM.readonly(ea), AM.readonly(PC::PROGRAM_ID) ] - data = [1].pack("C") + PC.u64_le(salt) + PC.u64_le(MAX) + PC.u32_le(900) + PC.u32_le(0) + data = [1].pack("C") + PC.u64_le(open_salt) + PC.u64_le(open_deposit) + PC.u32_le(900) + PC.u32_le(open_recipients_count) + open_recipients_count.times { data += ::PayCore::Solana::Base58.decode(@payee) + [1].pack("v") } tx = MB.build_legacy(fee_payer: @operator, recent_blockhash: pubkey(9), instructions: [::PayCore::Solana::PreparedInstruction.new(open_program, accounts, data)]) body = { diff --git a/ruby/test/pay_kit/usage_test.rb b/ruby/test/pay_kit/usage_test.rb index 87c582d04..9ed0856a8 100644 --- a/ruby/test/pay_kit/usage_test.rb +++ b/ruby/test/pay_kit/usage_test.rb @@ -49,12 +49,13 @@ class UsageMiddlewareTest < Minitest::Test # Minimal engine double: records verify_open / settle_actual calls. class FakeEngine attr_reader :settled_with - attr_accessor :raise_on_verify + attr_accessor :raise_on_verify, :raise_on_settle Open = Struct.new(:max_amount) def initialize(raise_on_verify: nil) @raise_on_verify = raise_on_verify + @raise_on_settle = nil @settled_with = [] end @@ -67,6 +68,8 @@ def verify_open(header) def settle_actual(_open, actual) @settled_with << actual + raise @raise_on_settle if @raise_on_settle + {"success" => true, "transaction" => "SiG#{actual}", "network" => "n", "amount" => actual.to_s} end @@ -75,6 +78,24 @@ def payment_required(resource:) end end + # A Rack body that records whether the server (or middleware) closed it. + class CloseTrackingBody + attr_reader :closed + + def initialize(chunks) + @chunks = chunks + @closed = false + end + + def each(&block) + @chunks.each(&block) + end + + def close + @closed = true + end + end + def setup @engine = FakeEngine.new @app = ->(env) { @@ -136,6 +157,42 @@ def test_verify_failure_returns_402 assert headers.key?(Constants::PAYMENT_REQUIRED_HEADER) end + def test_settlement_failure_is_a_server_error_not_a_challenge + @engine.raise_on_settle = RuntimeError.new("rpc timeout") + status, headers, body = @mw.call(env_for("/usage", header: "HDR", meter: 50_000)) + + assert_equal 502, status + refute headers.key?(Constants::PAYMENT_REQUIRED_HEADER), "must not tell the client to pay again" + assert_equal "settlement_failed", JSON.parse(body.join)["error"] + end + + def test_closes_dropped_body_on_zero_charge + body = CloseTrackingBody.new([JSON.generate({ok: true})]) + app = ->(env) { + env[::PayKit::Usage::CHARGE_ENV_KEY]&.charge(0) + [200, {}, body] + } + mw = ::PayKit::Usage::Middleware.new(app, engine: @engine, resource_path: "/usage") + + status, = mw.call(env_for("/usage", header: "HDR")) + assert_equal 402, status + assert body.closed, "fail-closed path must close the dropped body" + end + + def test_closes_dropped_body_on_settlement_failure + @engine.raise_on_settle = RuntimeError.new("rpc timeout") + body = CloseTrackingBody.new([JSON.generate({ok: true})]) + app = ->(env) { + env[::PayKit::Usage::CHARGE_ENV_KEY]&.charge(50_000) + [200, {}, body] + } + mw = ::PayKit::Usage::Middleware.new(app, engine: @engine, resource_path: "/usage") + + status, = mw.call(env_for("/usage", header: "HDR")) + assert_equal 502, status + assert body.closed + end + private def env_for(path, header: nil, legacy_header: nil, meter: 0) From 2e49fb9e367477f8039b8f9e0d28b594cc76656e Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:46:05 +0300 Subject: [PATCH 05/17] fix(ruby): pin upto open grace period to the canonical default 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. --- ruby/lib/pay_core/solana/payment_channels.rb | 5 +++++ .../x402/protocol/schemes/upto/verify.rb | 4 ++++ .../pay_kit/protocols/x402/server_upto_test.rb | 18 ++++++++++++------ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ruby/lib/pay_core/solana/payment_channels.rb b/ruby/lib/pay_core/solana/payment_channels.rb index 451911502..f84211199 100644 --- a/ruby/lib/pay_core/solana/payment_channels.rb +++ b/ruby/lib/pay_core/solana/payment_channels.rb @@ -53,6 +53,11 @@ module PaymentChannels # Channel.status enum (idl channelStatus): 0 = open, 1 = finalized, 2 = closing. STATUS_OPEN = 0 + # Canonical channel close grace period in seconds, pinned across the SDKs + # (rust DEFAULT_GRACE_PERIOD_SECONDS). The on-chain program rejects zero; + # `upto` opens commit this exact value so the close timing is fixed. + DEFAULT_GRACE_PERIOD_SECONDS = 900 + # Borsh `Channel` account size: a 1-byte account discriminator followed by # the fixed fields below. The on-chain account keeps the discriminator at # offset 0 (unlike the codama-py dropped-discriminator quirk), so Ruby diff --git a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb index 8a03b6972..956886938 100644 --- a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb @@ -130,10 +130,14 @@ def validate_open_args!(data, max:, payer:, payee:, mint:, operator:, channel_id salt = PaymentChannels.read_u64_le(data, 1) deposit = PaymentChannels.read_u64_le(data, 9) + grace_period = PaymentChannels.read_u32_le(data, 17) recipients_count = PaymentChannels.read_u32_le(data, 21) unless deposit == max raise reject("open deposit #{deposit} must equal the authorized maximum #{max}") end + unless grace_period == PaymentChannels::DEFAULT_GRACE_PERIOD_SECONDS + raise reject("open grace period #{grace_period} must equal the canonical #{PaymentChannels::DEFAULT_GRACE_PERIOD_SECONDS}") + end unless recipients_count.zero? raise reject("x402 upto requires an empty-recipient open (got #{recipients_count} splits)") end diff --git a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb index 440987273..1596a884a 100644 --- a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb +++ b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb @@ -122,6 +122,10 @@ def test_rejects_open_salt_not_deriving_channel assert_reject("does not derive the payload channelId") { verify(salt: 32, open_salt: 999) } end + def test_rejects_open_grace_period_mismatch + assert_reject("grace period") { verify(salt: 33, open_grace_period: 60) } + end + # ---- channel rejects --------------------------------------------------- def test_rejects_channel_not_open assert_reject("not open") { verify(salt: 15, channel: {status: 1}) } @@ -175,11 +179,12 @@ def channel_id(salt) # Build [engine, header, channel] for a fresh salt. Knobs let each test # corrupt exactly one input. def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, - open_salt: nil, open_deposit: nil, open_recipients_count: 0) + open_salt: nil, open_deposit: nil, open_recipients_count: 0, open_grace_period: 900) cid = channel_id(salt) header = open_header(salt: salt, cid: cid, network: network, payload: payload, open_program: open_program, open_payee: open_payee || @payee, - open_salt: open_salt || salt, open_deposit: open_deposit || MAX, open_recipients_count: open_recipients_count) + open_salt: open_salt || salt, open_deposit: open_deposit || MAX, open_recipients_count: open_recipients_count, + open_grace_period: open_grace_period) fake = fake_channel(channel) engine = ::PayKit::Protocols::X402::Server::Upto.new( Upto::Config.new( @@ -194,14 +199,15 @@ def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: end def verify(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, - now_override: nil, open_salt: nil, open_deposit: nil, open_recipients_count: 0) + now_override: nil, open_salt: nil, open_deposit: nil, open_recipients_count: 0, open_grace_period: 900) engine, header, = build_case(salt: salt, payload: payload, channel: channel, network: network, open_program: open_program, open_payee: open_payee, - open_salt: open_salt, open_deposit: open_deposit, open_recipients_count: open_recipients_count) + open_salt: open_salt, open_deposit: open_deposit, open_recipients_count: open_recipients_count, + open_grace_period: open_grace_period) engine.verify_open(header, now: now_override || now) end - def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:, open_salt:, open_deposit:, open_recipients_count:) + def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:, open_salt:, open_deposit:, open_recipients_count:, open_grace_period:) payer_token = ::PayCore::Solana::ATA.derive(owner: @payer, mint: @mint, token_program: @token_program) channel_token = ::PayCore::Solana::ATA.derive(owner: cid, mint: @mint, token_program: @token_program) ea = PC.find_event_authority_pda @@ -211,7 +217,7 @@ def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:, ope AM.readonly(@token_program), AM.readonly(PC::SYSTEM_PROGRAM), AM.readonly(PC::RENT_SYSVAR), AM.readonly(PC::ASSOCIATED_TOKEN_PROGRAM), AM.readonly(ea), AM.readonly(PC::PROGRAM_ID) ] - data = [1].pack("C") + PC.u64_le(open_salt) + PC.u64_le(open_deposit) + PC.u32_le(900) + PC.u32_le(open_recipients_count) + data = [1].pack("C") + PC.u64_le(open_salt) + PC.u64_le(open_deposit) + PC.u32_le(open_grace_period) + PC.u32_le(open_recipients_count) open_recipients_count.times { data += ::PayCore::Solana::Base58.decode(@payee) + [1].pack("v") } tx = MB.build_legacy(fee_payer: @operator, recent_blockhash: pubkey(9), instructions: [::PayCore::Solana::PreparedInstruction.new(open_program, accounts, data)]) From 5df772fea04b5dd5bc4a6bbbaee3938c37535390 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:18:53 +0300 Subject: [PATCH 06/17] fix(ruby): settle and release the channel when the protected app raises 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. --- ruby/lib/pay_kit/usage.rb | 23 ++++++++++++++++++++++- ruby/test/pay_kit/usage_test.rb | 12 ++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/ruby/lib/pay_kit/usage.rb b/ruby/lib/pay_kit/usage.rb index c477c2cbe..f0d01fc25 100644 --- a/ruby/lib/pay_kit/usage.rb +++ b/ruby/lib/pay_kit/usage.rb @@ -88,7 +88,18 @@ def call(env) charge = Charge.new(open.max_amount) env[CHARGE_ENV_KEY] = charge - status, headers, body = @app.call(env) + + # The channel is now open and reserved. If the protected app raises + # before settlement runs, settle a zero charge to close the channel and + # refund the payer's full deposit — which also releases the in-flight + # reservation — then re-raise so the app's error surfaces. Without this + # the reservation would leak and the payer's deposit would stay locked. + begin + status, headers, body = @app.call(env) + rescue => app_error + release_after_app_failure(open) + raise app_error + end settled = charge.settled_base_units # Phase 4 (after the resource): settlement may broadcast on-chain, so a @@ -125,6 +136,16 @@ def close_body(body) body.close if body.respond_to?(:close) end + # The protected app raised after the channel opened. Settle a zero charge + # to close the channel and refund the payer; `settle_actual` releases the + # in-flight reservation in its own ensure even if the settle broadcast + # fails, so fall back to an explicit (idempotent) release on error. + def release_after_app_failure(open) + @engine.settle_actual(open, 0) + rescue + open.release! if open.respond_to?(:release!) + end + # A post-resource settlement failure: report a server error, never a 402, # so the client is not told to retry a payment that may already be # settling on-chain. diff --git a/ruby/test/pay_kit/usage_test.rb b/ruby/test/pay_kit/usage_test.rb index 9ed0856a8..34bec5e62 100644 --- a/ruby/test/pay_kit/usage_test.rb +++ b/ruby/test/pay_kit/usage_test.rb @@ -193,6 +193,18 @@ def test_closes_dropped_body_on_settlement_failure assert body.closed end + def test_app_exception_settles_zero_and_reraises + boom = ->(env) { + env[::PayKit::Usage::CHARGE_ENV_KEY]&.charge(40_000) + raise "app boom" + } + mw = ::PayKit::Usage::Middleware.new(boom, engine: @engine, resource_path: "/usage") + + error = assert_raises(RuntimeError) { mw.call(env_for("/usage", header: "HDR")) } + assert_equal "app boom", error.message + assert_equal [0], @engine.settled_with, "an app failure must settle 0 to close and refund the channel" + end + private def env_for(path, header: nil, legacy_header: nil, meter: 0) From bfdc857c970fff583e4350d7cffec013e43969cb Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:01:55 +0300 Subject: [PATCH 07/17] fix(ruby): advertise extra.facilitator on upto requirements 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. --- .../protocols/x402/protocol/schemes/upto/types.rb | 5 +++++ ruby/test/pay_kit/protocols/x402/server_upto_test.rb | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb index 51fcfdc1a..b00fcaf9e 100644 --- a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb @@ -84,6 +84,11 @@ def requirement(network:, amount:, asset:, pay_to:, max_timeout_seconds:, decima "profiles" => [PROFILE_PAYMENT_CHANNEL], "decimals" => decimals, "tokenProgram" => token_program, + # The operator is the fee payer. Advertise it under both keys: the + # SVM upto spec / TS @x402/svm client read `extra.facilitator`, while + # Go/Rust read `extra.feePayer`. Sending both keeps every client + # interoperable (mirrors the Python SDK). + "facilitator" => fee_payer, "feePayer" => fee_payer, "channelProgram" => channel_program } diff --git a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb index 1596a884a..303a4fb74 100644 --- a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb +++ b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb @@ -59,6 +59,18 @@ def test_settle_actual_zero_is_honored assert_equal "0", response["amount"] end + def test_requirement_advertises_facilitator_and_fee_payer + req = UptoTypes.requirement( + network: NETWORK, amount: MAX, asset: @mint, pay_to: @payee, + max_timeout_seconds: 300, decimals: 6, token_program: @token_program, + fee_payer: @operator, channel_program: "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX" + ) + extra = req["extra"] + # TS @x402/svm reads extra.facilitator; Go/Rust read extra.feePayer. + assert_equal @operator, extra["facilitator"] + assert_equal @operator, extra["feePayer"] + end + def test_settle_actual_rejects_above_ceiling engine, header, = build_case(salt: 4) open = engine.verify_open(header, now: now) From 6d1c95f2e3a2eff7007cca0d102d2c472c2bfc23 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:22:09 +0300 Subject: [PATCH 08/17] fix(ruby): align Charge to replace semantics (parity with Go/Python/TS) 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. --- ruby/lib/pay_kit/usage.rb | 7 ++++--- ruby/test/pay_kit/usage_test.rb | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/ruby/lib/pay_kit/usage.rb b/ruby/lib/pay_kit/usage.rb index f0d01fc25..821f8ef3a 100644 --- a/ruby/lib/pay_kit/usage.rb +++ b/ruby/lib/pay_kit/usage.rb @@ -34,10 +34,11 @@ def initialize(max_base_units) @settled = 0 end - # Record additional metered base units. Negative deltas floor at 0; the - # running total is capped at the ceiling. + # Record the actual metered amount, replacing any prior value; clamped to + # [0, max] so a handler can never settle above the ceiling. Last call wins, + # mirroring the Go/Python/TS Charge. def charge(amount) - @settled = (@settled + amount.to_i).clamp(0, @max_base_units) + @settled = amount.to_i.clamp(0, @max_base_units) end # The clamped amount to settle on-chain. diff --git a/ruby/test/pay_kit/usage_test.rb b/ruby/test/pay_kit/usage_test.rb index 34bec5e62..1bf2627ed 100644 --- a/ruby/test/pay_kit/usage_test.rb +++ b/ruby/test/pay_kit/usage_test.rb @@ -14,11 +14,12 @@ def test_starts_at_zero assert_equal 0, Charge.new(100).settled_base_units end - def test_accumulates + def test_replaces_with_last_value charge = Charge.new(100) charge.charge(30) charge.charge(20) - assert_equal 50, charge.settled_base_units + # Last call wins (replace), matching the Go/Python/TS Charge. + assert_equal 20, charge.settled_base_units end def test_clamps_to_ceiling From f13a7fc394c6fd60b22c71b6abcdecf36509e22b Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 30 Jun 2026 01:19:44 +0300 Subject: [PATCH 09/17] fix(ruby): generate payment-channels client with codama --- justfile | 10 +- ruby/.standard.yml | 2 + .../solana/generated/payment_channels.rb | 45 +++ .../payment_channels/accounts/channel.rb | 153 +++++++++ .../accounts/closed_channel.rb | 55 +++ .../generated/payment_channels/errors.rb | 162 +++++++++ .../instructions/distribute.rb | 107 ++++++ .../instructions/emit_event.rb | 59 ++++ .../payment_channels/instructions/finalize.rb | 59 ++++ .../payment_channels/instructions/open.rb | 114 +++++++ .../instructions/request_close.rb | 61 ++++ .../payment_channels/instructions/settle.rb | 61 ++++ .../instructions/settle_and_finalize.rb | 83 +++++ .../payment_channels/instructions/top_up.rb | 90 +++++ .../instructions/withdraw_payer.rb | 76 +++++ .../generated/payment_channels/programs.rb | 78 +++++ .../payment_channels/shared/borsh.rb | 315 ++++++++++++++++++ .../payment_channels/shared/errors.rb | 15 + .../generated/payment_channels/shared/pda.rb | 93 ++++++ .../payment_channels/shared/pubkey.rb | 77 +++++ .../types/account_discriminator.rb | 29 ++ .../payment_channels/types/channel_status.rb | 30 ++ .../payment_channels/types/distribute_args.rb | 56 ++++ .../types/distribution_entry.rb | 61 ++++ .../payment_channels/types/open_args.rb | 71 ++++ .../payment_channels/types/opened.rb | 56 ++++ .../types/payout_beneficiary.rb | 30 ++ .../types/payout_redirected.rb | 78 +++++ .../payment_channels/types/redirect_reason.rb | 31 ++ .../types/settle_and_finalize_args.rb | 55 +++ .../types/settlement_watermarks.rb | 60 ++++ .../payment_channels/types/top_up_args.rb | 55 +++ .../payment_channels/types/voucher_args.rb | 66 ++++ ruby/lib/pay_core/solana/payment_channels.rb | 140 ++++---- ruby/test/pay_core/payment_channels_test.rb | 10 +- ruby/test/test_helper.rb | 4 + .../pay-sdk-implementation/codegen/.gitignore | 1 + .../generate-payment-channels-client-rb.ts | 73 ++++ .../codegen/package.json | 3 +- 39 files changed, 2557 insertions(+), 67 deletions(-) create mode 100644 ruby/.standard.yml create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/accounts/channel.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/accounts/closed_channel.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/errors.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/instructions/distribute.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/instructions/emit_event.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/instructions/finalize.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/instructions/open.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/instructions/request_close.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/instructions/settle.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/instructions/settle_and_finalize.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/instructions/top_up.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/instructions/withdraw_payer.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/programs.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/shared/borsh.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/shared/errors.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/shared/pda.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/shared/pubkey.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/account_discriminator.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/channel_status.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/distribute_args.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/distribution_entry.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/open_args.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/opened.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/payout_beneficiary.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/payout_redirected.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/redirect_reason.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/settle_and_finalize_args.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/settlement_watermarks.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/top_up_args.rb create mode 100644 ruby/lib/pay_core/solana/generated/payment_channels/types/voucher_args.rb create mode 100644 skills/pay-sdk-implementation/codegen/generate-payment-channels-client-rb.ts diff --git a/justfile b/justfile index 8ce62388c..382beee2b 100644 --- a/justfile +++ b/justfile @@ -82,8 +82,14 @@ payment-channels-generate-ts: codegen-install payment-channels-generate-py: codegen-install cd {{codegen_dir}} && pnpm run payment-channels:python -# Full refresh: pull IDL + regenerate every client (Rust, Go, TypeScript, Python). -payment-channels-sync: payment-channels-pull-idl payment-channels-generate-rs payment-channels-generate-go payment-channels-generate-ts payment-channels-generate-py +# Render the Ruby client from the vendored IDL. Wipes +# `ruby/lib/pay_core/solana/generated/` and rewrites it in place — see +# {{codegen_dir}}/generate-payment-channels-client-rb.ts. +payment-channels-generate-rb: codegen-install + cd {{codegen_dir}} && pnpm run payment-channels:ruby + +# Full refresh: pull IDL + regenerate every client (Rust, Go, TypeScript, Python, Ruby). +payment-channels-sync: payment-channels-pull-idl payment-channels-generate-rs payment-channels-generate-go payment-channels-generate-ts payment-channels-generate-py payment-channels-generate-rb # ── TypeScript ── diff --git a/ruby/.standard.yml b/ruby/.standard.yml new file mode 100644 index 000000000..c3a1de2f4 --- /dev/null +++ b/ruby/.standard.yml @@ -0,0 +1,2 @@ +ignore: + - 'lib/pay_core/solana/generated/**/*' diff --git a/ruby/lib/pay_core/solana/generated/payment_channels.rb b/ruby/lib/pay_core/solana/generated/payment_channels.rb new file mode 100644 index 000000000..2a2a95539 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +module PayCore + module Solana + module Generated + end + end +end + +require_relative 'payment_channels/shared/errors' +require_relative 'payment_channels/shared/borsh' +require_relative 'payment_channels/shared/pubkey' +require_relative 'payment_channels/shared/pda' +require_relative 'payment_channels/programs' +require_relative 'payment_channels/errors' +require_relative 'payment_channels/types/distribute_args' +require_relative 'payment_channels/types/payout_beneficiary' +require_relative 'payment_channels/types/redirect_reason' +require_relative 'payment_channels/types/distribution_entry' +require_relative 'payment_channels/types/open_args' +require_relative 'payment_channels/types/settle_and_finalize_args' +require_relative 'payment_channels/types/top_up_args' +require_relative 'payment_channels/types/voucher_args' +require_relative 'payment_channels/types/channel_status' +require_relative 'payment_channels/types/settlement_watermarks' +require_relative 'payment_channels/types/account_discriminator' +require_relative 'payment_channels/types/opened' +require_relative 'payment_channels/types/payout_redirected' +require_relative 'payment_channels/accounts/channel' +require_relative 'payment_channels/accounts/closed_channel' +require_relative 'payment_channels/instructions/open' +require_relative 'payment_channels/instructions/settle' +require_relative 'payment_channels/instructions/top_up' +require_relative 'payment_channels/instructions/settle_and_finalize' +require_relative 'payment_channels/instructions/request_close' +require_relative 'payment_channels/instructions/finalize' +require_relative 'payment_channels/instructions/distribute' +require_relative 'payment_channels/instructions/withdraw_payer' +require_relative 'payment_channels/instructions/emit_event' diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/accounts/channel.rb b/ruby/lib/pay_core/solana/generated/payment_channels/accounts/channel.rb new file mode 100644 index 000000000..05ba34162 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/accounts/channel.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' +require_relative '../shared/pubkey' +require_relative '../types/settlement_watermarks' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `discriminator` (Integer) + # - `version` (Integer) + # - `bump` (Integer) + # - `status` (Integer) + # - `salt` (Integer) + # - `deposit` (Integer) + # - `settlement` (SettlementWatermarks) + # - `closure_started_at` (Integer) + # - `payer_withdrawn_at` (Integer) + # - `grace_period` (Integer) + # - `distribution_hash` (Array) + # - `payer` (Pubkey) + # - `payee` (Pubkey) + # - `authorized_signer` (Pubkey) + # - `mint` (Pubkey) + # - `rent_payer` (Pubkey) + class Channel + attr_reader :discriminator, :version, :bump, :status, :salt, :deposit, :settlement, + :closure_started_at, :payer_withdrawn_at, :grace_period, :distribution_hash, :payer, + :payee, :authorized_signer, :mint, :rent_payer + + def initialize( + discriminator:, + version:, + bump:, + status:, + salt:, + deposit:, + settlement:, + closure_started_at:, + payer_withdrawn_at:, + grace_period:, + distribution_hash:, + payer:, + payee:, + authorized_signer:, + mint:, + rent_payer: + ) + @discriminator = discriminator + @version = version + @bump = bump + @status = status + @salt = salt + @deposit = deposit + @settlement = settlement + @closure_started_at = closure_started_at + @payer_withdrawn_at = payer_withdrawn_at + @grace_period = grace_period + @distribution_hash = distribution_hash + @payer = payer + @payee = payee + @authorized_signer = authorized_signer + @mint = mint + @rent_payer = rent_payer + end + + def self.deserialize(reader) + fields = {} + fields[:discriminator] = reader.u8 + fields[:version] = reader.u8 + fields[:bump] = reader.u8 + fields[:status] = reader.u8 + fields[:salt] = reader.u64 + fields[:deposit] = reader.u64 + fields[:settlement] = SettlementWatermarks.deserialize(reader) + fields[:closure_started_at] = reader.i64 + fields[:payer_withdrawn_at] = reader.i64 + fields[:grace_period] = reader.u32 + fields[:distribution_hash] = reader.array(32) { reader.u8 } + fields[:payer] = reader.pubkey + fields[:payee] = reader.pubkey + fields[:authorized_signer] = reader.pubkey + fields[:mint] = reader.pubkey + fields[:rent_payer] = reader.pubkey + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(@discriminator) + writer.u8(@version) + writer.u8(@bump) + writer.u8(@status) + writer.u64(@salt) + writer.u64(@deposit) + SettlementWatermarks.serialize(writer, @settlement) + writer.i64(@closure_started_at) + writer.i64(@payer_withdrawn_at) + writer.u32(@grace_period) + writer.array(@distribution_hash, 32) { |item1| writer.u8(item1) } + writer.pubkey(@payer) + writer.pubkey(@payee) + writer.pubkey(@authorized_signer) + writer.pubkey(@mint) + writer.pubkey(@rent_payer) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + discriminator == other.discriminator && + version == other.version && + bump == other.bump && + status == other.status && + salt == other.salt && + deposit == other.deposit && + settlement == other.settlement && + closure_started_at == other.closure_started_at && + payer_withdrawn_at == other.payer_withdrawn_at && + grace_period == other.grace_period && + distribution_hash == other.distribution_hash && + payer == other.payer && + payee == other.payee && + authorized_signer == other.authorized_signer && + mint == other.mint && + rent_payer == other.rent_payer + end + alias eql? == + + def hash + [self.class, discriminator, version, bump, status, salt, deposit, settlement, closure_started_at, + payer_withdrawn_at, grace_period, distribution_hash, payer, payee, authorized_signer, mint, + rent_payer].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/accounts/closed_channel.rb b/ruby/lib/pay_core/solana/generated/payment_channels/accounts/closed_channel.rb new file mode 100644 index 000000000..e5ed65fea --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/accounts/closed_channel.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `discriminator` (Integer) + class ClosedChannel + attr_reader :discriminator + + def initialize(discriminator:) + @discriminator = discriminator + end + + def self.deserialize(reader) + fields = {} + fields[:discriminator] = reader.u8 + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(@discriminator) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + discriminator == other.discriminator + end + alias eql? == + + def hash + [self.class, discriminator].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/errors.rb b/ruby/lib/pay_core/solana/generated/payment_channels/errors.rb new file mode 100644 index 000000000..544b38c93 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/errors.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative 'shared/errors' + +module PayCore::Solana::Generated::PaymentChannels + # An error returned by the on-chain `payment_channels` program. Carries the + # numeric error code and, when the code is known, the message declared in + # the IDL. + class ProgramError < Error + attr_reader :code + + def initialize(code, message = nil) + @code = code + super(message || "payment_channels: unknown program error code #{code}") + end + end + + # Program error codes and messages. + module Errors + NOT_IMPLEMENTED = 0 + MISSING_REQUIRED_SIGNATURE = 1 + INVALID_CHANNEL_STATUS = 2 + INVALID_ACCOUNT_DISCRIMINATOR = 3 + UNSUPPORTED_CHANNEL_VERSION = 4 + INVALID_CHANNEL_PAYER = 5 + INVALID_CHANNEL_PAYEE = 6 + INVALID_CHANNEL_MINT = 7 + INVALID_EVENT_AUTHORITY = 8 + NOT_ENOUGH_ACCOUNT_KEYS = 9 + INVALID_CHANNEL_RENT_PAYER = 10 + CHANNEL_ACCOUNT_MISMATCH = 50 + INVALID_CHANNEL_TOKEN_ACCOUNT = 51 + INVALID_CHANNEL_TOKEN_EXTENSIONS = 52 + MINT_ACCOUNT_MISMATCH = 53 + INVALID_MINT_TOKEN_PROGRAM = 54 + MALFORMED_MINT_TOKEN_ACCOUNT_DATA = 55 + MALFORMED_MINT_TOKEN_EXTENSIONS = 56 + PAYER_ACCOUNT_MISMATCH = 57 + INVALID_PAYER_TOKEN_ACCOUNT = 58 + INVALID_PAYER_TOKEN_EXTENSIONS = 59 + PAYEE_ACCOUNT_MISMATCH = 60 + INVALID_PAYEE_TOKEN_ACCOUNT = 61 + INVALID_PAYEE_TOKEN_EXTENSIONS = 62 + DEPOSIT_MUST_BE_NON_ZERO = 200 + GRACE_PERIOD_MUST_BE_NON_ZERO = 201 + MISSING_ED25519_VERIFICATION = 230 + MALFORMED_ED25519_INSTRUCTION = 231 + VOUCHER_CHANNEL_MISMATCH = 232 + VOUCHER_EXPIRED = 233 + VOUCHER_WATERMARK_NOT_MONOTONIC = 234 + VOUCHER_OVER_DEPOSIT = 235 + VOUCHER_MESSAGE_MISMATCH = 236 + VOUCHER_SIGNER_MISMATCH = 237 + INVALID_RECIPIENT_COUNT = 260 + INVALID_SPLIT_CONFIG = 261 + DISTRIBUTION_PARTS_OVERFLOW = 262 + DUPLICATE_RECIPIENT = 263 + DISTRIBUTION_AMOUNT_OVERFLOW = 264 + DISTRIBUTION_PREIMAGE_LENGTH_OVERFLOW = 265 + CHANNEL_ADDRESS_MISMATCH = 2000 + PAYER_PAYEE_MUST_DIFFER = 2001 + INVALID_AUTHORIZED_SIGNER = 2002 + TOP_UP_DEPOSIT_OVERFLOW = 2100 + FINALIZE_DEADLINE_OVERFLOW = 2200 + PAYER_ALREADY_WITHDRAWN = 2300 + REFUND_CALCULATION_OVERFLOW = 2301 + CHANNEL_NOT_DISTRIBUTABLE = 2400 + TREASURY_ACCOUNT_MISMATCH = 2401 + INVALID_TREASURY_TOKEN_ACCOUNT = 2402 + INVALID_TREASURY_TOKEN_EXTENSIONS = 2403 + RECIPIENT_ACCOUNT_MISMATCH = 2404 + INVALID_RECIPIENT_TOKEN_ACCOUNT = 2405 + INVALID_RECIPIENT_TOKEN_EXTENSIONS = 2406 + INVALID_DISTRIBUTION_HASH = 2407 + NOTHING_TO_DISTRIBUTE = 2408 + RECIPIENT_ACCOUNT_COUNT_MISMATCH = 2409 + DISTRIBUTE_POOL_OVERFLOW = 2410 + DISTRIBUTE_BALANCE_CALCULATION_OVERFLOW = 2411 + DISTRIBUTE_PAYER_BALANCE_OVERFLOW = 2412 + DISTRIBUTE_TRANSFER_QUEUE_OVERFLOW = 2413 + + MESSAGES = { + 0 => 'Not implemented', + 1 => 'A signature was required but not found', + 2 => 'Invalid channel status', + 3 => 'Invalid account discriminator', + 4 => 'Unsupported channel version', + 5 => 'Account does not match channel payer', + 6 => 'Account does not match channel payee', + 7 => 'Account does not match channel mint', + 8 => 'Invalid event authority', + 9 => 'Not enough accounts were provided', + 10 => 'Account does not match channel rent_payer', + 50 => 'Channel account does not match derived PDA', + 51 => 'Channel token account is not ATA(channel, mint, token_program)', + 52 => 'Channel token account has invalid extensions', + 53 => 'Mint account does not match channel.mint', + 54 => 'Token program must be SPL Token or Token-2022', + 55 => 'Token account or mint TLV trailer is malformed', + 56 => 'Token account or mint TLV trailer is malformed', + 57 => 'Payer token account is not ATA(payer, token_program, mint)', + 58 => 'Payer token account is invalid', + 59 => 'Payer token account has invalid extensions', + 60 => 'Payee token account is not ATA(payee, token_program, mint)', + 61 => 'Payee token account is invalid', + 62 => 'Payee token account has invalid extensions', + 200 => 'Deposit must be non-zero', + 201 => 'Grace period must be non-zero', + 230 => 'Missing Ed25519 precompile ix at current-1', + 231 => 'Malformed Ed25519 precompile instruction', + 232 => 'Voucher channel_id does not match channel PDA', + 233 => 'Voucher expired', + 234 => 'Voucher watermark not strictly monotonic', + 235 => 'Voucher cumulative_amount exceeds channel deposit', + 236 => 'Reserved (formerly: Ed25519 message does not match Borsh voucher payload)', + 237 => 'Voucher signer does not match channel authorized_signer', + 260 => 'num_recipients outside [0, 32]', + 261 => 'Each shareBps must be non-zero and Σbps must be at most 10_000', + 262 => 'num_recipients outside [0, 32]', + 263 => 'Distribution plan contains a duplicate recipient address', + 264 => 'num_recipients outside [0, 32]', + 265 => 'Distribution preimage length calculation overflow', + 2000 => 'Derived channel account address does not match the user provided address', + 2001 => 'Payer and payee must be different accounts', + 2002 => 'authorized_signer must be a valid Ed25519 public key', + 2100 => 'Deposit must be non-zero', + 2200 => 'Deadline overflow on grace period', + 2300 => 'Payer refund has already been claimed', + 2301 => 'Payer refund amount calculation underflow', + 2400 => 'Channel is not in OPEN or FINALIZED', + 2401 => 'Treasury token account is not ATA(TREASURY_OWNER, mint, token_program)', + 2402 => 'Treasury token account is invalid', + 2403 => 'Treasury token account has invalid extensions', + 2404 => 'Recipient token account is not ATA(recipient, token_program, mint)', + 2405 => 'Recipient token account is invalid', + 2406 => 'Recipient token account has invalid extensions', + 2407 => 'Distribution hash mismatch', + 2408 => 'No newly settled funds to distribute', + 2409 => 'Recipient ATA tail length does not match the committed plan\'s entry count', + 2410 => 'Distribution pool calculation underflow', + 2411 => 'Channel rent rebalance calculation underflow', + 2412 => 'Payer lamports overflow on rent refund', + 2413 => 'Transfer queue capacity exceeded' + }.freeze + + # Returns the error message for the given error code, or nil if unknown. + def self.message(code) + MESSAGES[code] + end + + # Builds a ProgramError for the given error code. + def self.from_code(code) + ProgramError.new(code, MESSAGES[code]) + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/instructions/distribute.rb b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/distribute.rb new file mode 100644 index 000000000..8031049af --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/distribute.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../programs' +require_relative '../shared/borsh' +require_relative '../types/distribute_args' + +module PayCore::Solana::Generated::PaymentChannels + DISTRIBUTE_DISCRIMINATOR = 7 + + # Fields: + # - `distribute_args` (DistributeArgs) + class DistributeInstructionData + attr_reader :distribute_args + + def initialize(distribute_args:) + @distribute_args = distribute_args + end + + def self.deserialize(reader) + fields = {} + reader.u8 + fields[:distribute_args] = DistributeArgs.deserialize(reader) + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(7) + DistributeArgs.serialize(writer, @distribute_args) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + distribute_args == other.distribute_args + end + alias eql? == + + def hash + [self.class, distribute_args].hash + end + end + + # Builds a `distribute` instruction. + # + # Accounts: + # 0. `channel` (writable) + # 1. `payer` (writable) + # 2. `rent_payer` (writable) + # 3. `channel_token_account` (writable) + # 4. `payer_token_account` (writable) + # 5. `payee_token_account` (writable) + # 6. `treasury_token_account` (writable) + # 7. `mint` + # 8. `token_program` + # 9. `event_authority` + # 10. `self_program` + def self.build_distribute( + channel:, + payer:, + rent_payer:, + channel_token_account:, + payer_token_account:, + payee_token_account:, + treasury_token_account:, + mint:, + token_program:, + event_authority:, + self_program:, + distribute_args:, + recipient_token_accounts: [] + ) + accounts = [] + accounts << AccountMeta.new(pubkey: channel, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: payer, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: rent_payer, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: channel_token_account, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: payer_token_account, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: payee_token_account, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: treasury_token_account, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: mint, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: token_program, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: event_authority, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: self_program, is_signer: false, is_writable: false) + accounts.concat(recipient_token_accounts.map { |account| AccountMeta.from(account, is_writable: true) }) + data = DistributeInstructionData.new(distribute_args: distribute_args).to_bytes + Instruction.new(program_id: PROGRAM_ID, accounts: accounts, data: data) + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/instructions/emit_event.rb b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/emit_event.rb new file mode 100644 index 000000000..3d046f24e --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/emit_event.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../programs' +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + EMIT_EVENT_DISCRIMINATOR = 228 + + class EmitEventInstructionData + def self.deserialize(reader) + reader.u8 + new + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(228) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class + end + alias eql? == + + def hash + self.class.hash + end + end + + # Builds a `emitEvent` instruction. + # + # Accounts: + # 0. `event_authority` (signer) + def self.build_emit_event(event_authority:) + accounts = [] + accounts << AccountMeta.new(pubkey: event_authority, is_signer: true, is_writable: false) + data = EmitEventInstructionData.new.to_bytes + Instruction.new(program_id: PROGRAM_ID, accounts: accounts, data: data) + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/instructions/finalize.rb b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/finalize.rb new file mode 100644 index 000000000..70a8bc9e0 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/finalize.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../programs' +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + FINALIZE_DISCRIMINATOR = 6 + + class FinalizeInstructionData + def self.deserialize(reader) + reader.u8 + new + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(6) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class + end + alias eql? == + + def hash + self.class.hash + end + end + + # Builds a `finalize` instruction. + # + # Accounts: + # 0. `channel` (writable) + def self.build_finalize(channel:) + accounts = [] + accounts << AccountMeta.new(pubkey: channel, is_signer: false, is_writable: true) + data = FinalizeInstructionData.new.to_bytes + Instruction.new(program_id: PROGRAM_ID, accounts: accounts, data: data) + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/instructions/open.rb b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/open.rb new file mode 100644 index 000000000..2c3148144 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/open.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../programs' +require_relative '../shared/borsh' +require_relative '../types/open_args' + +module PayCore::Solana::Generated::PaymentChannels + OPEN_DISCRIMINATOR = 1 + + # Fields: + # - `open_args` (OpenArgs) + class OpenInstructionData + attr_reader :open_args + + def initialize(open_args:) + @open_args = open_args + end + + def self.deserialize(reader) + fields = {} + reader.u8 + fields[:open_args] = OpenArgs.deserialize(reader) + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(1) + OpenArgs.serialize(writer, @open_args) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + open_args == other.open_args + end + alias eql? == + + def hash + [self.class, open_args].hash + end + end + + # Builds a `open` instruction. + # + # Accounts: + # 0. `payer` (writable, signer) + # 1. `rent_payer` (writable, signer) + # 2. `payee` + # 3. `mint` + # 4. `authorized_signer` + # 5. `channel` (writable) + # 6. `payer_token_account` (writable) + # 7. `channel_token_account` (writable) + # 8. `token_program` + # 9. `system_program` + # 10. `rent` + # 11. `associated_token_program` + # 12. `event_authority` + # 13. `self_program` + def self.build_open( + payer:, + rent_payer:, + payee:, + mint:, + authorized_signer:, + channel:, + payer_token_account:, + channel_token_account:, + token_program:, + system_program:, + rent:, + associated_token_program:, + event_authority:, + self_program:, + open_args: + ) + accounts = [] + accounts << AccountMeta.new(pubkey: payer, is_signer: true, is_writable: true) + accounts << AccountMeta.new(pubkey: rent_payer, is_signer: true, is_writable: true) + accounts << AccountMeta.new(pubkey: payee, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: mint, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: authorized_signer, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: channel, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: payer_token_account, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: channel_token_account, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: token_program, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: system_program, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: rent, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: associated_token_program, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: event_authority, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: self_program, is_signer: false, is_writable: false) + data = OpenInstructionData.new(open_args: open_args).to_bytes + Instruction.new(program_id: PROGRAM_ID, accounts: accounts, data: data) + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/instructions/request_close.rb b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/request_close.rb new file mode 100644 index 000000000..a71a58786 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/request_close.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../programs' +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + REQUEST_CLOSE_DISCRIMINATOR = 5 + + class RequestCloseInstructionData + def self.deserialize(reader) + reader.u8 + new + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(5) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class + end + alias eql? == + + def hash + self.class.hash + end + end + + # Builds a `requestClose` instruction. + # + # Accounts: + # 0. `payer` (signer) + # 1. `channel` (writable) + def self.build_request_close(payer:, channel:) + accounts = [] + accounts << AccountMeta.new(pubkey: payer, is_signer: true, is_writable: false) + accounts << AccountMeta.new(pubkey: channel, is_signer: false, is_writable: true) + data = RequestCloseInstructionData.new.to_bytes + Instruction.new(program_id: PROGRAM_ID, accounts: accounts, data: data) + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/instructions/settle.rb b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/settle.rb new file mode 100644 index 000000000..54ffceecf --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/settle.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../programs' +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + SETTLE_DISCRIMINATOR = 2 + + class SettleInstructionData + def self.deserialize(reader) + reader.u8 + new + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(2) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class + end + alias eql? == + + def hash + self.class.hash + end + end + + # Builds a `settle` instruction. + # + # Accounts: + # 0. `channel` (writable) + # 1. `instructions_sysvar` + def self.build_settle(channel:, instructions_sysvar:) + accounts = [] + accounts << AccountMeta.new(pubkey: channel, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: instructions_sysvar, is_signer: false, is_writable: false) + data = SettleInstructionData.new.to_bytes + Instruction.new(program_id: PROGRAM_ID, accounts: accounts, data: data) + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/instructions/settle_and_finalize.rb b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/settle_and_finalize.rb new file mode 100644 index 000000000..78dc72b8f --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/settle_and_finalize.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../programs' +require_relative '../shared/borsh' +require_relative '../types/settle_and_finalize_args' + +module PayCore::Solana::Generated::PaymentChannels + SETTLE_AND_FINALIZE_DISCRIMINATOR = 4 + + # Fields: + # - `settle_and_finalize_args` (SettleAndFinalizeArgs) + class SettleAndFinalizeInstructionData + attr_reader :settle_and_finalize_args + + def initialize(settle_and_finalize_args:) + @settle_and_finalize_args = settle_and_finalize_args + end + + def self.deserialize(reader) + fields = {} + reader.u8 + fields[:settle_and_finalize_args] = SettleAndFinalizeArgs.deserialize(reader) + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(4) + SettleAndFinalizeArgs.serialize(writer, @settle_and_finalize_args) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + settle_and_finalize_args == other.settle_and_finalize_args + end + alias eql? == + + def hash + [self.class, settle_and_finalize_args].hash + end + end + + # Builds a `settleAndFinalize` instruction. + # + # Accounts: + # 0. `merchant` (signer) + # 1. `channel` (writable) + # 2. `instructions_sysvar` + def self.build_settle_and_finalize( + merchant:, + channel:, + instructions_sysvar:, + settle_and_finalize_args: + ) + accounts = [] + accounts << AccountMeta.new(pubkey: merchant, is_signer: true, is_writable: false) + accounts << AccountMeta.new(pubkey: channel, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: instructions_sysvar, is_signer: false, is_writable: false) + data = SettleAndFinalizeInstructionData.new( + settle_and_finalize_args: settle_and_finalize_args + ).to_bytes + Instruction.new(program_id: PROGRAM_ID, accounts: accounts, data: data) + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/instructions/top_up.rb b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/top_up.rb new file mode 100644 index 000000000..af3b1ec19 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/top_up.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../programs' +require_relative '../shared/borsh' +require_relative '../types/top_up_args' + +module PayCore::Solana::Generated::PaymentChannels + TOP_UP_DISCRIMINATOR = 3 + + # Fields: + # - `top_up_args` (TopUpArgs) + class TopUpInstructionData + attr_reader :top_up_args + + def initialize(top_up_args:) + @top_up_args = top_up_args + end + + def self.deserialize(reader) + fields = {} + reader.u8 + fields[:top_up_args] = TopUpArgs.deserialize(reader) + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(3) + TopUpArgs.serialize(writer, @top_up_args) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + top_up_args == other.top_up_args + end + alias eql? == + + def hash + [self.class, top_up_args].hash + end + end + + # Builds a `topUp` instruction. + # + # Accounts: + # 0. `payer` (writable, signer) + # 1. `channel` (writable) + # 2. `payer_token_account` (writable) + # 3. `channel_token_account` (writable) + # 4. `mint` + # 5. `token_program` + def self.build_top_up( + payer:, + channel:, + payer_token_account:, + channel_token_account:, + mint:, + token_program:, + top_up_args: + ) + accounts = [] + accounts << AccountMeta.new(pubkey: payer, is_signer: true, is_writable: true) + accounts << AccountMeta.new(pubkey: channel, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: payer_token_account, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: channel_token_account, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: mint, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: token_program, is_signer: false, is_writable: false) + data = TopUpInstructionData.new(top_up_args: top_up_args).to_bytes + Instruction.new(program_id: PROGRAM_ID, accounts: accounts, data: data) + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/instructions/withdraw_payer.rb b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/withdraw_payer.rb new file mode 100644 index 000000000..a447a04cc --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/instructions/withdraw_payer.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../programs' +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + WITHDRAW_PAYER_DISCRIMINATOR = 8 + + class WithdrawPayerInstructionData + def self.deserialize(reader) + reader.u8 + new + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(8) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class + end + alias eql? == + + def hash + self.class.hash + end + end + + # Builds a `withdrawPayer` instruction. + # + # Accounts: + # 0. `payer` (signer) + # 1. `channel` (writable) + # 2. `channel_token_account` (writable) + # 3. `payer_token_account` (writable) + # 4. `mint` + # 5. `token_program` + def self.build_withdraw_payer( + payer:, + channel:, + channel_token_account:, + payer_token_account:, + mint:, + token_program: + ) + accounts = [] + accounts << AccountMeta.new(pubkey: payer, is_signer: true, is_writable: false) + accounts << AccountMeta.new(pubkey: channel, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: channel_token_account, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: payer_token_account, is_signer: false, is_writable: true) + accounts << AccountMeta.new(pubkey: mint, is_signer: false, is_writable: false) + accounts << AccountMeta.new(pubkey: token_program, is_signer: false, is_writable: false) + data = WithdrawPayerInstructionData.new.to_bytes + Instruction.new(program_id: PROGRAM_ID, accounts: accounts, data: data) + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/programs.rb b/ruby/lib/pay_core/solana/generated/payment_channels/programs.rb new file mode 100644 index 000000000..723211a14 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/programs.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative 'shared/pubkey' + +module PayCore::Solana::Generated::PaymentChannels + PROGRAM_NAME = 'payment_channels' + PROGRAM_ID = Pubkey.from_base58('CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX') + + # A Solana account meta: a pubkey plus its signer/writable flags. + # Immutable value object. + class AccountMeta + attr_reader :pubkey, :is_signer, :is_writable + + def initialize(pubkey:, is_signer: false, is_writable: false) + @pubkey = pubkey + @is_signer = is_signer + @is_writable = is_writable + end + + # Coerces `value` into an AccountMeta: existing metas pass through + # untouched, bare pubkeys are wrapped with the given flags. + def self.from(value, is_signer: false, is_writable: false) + return value if value.is_a?(AccountMeta) + + new(pubkey: value, is_signer: is_signer, is_writable: is_writable) + end + + # Meta for an optional account: when `pubkey` is nil, the program id is + # used as a placeholder with both flags cleared. + def self.optional(pubkey, is_signer: false, is_writable: false) + return new(pubkey: PROGRAM_ID) if pubkey.nil? + + new(pubkey: pubkey, is_signer: is_signer, is_writable: is_writable) + end + + def ==(other) + other.class == self.class && + pubkey == other.pubkey && + is_signer == other.is_signer && + is_writable == other.is_writable + end + alias eql? == + + def hash + [self.class, pubkey, is_signer, is_writable].hash + end + end + + # A Solana instruction: program id, account metas and serialized data bytes. + # Immutable value object. + class Instruction + attr_reader :program_id, :accounts, :data + + def initialize(program_id:, accounts:, data:) + @program_id = program_id + @accounts = accounts.freeze + @data = data.freeze + end + + def ==(other) + other.class == self.class && + program_id == other.program_id && + accounts == other.accounts && + data == other.data + end + alias eql? == + + def hash + [self.class, program_id, accounts, data].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/shared/borsh.rb b/ruby/lib/pay_core/solana/generated/payment_channels/shared/borsh.rb new file mode 100644 index 000000000..43db98652 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/shared/borsh.rb @@ -0,0 +1,315 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require 'set' +require_relative 'errors' +require_relative 'pubkey' + +module PayCore::Solana::Generated::PaymentChannels + # Minimal Borsh (https://borsh.io) serialization runtime. + # Numbers are little-endian; strings, vectors and maps are u32-length-prefixed; + # booleans are a single byte; options are a 1-byte presence flag plus the value. + # + # Determinism: maps and sets are encoded in the iteration order of the given + # `Hash`/`Set`, which Ruby guarantees to be insertion order. Serializing the + # same object therefore always yields the same bytes, and decoding preserves + # the wire order. If the target program expects Borsh-canonical (sorted) + # entries, sort them before building the collection. + module Borsh + # Raised when a value cannot be Borsh-encoded or decoded. + class Error < PayCore::Solana::Generated::PaymentChannels::Error; end + + # Appends Borsh-encoded values to an internal binary buffer. + class Writer + def initialize + @buffer = +''.b + end + + # Returns a copy of the encoded bytes as a binary string. + def to_s + @buffer.dup + end + alias to_bytes to_s + + def raw(bytes) + @buffer << bytes.to_s.b + self + end + + def u8(value) + check_range(value, 0, 0xFF, 'u8') + raw([value].pack('C')) + end + + def u16(value) + check_range(value, 0, 0xFFFF, 'u16') + raw([value].pack('v')) + end + + def u32(value) + check_range(value, 0, 0xFFFFFFFF, 'u32') + raw([value].pack('V')) + end + + def u64(value) + check_range(value, 0, (2**64) - 1, 'u64') + raw([value].pack('Q<')) + end + + def u128(value) + check_range(value, 0, (2**128) - 1, 'u128') + u64(value & ((2**64) - 1)) + u64(value >> 64) + end + + def i8(value) + check_range(value, -0x80, 0x7F, 'i8') + raw([value].pack('c')) + end + + def i16(value) + check_range(value, -0x8000, 0x7FFF, 'i16') + raw([value].pack('s<')) + end + + def i32(value) + check_range(value, -0x80000000, 0x7FFFFFFF, 'i32') + raw([value].pack('l<')) + end + + def i64(value) + check_range(value, -(2**63), (2**63) - 1, 'i64') + raw([value].pack('q<')) + end + + def i128(value) + check_range(value, -(2**127), (2**127) - 1, 'i128') + u128(value & ((2**128) - 1)) + end + + def f32(value) + raw([Float(value)].pack('e')) + end + + def f64(value) + raw([Float(value)].pack('E')) + end + + def bool(value) + u8(value ? 1 : 0) + end + + # Solana's compact-u16 (short vec) encoding. + def short_u16(value) + value = Integer(value) + check_range(value, 0, 0xFFFF, 'short_u16') + loop do + byte = value & 0x7F + value >>= 7 + if value.zero? + u8(byte) + break + end + u8(byte | 0x80) + end + self + end + + # Length-prefixed UTF-8 string (u32 prefix by default). + def str(value, format = :u32) + bytes = value.to_s.encode(Encoding::UTF_8).b + send(format, bytes.bytesize) + raw(bytes) + end + + # Fixed-size string: must be exactly `size` bytes once encoded. + def fixed_str(value, size) + bytes = value.to_s.b + raise Error, "payment_channels: expected #{size} bytes, got #{bytes.bytesize}" unless bytes.bytesize == size + + raw(bytes) + end + alias fixed_bytes fixed_str + + # Length-prefixed raw bytes (u32 prefix by default). + def prefixed_bytes(value, format = :u32) + bytes = value.to_s.b + send(format, bytes.bytesize) + raw(bytes) + end + + # Optional value: 0 when nil, otherwise 1 followed by the encoded value. + def option(value, format = :u8) + if value.nil? + send(format, 0) + else + send(format, 1) + yield value + end + self + end + + # Fixed-size array (no length prefix). Verifies the size when given. + def array(items, expected_size = nil, &) + items = items.to_a + if expected_size && items.size != expected_size + raise Error, "payment_channels: expected an array of #{expected_size} items, got #{items.size}" + end + + items.each(&) + self + end + + # Length-prefixed array (u32 prefix by default). + def vec(items, format = :u32, &) + items = items.to_a + send(format, items.size) + items.each(&) + self + end + + # Length-prefixed map (u32 prefix by default). Yields each key/value + # pair in the hash's insertion order (see the module docs on determinism). + def map(hash, format: :u32, expected: nil, &) + if expected && hash.size != expected + raise Error, "payment_channels: expected a map of #{expected} entries, got #{hash.size}" + end + + send(format, hash.size) if format + hash.each(&) + self + end + + def pubkey(value) + raw(value.to_bytes) + end + + private + + def check_range(value, min, max, type) + value = Integer(value) + raise Error, "payment_channels: #{type} out of range: #{value}" if value < min || value > max + end + end + + # Reads Borsh-encoded values from a binary string. + class Reader + def initialize(bytes) + @buffer = bytes.to_s.b + @position = 0 + end + + def eof? + @position >= @buffer.bytesize + end + + # Number of bytes left to read. + def remaining + @buffer.bytesize - @position + end + + def read(size) + if @position + size > @buffer.bytesize + raise Error, "payment_channels: unexpected end of input (needed #{size} more bytes, have #{remaining})" + end + + out = @buffer.byteslice(@position, size) + @position += size + out + end + + def u8 = read(1).unpack1('C') + def u16 = read(2).unpack1('v') + def u32 = read(4).unpack1('V') + def u64 = read(8).unpack1('Q<') + + def u128 + low = u64 + high = u64 + (high << 64) | low + end + + def i8 = read(1).unpack1('c') + def i16 = read(2).unpack1('s<') + def i32 = read(4).unpack1('l<') + def i64 = read(8).unpack1('q<') + + def i128 + value = u128 + value >= 2**127 ? value - (2**128) : value + end + + def f32 = read(4).unpack1('e') + def f64 = read(8).unpack1('E') + + def bool + u8 != 0 + end + + # Solana's compact-u16 (short vec) encoding. + def short_u16 + result = 0 + shift = 0 + loop do + byte = u8 + result |= (byte & 0x7F) << shift + break if byte.nobits?(0x80) + + shift += 7 + raise Error, 'payment_channels: invalid short_u16 encoding' if shift > 14 + end + result + end + + # Length-prefixed UTF-8 string (u32 prefix by default). + def str(format = :u32) + read(send(format)).force_encoding(Encoding::UTF_8) + end + + def remainder + read(remaining) + end + + def remainder_str + remainder.force_encoding(Encoding::UTF_8) + end + + # Optional value: nil when the flag byte is zero. + def option(format = :u8) + send(format).zero? ? nil : yield + end + + # Fixed-size array of `size` items. + def array(size, &) + Array.new(size, &) + end + + # Length-prefixed array (u32 prefix by default). + def vec(format = :u32, &) + Array.new(send(format), &) + end + + # Length-prefixed map (u32 prefix by default). The block must return a + # [key, value] pair. Wire order becomes the hash's insertion order. + def map(format = :u32, &) + Array.new(send(format), &).to_h + end + + # Reads items until the end of the input. + def until_eof + out = [] + out << yield until eof? + out + end + + def pubkey + Pubkey.new(read(32)) + end + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/shared/errors.rb b/ruby/lib/pay_core/solana/generated/payment_channels/shared/errors.rb new file mode 100644 index 000000000..2c8c78e6c --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/shared/errors.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +module PayCore::Solana::Generated::PaymentChannels + # Base class for every error raised by the generated `payment_channels` + # client. Rescue this to handle any failure coming from this library; all + # error messages are prefixed with `payment_channels:` so call sites are + # easy to grep. + class Error < StandardError; end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/shared/pda.rb b/ruby/lib/pay_core/solana/generated/payment_channels/shared/pda.rb new file mode 100644 index 000000000..60a65ccfd --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/shared/pda.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require 'digest' +require_relative 'errors' +require_relative 'pubkey' + +module PayCore::Solana::Generated::PaymentChannels + # Program Derived Address (PDA) helpers. + module Pda + # Raised when a program address cannot be derived from the given seeds. + class Error < PayCore::Solana::Generated::PaymentChannels::Error; end + + # Raised when the derived address lies on the ed25519 curve. + class OnCurveError < Error; end + + PDA_MARKER = 'ProgramDerivedAddress' + MAX_SEEDS = 16 + MAX_SEED_LENGTH = 32 + + # ed25519 curve constants (RFC 8032). + ED25519_P = (2**255) - 19 + ED25519_D = ((ED25519_P - 121_665) * 121_666.pow(ED25519_P - 2, ED25519_P)) % ED25519_P + ED25519_SQRT_M1 = 2.pow((ED25519_P - 1) / 4, ED25519_P) + + # Derives a program address from the given seeds. Raises OnCurveError if + # the resulting address lies on the ed25519 curve. + def self.create_program_address(seeds, program_id) + raise Error, "payment_channels: too many seeds (max #{MAX_SEEDS})" if seeds.length > MAX_SEEDS + + data = +''.b + seeds.each do |seed| + seed = seed.to_s.b + raise Error, "payment_channels: seed too long (max #{MAX_SEED_LENGTH} bytes)" if seed.bytesize > MAX_SEED_LENGTH + + data << seed + end + data << program_id.to_bytes << PDA_MARKER.b + digest = ::Digest::SHA256.digest(data) + raise OnCurveError, 'payment_channels: invalid seeds, address must fall off the curve' if on_curve?(digest) + + Pubkey.new(digest) + end + + # Finds the first off-curve program address by trying bump seeds 255 down to 0. + # Returns a [Pubkey, Integer] tuple of the address and bump. + def self.find_program_address(seeds, program_id) + 255.downto(0) do |bump| + return [create_program_address(seeds + [bump.chr], program_id), bump] + rescue OnCurveError + next + end + raise Error, 'payment_channels: unable to find a viable program address bump seed' + end + + # Returns true when the given 32 bytes are a valid ed25519 point encoding, + # following the decoding procedure of RFC 8032 section 5.1.3. + def self.on_curve?(bytes) + bytes = bytes.to_s.b + return false unless bytes.bytesize == 32 + + encoded = bytes.bytes + sign = encoded[31] >> 7 + encoded[31] &= 0x7F + y = 0 + encoded.reverse_each { |byte| y = (y << 8) | byte } + return false if y >= ED25519_P + + p = ED25519_P + y2 = y.pow(2, p) + u = (y2 - 1) % p + v = ((ED25519_D * y2) + 1) % p + + # Candidate square root x of u/v: x = u * v^3 * (u * v^7)^((p - 5) / 8). + x = (u * v.pow(3, p) % p) * (u * v.pow(7, p) % p).pow((p - 5) / 8, p) % p + vx2 = v * x.pow(2, p) % p + if vx2 != u + return false unless vx2 == (p - u) % p + + x = x * ED25519_SQRT_M1 % p + end + return false unless v * x.pow(2, p) % p == u + + # x == 0 with a negative sign bit is an invalid encoding. + !(x.zero? && sign == 1) + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/shared/pubkey.rb b/ruby/lib/pay_core/solana/generated/payment_channels/shared/pubkey.rb new file mode 100644 index 000000000..2a9d7d957 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/shared/pubkey.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative 'errors' + +module PayCore::Solana::Generated::PaymentChannels + # A 32-byte Solana public key with base58 encoding/decoding. + # Immutable value object: equality, hashing and the raw bytes are stable. + class Pubkey + BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + LENGTH = 32 + + def initialize(bytes) + bytes = bytes.to_s.b + raise Error, "payment_channels: Pubkey must be #{LENGTH} bytes, got #{bytes.bytesize}" unless bytes.bytesize == LENGTH + + @bytes = bytes.freeze + end + + def self.from_base58(string) + value = 0 + string.each_char do |char| + index = BASE58_ALPHABET.index(char) + raise Error, "payment_channels: invalid base58 character: #{char.inspect}" if index.nil? + + value = (value * 58) + index + end + body = +''.b + while value.positive? + body << (value & 0xFF) + value >>= 8 + end + body.reverse! + zeros = string.each_char.take_while { |char| char == BASE58_ALPHABET[0] }.count + new(("\x00".b * zeros) + body) + end + + def to_base58 + value = 0 + @bytes.each_byte { |byte| value = (value << 8) | byte } + out = +'' + while value.positive? + value, remainder = value.divmod(58) + out << BASE58_ALPHABET[remainder] + end + zeros = @bytes.each_byte.take_while(&:zero?).count + (BASE58_ALPHABET[0] * zeros) + out.reverse + end + + # The raw 32 bytes as a binary string. + def to_bytes + @bytes + end + + def to_s + to_base58 + end + + def inspect + "#<#{self.class.name} #{to_base58}>" + end + + def ==(other) + other.is_a?(Pubkey) && to_bytes == other.to_bytes + end + alias eql? == + + def hash + @bytes.hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/account_discriminator.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/account_discriminator.rb new file mode 100644 index 000000000..ccad2ec3c --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/account_discriminator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + module AccountDiscriminator + CHANNEL = 0 + CLOSED_CHANNEL = 1 + + def self.serialize(writer, value) + writer.u8(value) + writer + end + + def self.deserialize(reader) + reader.u8 + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/channel_status.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/channel_status.rb new file mode 100644 index 000000000..31494d385 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/channel_status.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + module ChannelStatus + OPEN = 0 + FINALIZED = 1 + CLOSING = 2 + + def self.serialize(writer, value) + writer.u8(value) + writer + end + + def self.deserialize(reader) + reader.u8 + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/distribute_args.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/distribute_args.rb new file mode 100644 index 000000000..cfcfe3068 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/distribute_args.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' +require_relative '../types/distribution_entry' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `recipients` (Array) + class DistributeArgs + attr_reader :recipients + + def initialize(recipients:) + @recipients = recipients + end + + def self.deserialize(reader) + fields = {} + fields[:recipients] = reader.vec { DistributionEntry.deserialize(reader) } + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.vec(@recipients) { |item1| DistributionEntry.serialize(writer, item1) } + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + recipients == other.recipients + end + alias eql? == + + def hash + [self.class, recipients].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/distribution_entry.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/distribution_entry.rb new file mode 100644 index 000000000..20e105266 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/distribution_entry.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' +require_relative '../shared/pubkey' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `recipient` (Pubkey) + # - `bps` (Integer) + class DistributionEntry + attr_reader :recipient, :bps + + def initialize(recipient:, bps:) + @recipient = recipient + @bps = bps + end + + def self.deserialize(reader) + fields = {} + fields[:recipient] = reader.pubkey + fields[:bps] = reader.u16 + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.pubkey(@recipient) + writer.u16(@bps) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + recipient == other.recipient && + bps == other.bps + end + alias eql? == + + def hash + [self.class, recipient, bps].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/open_args.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/open_args.rb new file mode 100644 index 000000000..a918397f0 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/open_args.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' +require_relative '../types/distribution_entry' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `salt` (Integer) + # - `deposit` (Integer) + # - `grace_period` (Integer) + # - `recipients` (Array) + class OpenArgs + attr_reader :salt, :deposit, :grace_period, :recipients + + def initialize(salt:, deposit:, grace_period:, recipients:) + @salt = salt + @deposit = deposit + @grace_period = grace_period + @recipients = recipients + end + + def self.deserialize(reader) + fields = {} + fields[:salt] = reader.u64 + fields[:deposit] = reader.u64 + fields[:grace_period] = reader.u32 + fields[:recipients] = reader.vec { DistributionEntry.deserialize(reader) } + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u64(@salt) + writer.u64(@deposit) + writer.u32(@grace_period) + writer.vec(@recipients) { |item1| DistributionEntry.serialize(writer, item1) } + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + salt == other.salt && + deposit == other.deposit && + grace_period == other.grace_period && + recipients == other.recipients + end + alias eql? == + + def hash + [self.class, salt, deposit, grace_period, recipients].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/opened.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/opened.rb new file mode 100644 index 000000000..99254568c --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/opened.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' +require_relative '../shared/pubkey' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `channel` (Pubkey) + class Opened + attr_reader :channel + + def initialize(channel:) + @channel = channel + end + + def self.deserialize(reader) + fields = {} + fields[:channel] = reader.pubkey + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.pubkey(@channel) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + channel == other.channel + end + alias eql? == + + def hash + [self.class, channel].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/payout_beneficiary.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/payout_beneficiary.rb new file mode 100644 index 000000000..2d0a57d23 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/payout_beneficiary.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + module PayoutBeneficiary + RECIPIENT = 0 + PAYEE = 1 + PAYER = 2 + + def self.serialize(writer, value) + writer.u8(value) + writer + end + + def self.deserialize(reader) + reader.u8 + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/payout_redirected.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/payout_redirected.rb new file mode 100644 index 000000000..cff019a52 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/payout_redirected.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' +require_relative '../shared/pubkey' +require_relative '../types/payout_beneficiary' +require_relative '../types/redirect_reason' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `channel` (Pubkey) + # - `owner` (Pubkey) + # - `amount` (Integer) + # - `beneficiary` (PayoutBeneficiary) + # - `reason` (RedirectReason) + class PayoutRedirected + attr_reader :channel, :owner, :amount, :beneficiary, :reason + + def initialize(channel:, owner:, amount:, beneficiary:, reason:) + @channel = channel + @owner = owner + @amount = amount + @beneficiary = beneficiary + @reason = reason + end + + def self.deserialize(reader) + fields = {} + fields[:channel] = reader.pubkey + fields[:owner] = reader.pubkey + fields[:amount] = reader.u64 + fields[:beneficiary] = PayoutBeneficiary.deserialize(reader) + fields[:reason] = RedirectReason.deserialize(reader) + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.pubkey(@channel) + writer.pubkey(@owner) + writer.u64(@amount) + PayoutBeneficiary.serialize(writer, @beneficiary) + RedirectReason.serialize(writer, @reason) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + channel == other.channel && + owner == other.owner && + amount == other.amount && + beneficiary == other.beneficiary && + reason == other.reason + end + alias eql? == + + def hash + [self.class, channel, owner, amount, beneficiary, reason].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/redirect_reason.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/redirect_reason.rb new file mode 100644 index 000000000..766fa53f4 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/redirect_reason.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + module RedirectReason + UNSUPPORTED_EXTENSION = 0 + CLOSED_OR_MALFORMED = 1 + NOT_INITIALIZED = 2 + REASSIGNED_AUTHORITY = 3 + + def self.serialize(writer, value) + writer.u8(value) + writer + end + + def self.deserialize(reader) + reader.u8 + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/settle_and_finalize_args.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/settle_and_finalize_args.rb new file mode 100644 index 000000000..d71c07503 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/settle_and_finalize_args.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `has_voucher` (Integer) + class SettleAndFinalizeArgs + attr_reader :has_voucher + + def initialize(has_voucher:) + @has_voucher = has_voucher + end + + def self.deserialize(reader) + fields = {} + fields[:has_voucher] = reader.u8 + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u8(@has_voucher) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + has_voucher == other.has_voucher + end + alias eql? == + + def hash + [self.class, has_voucher].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/settlement_watermarks.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/settlement_watermarks.rb new file mode 100644 index 000000000..7663b8e0b --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/settlement_watermarks.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `settled` (Integer) + # - `payout_watermark` (Integer) + class SettlementWatermarks + attr_reader :settled, :payout_watermark + + def initialize(settled:, payout_watermark:) + @settled = settled + @payout_watermark = payout_watermark + end + + def self.deserialize(reader) + fields = {} + fields[:settled] = reader.u64 + fields[:payout_watermark] = reader.u64 + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u64(@settled) + writer.u64(@payout_watermark) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + settled == other.settled && + payout_watermark == other.payout_watermark + end + alias eql? == + + def hash + [self.class, settled, payout_watermark].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/top_up_args.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/top_up_args.rb new file mode 100644 index 000000000..5b15338e4 --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/top_up_args.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `amount` (Integer) + class TopUpArgs + attr_reader :amount + + def initialize(amount:) + @amount = amount + end + + def self.deserialize(reader) + fields = {} + fields[:amount] = reader.u64 + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.u64(@amount) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + amount == other.amount + end + alias eql? == + + def hash + [self.class, amount].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/generated/payment_channels/types/voucher_args.rb b/ruby/lib/pay_core/solana/generated/payment_channels/types/voucher_args.rb new file mode 100644 index 000000000..249860aaa --- /dev/null +++ b/ruby/lib/pay_core/solana/generated/payment_channels/types/voucher_args.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# This code was AUTOGENERATED using the codama library. +# Please DO NOT EDIT THIS FILE, instead use visitors +# to add features, then rerun codama to update it. +# +# https://github.com/codama-idl/codama + +require_relative '../shared/borsh' +require_relative '../shared/pubkey' + +module PayCore::Solana::Generated::PaymentChannels + # Fields: + # - `channel_id` (Pubkey) + # - `cumulative_amount` (Integer) + # - `expires_at` (Integer) + class VoucherArgs + attr_reader :channel_id, :cumulative_amount, :expires_at + + def initialize(channel_id:, cumulative_amount:, expires_at:) + @channel_id = channel_id + @cumulative_amount = cumulative_amount + @expires_at = expires_at + end + + def self.deserialize(reader) + fields = {} + fields[:channel_id] = reader.pubkey + fields[:cumulative_amount] = reader.u64 + fields[:expires_at] = reader.i64 + new(**fields) + end + + def self.from_bytes(bytes) + deserialize(Borsh::Reader.new(bytes)) + end + + def self.serialize(writer, value) + value.serialize(writer) + writer + end + + def serialize(writer = Borsh::Writer.new) + writer.pubkey(@channel_id) + writer.u64(@cumulative_amount) + writer.i64(@expires_at) + writer + end + + def to_bytes + serialize.to_s + end + + def ==(other) + other.class == self.class && + channel_id == other.channel_id && + cumulative_amount == other.cumulative_amount && + expires_at == other.expires_at + end + alias eql? == + + def hash + [self.class, channel_id, cumulative_amount, expires_at].hash + end + end +end diff --git a/ruby/lib/pay_core/solana/payment_channels.rb b/ruby/lib/pay_core/solana/payment_channels.rb index f84211199..cbfe13e19 100644 --- a/ruby/lib/pay_core/solana/payment_channels.rb +++ b/ruby/lib/pay_core/solana/payment_channels.rb @@ -7,16 +7,15 @@ require_relative "ata" require_relative "mints" require_relative "instruction" +require_relative "generated/payment_channels" module PayCore module Solana - # Hand-written, byte-identical client for the pay-kit payment-channels - # program — the SVM `upto` (usage-based) settlement primitive. Ruby has no - # codama codegen target, so the channel account decoder, PDA derivations, - # voucher preimage, distribution-hash commitment, and the settle / - # distribute / Ed25519-precompile instruction encoders are written here by - # hand and pinned to golden byte-vectors lifted from the Go/Rust references - # (`go/paycore/paymentchannels/*`, `rust/crates/core/src/payment_channels.rs`). + # Thin payment-channels facade for the SVM `upto` (usage-based) settlement + # primitive. Account decode and on-chain instruction layouts come from the + # Codama-generated Ruby client under `generated/payment_channels`; this file + # keeps the pay-kit-specific PDA, voucher, distribution-hash, ATA, and + # PreparedInstruction conveniences pinned to cross-language golden vectors. # # The bytes emitted here MUST stay identical across the language SDKs or the # on-chain program rejects them; the offline parity tests are the guard, the @@ -24,10 +23,11 @@ module Solana module PaymentChannels module_function - # Canonical mainnet program id; matches the codama-generated default and - # every PDA derivation pinned to it (go/paycore/paymentchannels/ - # paymentchannels.go:27). The issue draft's `GuoKrza…` is stale. - PROGRAM_ID = "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX" + Generated = ::PayCore::Solana::Generated::PaymentChannels + + # Canonical mainnet program id from the Codama-generated client; every PDA + # derivation is pinned to it. The issue draft's `GuoKrza…` is stale. + PROGRAM_ID = Generated::PROGRAM_ID.to_s # Ed25519 signature-verification native precompile (settlement.go:22). ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111" @@ -45,13 +45,12 @@ module PaymentChannels CHANNEL_SEED = "channel" EVENT_AUTHORITY_SEED = "event_authority" - # One-byte instruction discriminators (idl/payment-channels.json + - # go/protocols/programs/paymentchannels/instruction_*.go). - DISCRIMINATOR_SETTLE_AND_FINALIZE = 4 - DISCRIMINATOR_DISTRIBUTE = 7 + # One-byte instruction discriminators from the Codama-generated builders. + DISCRIMINATOR_SETTLE_AND_FINALIZE = Generated::SETTLE_AND_FINALIZE_DISCRIMINATOR + DISCRIMINATOR_DISTRIBUTE = Generated::DISTRIBUTE_DISCRIMINATOR # Channel.status enum (idl channelStatus): 0 = open, 1 = finalized, 2 = closing. - STATUS_OPEN = 0 + STATUS_OPEN = Generated::ChannelStatus::OPEN # Canonical channel close grace period in seconds, pinned across the SDKs # (rust DEFAULT_GRACE_PERIOD_SECONDS). The on-chain program rejects zero; @@ -148,25 +147,28 @@ def decode_channel(data) raise ArgumentError, "channel account is #{data.respond_to?(:bytesize) ? data.bytesize : "?"} bytes, want >= #{CHANNEL_ACCOUNT_SIZE}" end + generated = Generated::Channel.from_bytes(data) Channel.new( - discriminator: data.getbyte(0), - version: data.getbyte(1), - bump: data.getbyte(2), - status: data.getbyte(3), - salt: read_u64_le(data, 4), - deposit: read_u64_le(data, 12), - settled: read_u64_le(data, 20), - payout_watermark: read_u64_le(data, 28), - closure_started_at: read_i64_le(data, 36), - payer_withdrawn_at: read_i64_le(data, 44), - grace_period: read_u32_le(data, 52), - distribution_hash: data.byteslice(56, 32), - payer: Base58.encode(data.byteslice(88, 32)), - payee: Base58.encode(data.byteslice(120, 32)), - authorized_signer: Base58.encode(data.byteslice(152, 32)), - mint: Base58.encode(data.byteslice(184, 32)), - rent_payer: Base58.encode(data.byteslice(216, 32)) + discriminator: generated.discriminator, + version: generated.version, + bump: generated.bump, + status: generated.status, + salt: generated.salt, + deposit: generated.deposit, + settled: generated.settlement.settled, + payout_watermark: generated.settlement.payout_watermark, + closure_started_at: generated.closure_started_at, + payer_withdrawn_at: generated.payer_withdrawn_at, + grace_period: generated.grace_period, + distribution_hash: generated.distribution_hash.pack("C*"), + payer: generated.payer.to_s, + payee: generated.payee.to_s, + authorized_signer: generated.authorized_signer.to_s, + mint: generated.mint.to_s, + rent_payer: generated.rent_payer.to_s ) + rescue Generated::Error => error + raise ArgumentError, error.message end # ---- instruction encoders -------------------------------------------- @@ -211,17 +213,13 @@ def settle_and_finalize_instructions(merchant:, channel:, authorized_signer:, si has_voucher = 1 end - # disc(4) || SettleAndFinalizeArgs{ hasVoucher: u8 }. - settle = PreparedInstruction.new( - program_id, - [ - AccountMeta.signer_readonly(merchant), - AccountMeta.writable(channel), - AccountMeta.readonly(INSTRUCTIONS_SYSVAR) - ], - [DISCRIMINATOR_SETTLE_AND_FINALIZE, has_voucher].pack("C2") + settle = Generated.build_settle_and_finalize( + merchant: generated_pubkey(merchant), + channel: generated_pubkey(channel), + instructions_sysvar: generated_pubkey(INSTRUCTIONS_SYSVAR), + settle_and_finalize_args: Generated::SettleAndFinalizeArgs.new(has_voucher: has_voucher) ) - instructions << settle + instructions << prepared_instruction(settle, program_id: program_id) instructions end @@ -237,28 +235,32 @@ def distribute_instruction(channel:, payer:, rent_payer:, payee:, mint:, token_p treasury_token = ATA.derive(owner: treasury, mint: mint, token_program: token_program) event_authority = find_event_authority_pda(program_id: program_id) - accounts = [ - AccountMeta.writable(channel), - AccountMeta.writable(payer), - AccountMeta.writable(rent_payer), - AccountMeta.writable(channel_token), - AccountMeta.writable(payer_token), - AccountMeta.writable(payee_token), - AccountMeta.writable(treasury_token), - AccountMeta.readonly(mint), - AccountMeta.readonly(token_program), - AccountMeta.readonly(event_authority), - AccountMeta.readonly(program_id) - ] - - data = [DISCRIMINATOR_DISTRIBUTE].pack("C") + u32_le(recipients.length) - recipients.each do |entry| - recipient = entry.fetch(:recipient) - accounts << AccountMeta.writable(ATA.derive(owner: recipient, mint: mint, token_program: token_program)) - data += Base58.decode(recipient) + u16_le(entry.fetch(:bps)) + generated_recipients = recipients.map do |entry| + Generated::DistributionEntry.new( + recipient: generated_pubkey(entry.fetch(:recipient)), + bps: entry.fetch(:bps) + ) + end + recipient_token_accounts = recipients.map do |entry| + generated_pubkey(ATA.derive(owner: entry.fetch(:recipient), mint: mint, token_program: token_program)) end - PreparedInstruction.new(program_id, accounts, data) + distribute = Generated.build_distribute( + channel: generated_pubkey(channel), + payer: generated_pubkey(payer), + rent_payer: generated_pubkey(rent_payer), + channel_token_account: generated_pubkey(channel_token), + payer_token_account: generated_pubkey(payer_token), + payee_token_account: generated_pubkey(payee_token), + treasury_token_account: generated_pubkey(treasury_token), + mint: generated_pubkey(mint), + token_program: generated_pubkey(token_program), + event_authority: generated_pubkey(event_authority), + self_program: generated_pubkey(program_id), + distribute_args: Generated::DistributeArgs.new(recipients: generated_recipients), + recipient_token_accounts: recipient_token_accounts + ) + prepared_instruction(distribute, program_id: program_id) end # Idempotent associated-token-account create. The settle transaction @@ -280,6 +282,18 @@ def create_idempotent_ata_instruction(payer:, owner:, mint:, token_program:) [1].pack("C") ) end + + def generated_pubkey(value) + Generated::Pubkey.from_base58(value) + end + + def prepared_instruction(instruction, program_id: instruction.program_id.to_s) + PreparedInstruction.new( + program_id, + instruction.accounts.map { |account| AccountMeta.new(account.pubkey.to_s, account.is_signer, account.is_writable) }, + instruction.data + ) + end end end end diff --git a/ruby/test/pay_core/payment_channels_test.rb b/ruby/test/pay_core/payment_channels_test.rb index 6b2332747..daf1a3378 100644 --- a/ruby/test/pay_core/payment_channels_test.rb +++ b/ruby/test/pay_core/payment_channels_test.rb @@ -2,7 +2,7 @@ require_relative "../test_helper" -# Byte-level parity tests for the hand-written payment-channels client. The +# Byte-level parity tests for the Codama-backed payment-channels facade. The # golden vectors are lifted from the Go/Rust references so a Ruby encoder drift # fails here, offline, before it ever reaches a validator. class PaymentChannelsTest < Minitest::Test @@ -209,6 +209,14 @@ def test_decode_channel_tolerates_trailing_bytes assert_instance_of PC::Channel, PC.decode_channel(data) end + def test_uses_codama_generated_client + assert_same ::PayCore::Solana::Generated::PaymentChannels, PC::Generated + assert_equal PC::PROGRAM_ID, PC::Generated::PROGRAM_ID.to_s + assert_equal PC::DISCRIMINATOR_SETTLE_AND_FINALIZE, PC::Generated::SETTLE_AND_FINALIZE_DISCRIMINATOR + assert_equal PC::DISCRIMINATOR_DISTRIBUTE, PC::Generated::DISTRIBUTE_DISCRIMINATOR + assert_equal PC::STATUS_OPEN, PC::Generated::ChannelStatus::OPEN + end + # ---- constants + LE helpers ------------------------------------------- def test_constants assert_equal "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX", PC::PROGRAM_ID diff --git a/ruby/test/test_helper.rb b/ruby/test/test_helper.rb index 59cc04e9e..5844852fb 100644 --- a/ruby/test/test_helper.rb +++ b/ruby/test/test_helper.rb @@ -31,6 +31,10 @@ # behaviour is exercised through the Sinatra example. Follows the # same pattern as `/lib/pay_kit/rack/` + `/lib/pay_kit/protocols/`. add_filter "/lib/pay_kit/preflight.rb" + # Codama-generated program clients are verified through facade golden-vector + # tests plus the generator syntax check, not included in hand-written code + # coverage gates. + add_filter "/lib/pay_core/solana/generated/" # Cross-SDK baseline target is 90 percent branch coverage. Line # coverage stays at 92 since the suite already exceeds that. minimum_coverage line: 92, branch: 90 diff --git a/skills/pay-sdk-implementation/codegen/.gitignore b/skills/pay-sdk-implementation/codegen/.gitignore index 111199c93..48e4bcd45 100644 --- a/skills/pay-sdk-implementation/codegen/.gitignore +++ b/skills/pay-sdk-implementation/codegen/.gitignore @@ -1,2 +1,3 @@ node_modules .codama-py/ +.codama-renderers-ruby/ diff --git a/skills/pay-sdk-implementation/codegen/generate-payment-channels-client-rb.ts b/skills/pay-sdk-implementation/codegen/generate-payment-channels-client-rb.ts new file mode 100644 index 000000000..0115da9bc --- /dev/null +++ b/skills/pay-sdk-implementation/codegen/generate-payment-channels-client-rb.ts @@ -0,0 +1,73 @@ +import { createFromJson } from 'codama'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +const CODAMA_RUBY_REPO = 'https://github.com/lgalabru/codama-renderers-ruby.git'; +const CODAMA_RUBY_COMMIT = '875672cd2e92007f5973dc335234c7e654153827'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +const idlPath = path.join(repoRoot, 'idl', 'payment-channels.json'); +const rubyClientDir = path.join(repoRoot, 'ruby', 'lib', 'pay_core', 'solana', 'generated'); +const cacheDir = path.join(__dirname, '.codama-renderers-ruby'); + +if (!fs.existsSync(idlPath)) { + console.error(`[codegen] IDL not found at ${idlPath}`); + console.error(`[codegen] Run \`just payment-channels-pull-idl\` first to fetch it from upstream.`); + process.exit(1); +} + +const run = (cmd: string, args: string[], cwd: string) => execFileSync(cmd, args, { cwd, stdio: 'inherit' }); + +if (!fs.existsSync(path.join(cacheDir, 'package.json'))) { + console.log(`[codegen] Cloning codama-renderers-ruby @ ${CODAMA_RUBY_COMMIT.slice(0, 8)}`); + fs.rmSync(cacheDir, { force: true, recursive: true }); + run('git', ['clone', '--quiet', CODAMA_RUBY_REPO, cacheDir], __dirname); +} +run('git', ['checkout', '--quiet', CODAMA_RUBY_COMMIT], cacheDir); +run('pnpm', ['install', '--frozen-lockfile', '--ignore-scripts', '--silent'], cacheDir); +run('pnpm', ['build'], cacheDir); + +const rendererPath = path.join(cacheDir, 'dist', 'index.node.mjs'); +const { renderVisitor } = await import(pathToFileURL(rendererPath).href); +const codama = createFromJson(fs.readFileSync(idlPath, 'utf-8')); + +console.log(`[codegen] Rendering Ruby client from ${path.relative(repoRoot, idlPath)}`); +console.log(`[codegen] → ${path.relative(repoRoot, rubyClientDir)}/`); + +await codama.accept(renderVisitor(rubyClientDir, { deleteFolderBeforeRendering: true, formatCode: true })); + +const rubyFiles = collectRubyFiles(rubyClientDir); +for (const filePath of rubyFiles) { + const original = fs.readFileSync(filePath, 'utf8'); + const patched = original + .replaceAll('module PaymentChannels', 'module PayCore::Solana::Generated::PaymentChannels') + .replaceAll('PaymentChannels::Error', 'PayCore::Solana::Generated::PaymentChannels::Error'); + fs.writeFileSync(filePath, patched); +} + +const entry = path.join(rubyClientDir, 'payment_channels.rb'); +const entryContent = fs.readFileSync(entry, 'utf8'); +const namespace = `module PayCore\n module Solana\n module Generated\n end\n end\nend\n\n`; +if (!entryContent.includes('module Generated')) { + fs.writeFileSync(entry, entryContent.replace("require_relative 'payment_channels/shared/errors'", namespace + "require_relative 'payment_channels/shared/errors'")); +} + +for (const filePath of rubyFiles) { + run('ruby', ['-c', filePath], repoRoot); +} + +console.log(`[codegen] Done.`); + +function collectRubyFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const filePath = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...collectRubyFiles(filePath)); + if (entry.isFile() && entry.name.endsWith('.rb')) out.push(filePath); + } + return out; +} diff --git a/skills/pay-sdk-implementation/codegen/package.json b/skills/pay-sdk-implementation/codegen/package.json index 7c4b1c259..12a616339 100644 --- a/skills/pay-sdk-implementation/codegen/package.json +++ b/skills/pay-sdk-implementation/codegen/package.json @@ -8,7 +8,8 @@ "payment-channels:rust": "tsx ./generate-payment-channels-client-rs.ts", "payment-channels:go": "tsx ./generate-payment-channels-client-go.ts", "payment-channels:ts": "tsx ./generate-payment-channels-client-ts.ts", - "payment-channels:python": "tsx ./generate-payment-channels-client-py.ts" + "payment-channels:python": "tsx ./generate-payment-channels-client-py.ts", + "payment-channels:ruby": "tsx ./generate-payment-channels-client-rb.ts" }, "dependencies": { "@codama/nodes-from-anchor": "^1.4.1", From 3d7baf522661310c8ec071e30b045826677b773a Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 30 Jun 2026 11:15:55 +0300 Subject: [PATCH 10/17] fix(ruby): surface stranded channel when app-failure zero settle fails --- ruby/lib/pay_kit/usage.rb | 29 ++++++++++++++++++++++++++--- ruby/test/pay_kit/usage_test.rb | 30 ++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/ruby/lib/pay_kit/usage.rb b/ruby/lib/pay_kit/usage.rb index 821f8ef3a..cc8a71c39 100644 --- a/ruby/lib/pay_kit/usage.rb +++ b/ruby/lib/pay_kit/usage.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "json" +require "logger" require "rack" require_relative "protocols/x402/runtime" @@ -139,12 +140,34 @@ def close_body(body) # The protected app raised after the channel opened. Settle a zero charge # to close the channel and refund the payer; `settle_actual` releases the - # in-flight reservation in its own ensure even if the settle broadcast - # fails, so fall back to an explicit (idempotent) release on error. + # in-flight reservation in its own ensure even when the settle broadcast + # fails. If that compensating settlement fails the channel may still be + # open on-chain with the payer's deposit locked, and `release!` only clears + # the in-memory reservation — so log loudly with the channel id (operators + # must reconcile/refund it manually) instead of swallowing the failure. def release_after_app_failure(open) @engine.settle_actual(open, 0) - rescue + rescue => settle_error open.release! if open.respond_to?(:release!) + log_orphaned_channel(open, settle_error) + end + + # Loud, actionable warning when a compensating zero settlement fails and a + # channel may be stranded on-chain. Uses the PayKit logger convention so it + # lands alongside the rest of the application log. + def log_orphaned_channel(open, error) + channel_id = open.respond_to?(:channel_id) ? open.channel_id : "unknown" + logger.warn( + "x402 upto: compensating zero settlement failed for channel #{channel_id}; " \ + "the channel may remain open on-chain with the payer deposit locked and needs manual reconciliation " \ + "(#{error.class}: #{error.message})" + ) + end + + def logger + ::PayKit.logger || (@default_logger ||= ::Logger.new($stderr).tap do |log| + log.formatter = proc { |_severity, _datetime, _progname, msg| "[PayKit] WARN: #{msg}\n" } + end) end # A post-resource settlement failure: report a server error, never a 402, diff --git a/ruby/test/pay_kit/usage_test.rb b/ruby/test/pay_kit/usage_test.rb index 1bf2627ed..e684d3977 100644 --- a/ruby/test/pay_kit/usage_test.rb +++ b/ruby/test/pay_kit/usage_test.rb @@ -52,7 +52,13 @@ class FakeEngine attr_reader :settled_with attr_accessor :raise_on_verify, :raise_on_settle - Open = Struct.new(:max_amount) + Open = Struct.new(:max_amount, :channel_id, :released, keyword_init: true) do + def release! + self.released = true + end + end + + attr_reader :last_open def initialize(raise_on_verify: nil) @raise_on_verify = raise_on_verify @@ -64,7 +70,7 @@ def verify_open(header) raise @raise_on_verify if @raise_on_verify @verified_header = header - Open.new(100_000) + @last_open = Open.new(max_amount: 100_000, channel_id: "chan_test", released: false) end def settle_actual(_open, actual) @@ -206,6 +212,26 @@ def test_app_exception_settles_zero_and_reraises assert_equal [0], @engine.settled_with, "an app failure must settle 0 to close and refund the channel" end + def test_app_exception_with_failed_zero_settle_warns_about_orphaned_channel + @engine.raise_on_settle = RuntimeError.new("rpc timeout") + boom = ->(env) { + env[::PayKit::Usage::CHARGE_ENV_KEY]&.charge(40_000) + raise "app boom" + } + mw = ::PayKit::Usage::Middleware.new(boom, engine: @engine, resource_path: "/usage") + + log = StringIO.new + PayKit.logger = ::Logger.new(log).tap { |l| l.formatter = proc { |_s, _d, _p, msg| "#{msg}\n" } } + + error = assert_raises(RuntimeError) { mw.call(env_for("/usage", header: "HDR")) } + assert_equal "app boom", error.message, "the original app error still surfaces" + assert @engine.last_open.released, "the in-memory reservation is still released" + assert_match(/chan_test/, log.string, "a failed compensating settle must be logged with the channel id") + assert_match(/manual reconciliation/i, log.string, "operators must be told the deposit may be locked on-chain") + ensure + PayKit.logger = nil + end + private def env_for(path, header: nil, legacy_header: nil, meter: 0) From 57dea05c7f8026355a5e58fbf86fbda46d7413c0 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 30 Jun 2026 11:56:15 +0300 Subject: [PATCH 11/17] fix(ci): point ruby upto harness at paykit-harness-bins client Main's crate restructure (#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. --- .github/workflows/ruby.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 59aee3c4a..0bf827bb0 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -93,7 +93,7 @@ jobs: with: # Ruby is server-only for x402; the canonical Rust client builds and # broadcasts the channel `open` this server settles against. - cargo-bins: "solana-x402:harness_upto_client" + cargo-bins: "paykit-harness-bins:x402_harness_upto_client" cargo-cache-key: ruby - name: Checkout payment channels program uses: actions/checkout@v5 From b4e4651b29fea6f8be1fbdc0a9b46f6e73e3bb30 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 30 Jun 2026 11:56:23 +0300 Subject: [PATCH 12/17] fix(ruby): require signer privileges on upto open payer + rent_payer 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. --- .../x402/protocol/schemes/upto/verify.rb | 20 +++++++++++ .../protocols/x402/server_upto_test.rb | 34 +++++++++++++++---- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb index 956886938..239e2dc58 100644 --- a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb @@ -103,6 +103,14 @@ def validate_open_instruction!(transaction, program_id:, operator:, payer:, paye expect(instruction, keys, OPEN_PAYER, payer, "payer") expect(instruction, keys, OPEN_RENT_PAYER, operator, "rent_payer") expect(instruction, keys, OPEN_PAYEE, payee, "payee") + # The on-chain `open` requires payer + rent_payer to be signers + # (idl/payment-channels.json `open`, accounts 0/1). Matching only the + # pubkeys is not enough: a client can point those instruction slots at + # a non-signer account index, so the operator signs and pays the fee + # for a transaction the program then rejects. Pin both to signer + # positions in the message header before broadcast. + require_signer!(transaction, instruction, OPEN_PAYER, "payer") + require_signer!(transaction, instruction, OPEN_RENT_PAYER, "rent_payer") expect(instruction, keys, OPEN_MINT, mint, "mint") expect(instruction, keys, OPEN_AUTHORIZED_SIGNER, operator, "authorized_signer") expect(instruction, keys, OPEN_CHANNEL, channel_id, "channel") @@ -162,6 +170,18 @@ def expect(instruction, keys, position, want, label) raise reject("open transaction #{label} mismatch: expected #{want}, got #{got || ""}") unless got == want end + # Assert the instruction account at `position` resolves to a signer + # slot in the message header. The compiled message lays out all + # required signers first, so an account key index below + # `header.required_signatures` is a signer (Solana message layout). + def require_signer!(transaction, instruction, position, label) + key_index = instruction.accounts[position] + required = transaction.message.header[:required_signatures] + unless !key_index.nil? && key_index < required + raise reject("open transaction #{label} must be a signer") + end + end + def account_at(instruction, keys, position) return nil if position >= instruction.accounts.length diff --git a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb index 303a4fb74..16e9dc2c5 100644 --- a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb +++ b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb @@ -138,6 +138,19 @@ def test_rejects_open_grace_period_mismatch assert_reject("grace period") { verify(salt: 33, open_grace_period: 60) } end + def test_rejects_open_payer_not_signer + assert_reject("payer must be a signer") { verify(salt: 34, open_payer_signer: false) } + end + + def test_rejects_open_rent_payer_not_signer + # The operator is normally both rent_payer and fee payer (account index 0, + # always a signer), so a separate fee payer is needed to compile a tx where + # the operator's rent_payer slot lands at a non-signer index. + assert_reject("rent_payer must be a signer") do + verify(salt: 35, open_rent_payer_signer: false, open_fee_payer: pubkey(88)) + end + end + # ---- channel rejects --------------------------------------------------- def test_rejects_channel_not_open assert_reject("not open") { verify(salt: 15, channel: {status: 1}) } @@ -191,12 +204,14 @@ def channel_id(salt) # Build [engine, header, channel] for a fresh salt. Knobs let each test # corrupt exactly one input. def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, - open_salt: nil, open_deposit: nil, open_recipients_count: 0, open_grace_period: 900) + open_salt: nil, open_deposit: nil, open_recipients_count: 0, open_grace_period: 900, + open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil) cid = channel_id(salt) header = open_header(salt: salt, cid: cid, network: network, payload: payload, open_program: open_program, open_payee: open_payee || @payee, open_salt: open_salt || salt, open_deposit: open_deposit || MAX, open_recipients_count: open_recipients_count, - open_grace_period: open_grace_period) + open_grace_period: open_grace_period, open_payer_signer: open_payer_signer, + open_rent_payer_signer: open_rent_payer_signer, open_fee_payer: open_fee_payer) fake = fake_channel(channel) engine = ::PayKit::Protocols::X402::Server::Upto.new( Upto::Config.new( @@ -211,27 +226,32 @@ def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: end def verify(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, - now_override: nil, open_salt: nil, open_deposit: nil, open_recipients_count: 0, open_grace_period: 900) + now_override: nil, open_salt: nil, open_deposit: nil, open_recipients_count: 0, open_grace_period: 900, + open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil) engine, header, = build_case(salt: salt, payload: payload, channel: channel, network: network, open_program: open_program, open_payee: open_payee, open_salt: open_salt, open_deposit: open_deposit, open_recipients_count: open_recipients_count, - open_grace_period: open_grace_period) + open_grace_period: open_grace_period, open_payer_signer: open_payer_signer, + open_rent_payer_signer: open_rent_payer_signer, open_fee_payer: open_fee_payer) engine.verify_open(header, now: now_override || now) end - def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:, open_salt:, open_deposit:, open_recipients_count:, open_grace_period:) + def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:, open_salt:, open_deposit:, open_recipients_count:, open_grace_period:, + open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil) payer_token = ::PayCore::Solana::ATA.derive(owner: @payer, mint: @mint, token_program: @token_program) channel_token = ::PayCore::Solana::ATA.derive(owner: cid, mint: @mint, token_program: @token_program) ea = PC.find_event_authority_pda + payer_meta = open_payer_signer ? AM.signer_writable(@payer) : AM.writable(@payer) + rent_payer_meta = open_rent_payer_signer ? AM.signer_writable(@operator) : AM.writable(@operator) accounts = [ - AM.signer_writable(@payer), AM.signer_writable(@operator), AM.readonly(open_payee), AM.readonly(@mint), + payer_meta, rent_payer_meta, AM.readonly(open_payee), AM.readonly(@mint), AM.readonly(@operator), AM.writable(cid), AM.writable(payer_token), AM.writable(channel_token), AM.readonly(@token_program), AM.readonly(PC::SYSTEM_PROGRAM), AM.readonly(PC::RENT_SYSVAR), AM.readonly(PC::ASSOCIATED_TOKEN_PROGRAM), AM.readonly(ea), AM.readonly(PC::PROGRAM_ID) ] data = [1].pack("C") + PC.u64_le(open_salt) + PC.u64_le(open_deposit) + PC.u32_le(open_grace_period) + PC.u32_le(open_recipients_count) open_recipients_count.times { data += ::PayCore::Solana::Base58.decode(@payee) + [1].pack("v") } - tx = MB.build_legacy(fee_payer: @operator, recent_blockhash: pubkey(9), + tx = MB.build_legacy(fee_payer: open_fee_payer || @operator, recent_blockhash: pubkey(9), instructions: [::PayCore::Solana::PreparedInstruction.new(open_program, accounts, data)]) body = { "profile" => "payment-channel", "from" => @payer, "maxAmount" => MAX.to_s, "expiresAt" => 4_102_444_800, From 4288c1b83ea3644c587a0f0362250653577035f7 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 30 Jun 2026 12:09:03 +0300 Subject: [PATCH 13/17] fix(ruby): log orphaned upto channel when post-broadcast read fails 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. --- .../lib/pay_kit/protocols/x402/server/upto.rb | 30 +++++++++++++++++++ .../protocols/x402/server_upto_test.rb | 25 ++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/ruby/lib/pay_kit/protocols/x402/server/upto.rb b/ruby/lib/pay_kit/protocols/x402/server/upto.rb index 62344a340..0fe239f79 100644 --- a/ruby/lib/pay_kit/protocols/x402/server/upto.rb +++ b/ruby/lib/pay_kit/protocols/x402/server/upto.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "logger" + require "pay_core/solana/account" require "pay_core/solana/caip2" require "pay_core/solana/mints" @@ -188,6 +190,7 @@ def verify_open(header, now: Time.now.to_i) reserve_channel(channel_id) released = false + broadcast_done = false begin transaction = ::PayCore::Solana::Transaction.from_base64(payload["openTransaction"]) Verifier.validate_open_instruction!( @@ -202,6 +205,7 @@ def verify_open(header, now: Time.now.to_i) transaction.sign_with(@config.operator) broadcast_and_confirm(transaction.to_base64) + broadcast_done = true channel = fetch_channel(channel_id) validate_channel!(channel, payer: payer, max: parsed[:max]) @@ -214,6 +218,14 @@ def verify_open(header, now: Time.now.to_i) ) released = true verified + rescue => error + # The open already broadcast and confirmed on-chain, but the + # post-broadcast read or validation failed, so no VerifiedOpen reaches + # the caller to settle or refund. The reservation is about to be + # released and the client told to pay again, so log the channel id + # loudly: the deposit may be locked and needs manual reconciliation. + log_orphaned_open(channel_id, error) if broadcast_done && !released + raise ensure release_channel(channel_id) unless released end @@ -350,6 +362,24 @@ def blank?(value) value.nil? || value.empty? end + # Loud, actionable warning when an open confirmed on-chain but the + # post-broadcast read/validation failed, so no VerifiedOpen reaches the + # caller. Uses the PayKit logger convention so it lands alongside the rest + # of the application log; operators reconcile the channel by id. + def log_orphaned_open(channel_id, error) + logger.warn( + "x402 upto: channel #{channel_id} opened on-chain but the post-broadcast read failed; " \ + "the payer deposit may be locked and the channel needs manual reconciliation " \ + "(#{error.class}: #{error.message})" + ) + end + + def logger + ::PayKit.logger || (@default_logger ||= ::Logger.new($stderr).tap do |log| + log.formatter = proc { |_severity, _datetime, _progname, msg| "[PayKit] WARN: #{msg}\n" } + end) + end + def reject(message) ::PayKit::Protocols::X402::Error::PaymentInvalid.new(message) end diff --git a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb index 16e9dc2c5..819dd468d 100644 --- a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb +++ b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb @@ -193,6 +193,27 @@ def test_in_flight_guard_blocks_concurrent_same_channel assert_reject("already being processed") { engine.verify_open(header, now: now) } end + # The open broadcast and confirmed, but the post-broadcast channel read + # failed: no VerifiedOpen reaches the caller, so the channel id must be logged + # for manual reconciliation before the reservation is released. + def test_post_broadcast_read_failure_logs_channel_for_reconciliation + captured = [] + fake_logger = Object.new + fake_logger.define_singleton_method(:warn) { |msg| captured << msg } + previous = ::PayKit.logger + ::PayKit.logger = fake_logger + engine, header, = build_case(salt: 24, channel_fetcher: ->(_c, _id) { raise "rpc unavailable" }) + assert_raises(RuntimeError) { engine.verify_open(header, now: now) } + warning = captured.find { |m| m.include?(channel_id(24)) } + refute_nil warning, "expected an orphaned-channel warning naming the channel id" + assert_includes warning, "manual reconciliation" + # reservation released on failure: a retry hits the read error again, not + # the concurrent-request guard. + assert_raises(RuntimeError) { engine.verify_open(header, now: now) } + ensure + ::PayKit.logger = previous + end + private def now = 1_000_000 @@ -205,7 +226,7 @@ def channel_id(salt) # corrupt exactly one input. def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, open_salt: nil, open_deposit: nil, open_recipients_count: 0, open_grace_period: 900, - open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil) + open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil, channel_fetcher: nil) cid = channel_id(salt) header = open_header(salt: salt, cid: cid, network: network, payload: payload, open_program: open_program, open_payee: open_payee || @payee, @@ -218,7 +239,7 @@ def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: rpc_url: "http://localhost:8899", pay_to: @payee, facilitator_secret_key: @operator_secret, amount: MAX.to_s, mint: @mint, network: NETWORK, token_program: @token_program, transaction_sender: ->(_c, _b) { "SiGnAtUrE1111111111111111111111111111111111" }, - signature_confirmer: ->(_c, sig) { sig }, channel_fetcher: ->(_c, _id) { fake }, + signature_confirmer: ->(_c, sig) { sig }, channel_fetcher: channel_fetcher || ->(_c, _id) { fake }, recent_blockhash_provider: -> { pubkey(9) } ) ) From 6288684ddf56e5749d26106f457d250a63e069cf Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 30 Jun 2026 12:19:49 +0300 Subject: [PATCH 14/17] fix(ruby): pin upto open signature count and log on send failure 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. --- .../x402/protocol/schemes/upto/verify.rb | 10 +++++ .../lib/pay_kit/protocols/x402/server/upto.rb | 19 ++++---- .../protocols/x402/server_upto_test.rb | 45 ++++++++++++++++++- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb index 239e2dc58..5c87dc726 100644 --- a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb @@ -96,6 +96,16 @@ def validate_open_instruction!(transaction, program_id:, operator:, payer:, paye raise reject("open transaction is not a channel-open instruction") end + # The wire signature array must cover every required signer. A client + # can pin payer/rent_payer to signer slots yet serialize fewer + # signatures than the header requires; the operator fills slot 0 and + # broadcasts a transaction the runtime rejects for missing + # signatures, after the fee is spent. Pin the array length first. + 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 + payer_token = ATA.derive(owner: payer, mint: mint, token_program: token_program) channel_token = ATA.derive(owner: channel_id, mint: mint, token_program: token_program) event_authority = PaymentChannels.find_event_authority_pda(program_id: program_id) diff --git a/ruby/lib/pay_kit/protocols/x402/server/upto.rb b/ruby/lib/pay_kit/protocols/x402/server/upto.rb index 0fe239f79..729e38e60 100644 --- a/ruby/lib/pay_kit/protocols/x402/server/upto.rb +++ b/ruby/lib/pay_kit/protocols/x402/server/upto.rb @@ -190,7 +190,7 @@ def verify_open(header, now: Time.now.to_i) reserve_channel(channel_id) released = false - broadcast_done = false + maybe_broadcast = false begin transaction = ::PayCore::Solana::Transaction.from_base64(payload["openTransaction"]) Verifier.validate_open_instruction!( @@ -204,8 +204,11 @@ def verify_open(header, now: Time.now.to_i) end transaction.sign_with(@config.operator) + # From here the open may have landed on-chain: send_raw can submit and + # then time out before returning, so flag a possible broadcast before + # the call, not after, otherwise a send-then-timeout skips the log. + maybe_broadcast = true broadcast_and_confirm(transaction.to_base64) - broadcast_done = true channel = fetch_channel(channel_id) validate_channel!(channel, payer: payer, max: parsed[:max]) @@ -219,12 +222,12 @@ def verify_open(header, now: Time.now.to_i) released = true verified rescue => error - # The open already broadcast and confirmed on-chain, but the - # post-broadcast read or validation failed, so no VerifiedOpen reaches - # the caller to settle or refund. The reservation is about to be - # released and the client told to pay again, so log the channel id - # loudly: the deposit may be locked and needs manual reconciliation. - log_orphaned_open(channel_id, error) if broadcast_done && !released + # The open may already be on-chain (send succeeded, then confirmation + # or the post-broadcast read failed), but no VerifiedOpen reaches the + # caller to settle or refund. The reservation is about to be released + # and the client told to pay again, so log the channel id loudly: the + # deposit may be locked and needs manual reconciliation. + log_orphaned_open(channel_id, error) if maybe_broadcast && !released raise ensure release_channel(channel_id) unless released diff --git a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb index 819dd468d..9adb2106d 100644 --- a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb +++ b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb @@ -214,8 +214,47 @@ def test_post_broadcast_read_failure_logs_channel_for_reconciliation ::PayKit.logger = previous end + # A confirmation timeout after send_raw may have submitted the open can leave + # the channel on-chain, so the orphan log must fire on a broadcast failure, + # not only on a post-broadcast read failure. + def test_confirmation_failure_after_send_logs_channel_for_reconciliation + captured = [] + fake_logger = Object.new + fake_logger.define_singleton_method(:warn) { |msg| captured << msg } + previous = ::PayKit.logger + ::PayKit.logger = fake_logger + engine, header, = build_case(salt: 25, signature_confirmer: ->(_c, _sig) { raise "confirmation timed out" }) + assert_raises(RuntimeError) { engine.verify_open(header, now: now) } + assert(captured.any? { |m| m.include?(channel_id(25)) && m.include?("manual reconciliation") }, + "expected an orphaned-channel warning naming the channel id on confirm failure") + ensure + ::PayKit.logger = previous + end + + # A client can pin payer/rent_payer to signer slots yet serialize fewer + # signatures than the header requires; reject before the operator signs. + def test_rejects_open_with_short_signature_array + _, header, = build_case(salt: 26) + tampered = tamper_open_transaction(header) do |tx| + ::PayCore::Solana::Transaction.new( + signatures: tx.signatures[0...-1], message: tx.message, + message_offset: tx.message_offset, version: tx.version + ) + end + engine, = build_case(salt: 26) + assert_reject("signatures, expected") { engine.verify_open(tampered, now: now) } + end + private + # Decode a header, rewrite its openTransaction via the block, re-encode. + def tamper_open_transaction(header) + envelope = JSON.parse(Base64.strict_decode64(header)) + tx = ::PayCore::Solana::Transaction.from_base64(envelope["payload"]["openTransaction"]) + envelope["payload"]["openTransaction"] = yield(tx).to_base64 + Base64.strict_encode64(JSON.generate(envelope)) + end + def now = 1_000_000 def channel_id(salt) @@ -226,7 +265,8 @@ def channel_id(salt) # corrupt exactly one input. def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, open_salt: nil, open_deposit: nil, open_recipients_count: 0, open_grace_period: 900, - open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil, channel_fetcher: nil) + open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil, channel_fetcher: nil, + signature_confirmer: nil) cid = channel_id(salt) header = open_header(salt: salt, cid: cid, network: network, payload: payload, open_program: open_program, open_payee: open_payee || @payee, @@ -239,7 +279,8 @@ def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: rpc_url: "http://localhost:8899", pay_to: @payee, facilitator_secret_key: @operator_secret, amount: MAX.to_s, mint: @mint, network: NETWORK, token_program: @token_program, transaction_sender: ->(_c, _b) { "SiGnAtUrE1111111111111111111111111111111111" }, - signature_confirmer: ->(_c, sig) { sig }, channel_fetcher: channel_fetcher || ->(_c, _id) { fake }, + signature_confirmer: signature_confirmer || ->(_c, sig) { sig }, + channel_fetcher: channel_fetcher || ->(_c, _id) { fake }, recent_blockhash_provider: -> { pubkey(9) } ) ) From 8a24537f079d16d119e8b2a433c5eef2bd3cdbe5 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 30 Jun 2026 12:38:06 +0300 Subject: [PATCH 15/17] fix(ruby): verify upto open co-signer signatures and reject lookup tables 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. --- .../x402/protocol/schemes/upto/verify.rb | 44 +++++++++++++++++++ .../protocols/x402/server_upto_test.rb | 26 ++++++++--- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb index 5c87dc726..057ad8081 100644 --- a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true +require "ed25519" + require "pay_core/solana/ata" +require "pay_core/solana/base58" require "pay_core/solana/payment_channels" require "pay_core/solana/transaction" @@ -85,6 +88,14 @@ def assert_within_ceiling!(actual, max) def validate_open_instruction!(transaction, program_id:, operator:, payer:, payee:, mint:, token_program:, channel_id:, max:) keys = transaction.message.account_keys instructions = transaction.message.instructions + # Reject v0 transactions that pull accounts from address lookup + # tables. This validator and the fee-payer co-sign resolve every + # account via the static key list; a non-empty ALT lookup could + # smuggle in accounts the position checks below cannot see, and the + # operator would blindly co-sign. Mirrors the Rust spine. + unless Array(transaction.message.address_table_lookups).empty? + raise reject("open transaction must not use address lookup tables") + end unless instructions.length == 1 raise reject("open transaction must contain exactly one instruction, found #{instructions.length}") end @@ -135,6 +146,14 @@ def validate_open_instruction!(transaction, program_id:, operator:, payer:, paye validate_open_args!(instruction.data, max: max, payer: payer, payee: payee, mint: mint, operator: operator, channel_id: channel_id, program_id: program_id) + + # Final check, once positions and signer slots are confirmed: every + # required signer other than the operator (whose slot the facilitator + # fills before broadcast) must already carry a valid signature over + # the message. A client can otherwise serialize a zeroed payer + # signature; the operator co-signs slot 0 and broadcasts, and the + # runtime rejects the open after the operator has paid the fee. + verify_cosigner_signatures!(transaction, operator, transaction.message.header[:required_signatures]) end # Validate the OpenArgs the client signed before the operator spends a @@ -192,6 +211,31 @@ def require_signer!(transaction, instruction, position, label) end end + # Verify the serialized signature bytes for every required signer slot + # except the operator's own (filled at co-sign). Rejects a zeroed or + # forged client signature before the operator spends a broadcast fee. + def verify_cosigner_signatures!(transaction, operator, required_signers) + message = transaction.message.raw + keys = transaction.message.account_keys + required_signers.times do |i| + signer = keys[i] + next if signer.nil? || signer == operator + + signature = transaction.signatures[i] + unless valid_signature?(signer, signature, message) + raise reject("open transaction signer #{signer} is missing a valid signature") + end + end + end + + def valid_signature?(pubkey, signature, message) + return false if signature.nil? || signature.bytesize != 64 + + ::Ed25519::VerifyKey.new(::PayCore::Solana::Base58.decode(pubkey)).verify(signature, message) + rescue + false + end + def account_at(instruction, keys, position) return nil if position >= instruction.accounts.length diff --git a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb index 9adb2106d..5f86cdd57 100644 --- a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb +++ b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb @@ -25,7 +25,12 @@ def setup signing = ::Ed25519::SigningKey.new(seed) @operator = base58(signing.verify_key.to_bytes.bytes) @operator_secret = JSON.generate(seed.bytes + signing.verify_key.to_bytes.bytes) - @payer = pubkey(2) + # The payer is a real keypair so fixtures can sign the open's payer slot; + # the server verifies that client signature before the operator co-signs. + payer_seed = "\x02".b * 32 + payer_signing = ::Ed25519::SigningKey.new(payer_seed) + @payer = base58(payer_signing.verify_key.to_bytes.bytes) + @payer_account = ::PayCore::Solana::Account.new(payer_seed.bytes + payer_signing.verify_key.to_bytes.bytes) @payee = pubkey(3) @mint = PROGRAMS::MINTS.fetch("USDC").fetch("devnet") @token_program = PROGRAMS::TOKEN_PROGRAM @@ -245,6 +250,12 @@ def test_rejects_open_with_short_signature_array assert_reject("signatures, expected") { engine.verify_open(tampered, now: now) } end + # The payer slot carries the right pubkey and a signer flag, but the client + # left its signature zeroed; reject before the operator co-signs and pays. + def test_rejects_open_with_unsigned_payer_slot + assert_reject("is missing a valid signature") { verify(salt: 27, sign_payer: false) } + end + private # Decode a header, rewrite its openTransaction via the block, re-encode. @@ -266,13 +277,13 @@ def channel_id(salt) def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, open_salt: nil, open_deposit: nil, open_recipients_count: 0, open_grace_period: 900, open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil, channel_fetcher: nil, - signature_confirmer: nil) + signature_confirmer: nil, sign_payer: true) cid = channel_id(salt) header = open_header(salt: salt, cid: cid, network: network, payload: payload, open_program: open_program, open_payee: open_payee || @payee, open_salt: open_salt || salt, open_deposit: open_deposit || MAX, open_recipients_count: open_recipients_count, open_grace_period: open_grace_period, open_payer_signer: open_payer_signer, - open_rent_payer_signer: open_rent_payer_signer, open_fee_payer: open_fee_payer) + open_rent_payer_signer: open_rent_payer_signer, open_fee_payer: open_fee_payer, sign_payer: sign_payer) fake = fake_channel(channel) engine = ::PayKit::Protocols::X402::Server::Upto.new( Upto::Config.new( @@ -289,17 +300,17 @@ def build_case(salt:, payload: {}, channel: {}, network: NETWORK, open_program: def verify(salt:, payload: {}, channel: {}, network: NETWORK, open_program: PC::PROGRAM_ID, open_payee: nil, now_override: nil, open_salt: nil, open_deposit: nil, open_recipients_count: 0, open_grace_period: 900, - open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil) + open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil, sign_payer: true) engine, header, = build_case(salt: salt, payload: payload, channel: channel, network: network, open_program: open_program, open_payee: open_payee, open_salt: open_salt, open_deposit: open_deposit, open_recipients_count: open_recipients_count, open_grace_period: open_grace_period, open_payer_signer: open_payer_signer, - open_rent_payer_signer: open_rent_payer_signer, open_fee_payer: open_fee_payer) + open_rent_payer_signer: open_rent_payer_signer, open_fee_payer: open_fee_payer, sign_payer: sign_payer) engine.verify_open(header, now: now_override || now) end def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:, open_salt:, open_deposit:, open_recipients_count:, open_grace_period:, - open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil) + open_payer_signer: true, open_rent_payer_signer: true, open_fee_payer: nil, sign_payer: true) payer_token = ::PayCore::Solana::ATA.derive(owner: @payer, mint: @mint, token_program: @token_program) channel_token = ::PayCore::Solana::ATA.derive(owner: cid, mint: @mint, token_program: @token_program) ea = PC.find_event_authority_pda @@ -315,6 +326,9 @@ def open_header(salt:, cid:, network:, payload:, open_program:, open_payee:, ope open_recipients_count.times { data += ::PayCore::Solana::Base58.decode(@payee) + [1].pack("v") } tx = MB.build_legacy(fee_payer: open_fee_payer || @operator, recent_blockhash: pubkey(9), instructions: [::PayCore::Solana::PreparedInstruction.new(open_program, accounts, data)]) + # The client signs the payer slot off-line before handing the open to the + # operator; only sign when the payer is actually a required signer. + tx.sign_with(@payer_account) if sign_payer && open_payer_signer body = { "profile" => "payment-channel", "from" => @payer, "maxAmount" => MAX.to_s, "expiresAt" => 4_102_444_800, "validAfter" => 0, "nonce" => "n#{salt}", "channelId" => cid, "deposit" => MAX.to_s, From 78be3591cf37221e035a978dd5811a33901513aa Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 30 Jun 2026 12:48:25 +0300 Subject: [PATCH 16/17] fix(ruby): verify duplicate operator signer slots on upto open 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. --- .../x402/protocol/schemes/upto/verify.rb | 15 ++++++--- .../protocols/x402/server_upto_test.rb | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb index 057ad8081..ea4bef8ef 100644 --- a/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb @@ -212,18 +212,23 @@ def require_signer!(transaction, instruction, position, label) end # Verify the serialized signature bytes for every required signer slot - # except the operator's own (filled at co-sign). Rejects a zeroed or - # forged client signature before the operator spends a broadcast fee. + # except the single one the operator fills at co-sign. Rejects a + # zeroed or forged client signature before the operator spends a + # broadcast fee. The facilitator signs exactly one slot (`sign_with` + # fills the first account-key match), so skipping that one index rather + # than every operator-keyed slot still verifies a duplicate operator + # slot the client may have planted in the signer range. def verify_cosigner_signatures!(transaction, operator, required_signers) message = transaction.message.raw keys = transaction.message.account_keys + operator_slot = keys.index(operator) required_signers.times do |i| - signer = keys[i] - next if signer.nil? || signer == operator + next if i == operator_slot + signer = keys[i] signature = transaction.signatures[i] unless valid_signature?(signer, signature, message) - raise reject("open transaction signer #{signer} is missing a valid signature") + raise reject("open transaction signer #{signer || ""} is missing a valid signature") end end end diff --git a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb index 5f86cdd57..e80c77c84 100644 --- a/ruby/test/pay_kit/protocols/x402/server_upto_test.rb +++ b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb @@ -25,6 +25,7 @@ def setup signing = ::Ed25519::SigningKey.new(seed) @operator = base58(signing.verify_key.to_bytes.bytes) @operator_secret = JSON.generate(seed.bytes + signing.verify_key.to_bytes.bytes) + @operator_account = ::PayCore::Solana::Account.from_json_array(@operator_secret) # The payer is a real keypair so fixtures can sign the open's payer slot; # the server verifies that client signature before the operator co-signs. payer_seed = "\x02".b * 32 @@ -256,8 +257,39 @@ def test_rejects_open_with_unsigned_payer_slot assert_reject("is missing a valid signature") { verify(salt: 27, sign_payer: false) } end + # The operator signs exactly one slot (sign_with fills the first key match), + # so a duplicate operator key planted in the signer range stays unsigned and + # must be rejected rather than skipped as "the operator's slot". + def test_cosigner_verification_rejects_duplicate_operator_signer_slot + message = "open-message-bytes".b + op_sig = @operator_account.sign(message) + payer_sig = @payer_account.sign(message) + # account_keys: operator (fee-payer slot the operator fills), a second + # operator copy in the signer range, then the payer. + tx = stub_tx([@operator, @operator, @payer], [op_sig, "\x00".b * 64, payer_sig], message) + error = assert_raises(::PayKit::Protocols::X402::Error::PaymentInvalid) do + Verifier.verify_cosigner_signatures!(tx, @operator, 3) + end + assert_includes error.message, "is missing a valid signature" + end + + def test_cosigner_verification_accepts_signed_payer_with_single_operator_slot + message = "open-message-bytes".b + tx = stub_tx([@operator, @payer], ["\x00".b * 64, @payer_account.sign(message)], message) + Verifier.verify_cosigner_signatures!(tx, @operator, 2) # no raise + end + private + Verifier = ::PayKit::Protocols::X402::Protocol::Schemes::Upto::Verifier + + # Minimal transaction double exposing only what verify_cosigner_signatures! + # reads: message.raw / message.account_keys and the signatures array. + def stub_tx(account_keys, signatures, raw) + message = Struct.new(:raw, :account_keys).new(raw, account_keys) + Struct.new(:message, :signatures).new(message, signatures) + end + # Decode a header, rewrite its openTransaction via the block, re-encode. def tamper_open_transaction(header) envelope = JSON.parse(Base64.strict_decode64(header)) From f91b97127e9b29a9366d516687b71d00fd3a84a5 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 30 Jun 2026 12:58:03 +0300 Subject: [PATCH 17/17] fix(ruby): log channel id when post-resource settlement fails 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. --- ruby/lib/pay_kit/usage.rb | 15 +++++++++++++++ ruby/test/pay_kit/usage_test.rb | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/ruby/lib/pay_kit/usage.rb b/ruby/lib/pay_kit/usage.rb index cc8a71c39..cf272b184 100644 --- a/ruby/lib/pay_kit/usage.rb +++ b/ruby/lib/pay_kit/usage.rb @@ -112,6 +112,7 @@ def call(env) settlement = @engine.settle_actual(open, settled) rescue => error close_body(body) + log_settlement_failure(open, settled, error) return settle_error(error) end @@ -152,6 +153,20 @@ def release_after_app_failure(open) log_orphaned_channel(open, settle_error) end + # A post-resource settlement broadcast may already have landed on-chain + # even though it raised here (RPC send or confirmation timeout). The client + # gets a 502, but the payer may already have been charged the metered + # amount, so log the channel id and amount loudly for manual reconciliation + # rather than returning the server error with no on-chain breadcrumb. + def log_settlement_failure(open, amount, error) + channel_id = open.respond_to?(:channel_id) ? open.channel_id : "unknown" + logger.warn( + "x402 upto: settlement of #{amount} failed for channel #{channel_id} after the resource ran; " \ + "the settle transaction may still land on-chain and charge the payer, so the channel needs manual reconciliation " \ + "(#{error.class}: #{error.message})" + ) + end + # Loud, actionable warning when a compensating zero settlement fails and a # channel may be stranded on-chain. Uses the PayKit logger convention so it # lands alongside the rest of the application log. diff --git a/ruby/test/pay_kit/usage_test.rb b/ruby/test/pay_kit/usage_test.rb index e684d3977..4d6fbdf22 100644 --- a/ruby/test/pay_kit/usage_test.rb +++ b/ruby/test/pay_kit/usage_test.rb @@ -200,6 +200,19 @@ def test_closes_dropped_body_on_settlement_failure assert body.closed end + def test_post_resource_settlement_failure_logs_channel_for_reconciliation + @engine.raise_on_settle = RuntimeError.new("rpc timeout") + log = StringIO.new + PayKit.logger = ::Logger.new(log).tap { |l| l.formatter = proc { |_s, _d, _p, msg| "#{msg}\n" } } + + status, = @mw.call(env_for("/usage", header: "HDR", meter: 40_000)) + assert_equal 502, status + assert_match(/chan_test/, log.string, "a post-resource settle failure must be logged with the channel id") + assert_match(/manual reconciliation/i, log.string, "the settle may land on-chain and charge the payer") + ensure + PayKit.logger = nil + end + def test_app_exception_settles_zero_and_reraises boom = ->(env) { env[::PayKit::Usage::CHARGE_ENV_KEY]&.charge(40_000)