diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 04d1ec99a..0bf827bb0 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: "paykit-harness-bins: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/Justfile b/Justfile index 1ed0daeb2..74a7ecbd5 100644 --- a/Justfile +++ b/Justfile @@ -96,8 +96,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/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 224633f54..477302e1c 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/.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/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 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/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/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..cbfe13e19 --- /dev/null +++ b/ruby/lib/pay_core/solana/payment_channels.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: true + +require "digest" + +require_relative "base58" +require_relative "public_key" +require_relative "ata" +require_relative "mints" +require_relative "instruction" +require_relative "generated/payment_channels" + +module PayCore + module Solana + # 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 + # live Surfpool e2e matrix is the proof. + module PaymentChannels + module_function + + 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" + # 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 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 = 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; + # `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 + # 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 + + generated = Generated::Channel.from_bytes(data) + Channel.new( + 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 -------------------------------------------- + # 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 + + 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 << prepared_instruction(settle, program_id: program_id) + 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) + + 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 + + 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 + # 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 + + 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/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/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..b00fcaf9e --- /dev/null +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/types.rb @@ -0,0 +1,145 @@ +# 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, + # 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 + } + 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..ea4bef8ef --- /dev/null +++ b/ruby/lib/pay_kit/protocols/x402/protocol/schemes/upto/verify.rb @@ -0,0 +1,263 @@ +# 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" + +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:, 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 + + 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 + + # 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) + + 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") + 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") + + 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 + # 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) + 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 + 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 + # 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 + + # 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 + + # Verify the serialized signature bytes for every required signer slot + # 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| + 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") + 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 + + 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..729e38e60 --- /dev/null +++ b/ruby/lib/pay_kit/protocols/x402/server/upto.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true + +require "logger" + +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 + maybe_broadcast = 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, max: parsed[:max] + ) + unless Verifier.fee_payer?(transaction, operator) + raise reject("open transaction fee payer must be the advertised operator") + 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) + + 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 + rescue => error + # 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 + 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 + + # 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 + 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..cf272b184 --- /dev/null +++ b/ruby/lib/pay_kit/usage.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require "json" +require "logger" +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 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 = 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? + + # 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 + + # 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 + # 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) + log_settlement_failure(open, settled, error) + 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] + end + + private + + def payment_header(env) + env["HTTP_" + Constants::PAYMENT_SIGNATURE_HEADER.upcase.tr("-", "_")] || + 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 + + # 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 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 => settle_error + open.release! if open.respond_to?(:release!) + 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. + 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, + # 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) + 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_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..daf1a3378 --- /dev/null +++ b/ruby/test/pay_core/payment_channels_test.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +# 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 + 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 + + 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 + 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 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..e80c77c84 --- /dev/null +++ b/ruby/test/pay_kit/protocols/x402/server_upto_test.rb @@ -0,0 +1,384 @@ +# 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) + @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 + 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 + 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_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) + 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 + + # ---- 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 + + 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}) } + 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 + + # 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 + + # 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 + + # 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 + + # 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)) + 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) + 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, + 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, 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, sign_payer: sign_payer) + 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: signature_confirmer || ->(_c, sig) { sig }, + channel_fetcher: 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, 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, 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, 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, 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 + 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 = [ + 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: 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, + "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..4d6fbdf22 --- /dev/null +++ b/ruby/test/pay_kit/usage_test.rb @@ -0,0 +1,259 @@ +# 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_replaces_with_last_value + charge = Charge.new(100) + charge.charge(30) + charge.charge(20) + # Last call wins (replace), matching the Go/Python/TS Charge. + assert_equal 20, 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, :raise_on_settle + + 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 + @raise_on_settle = nil + @settled_with = [] + end + + def verify_open(header) + raise @raise_on_verify if @raise_on_verify + + @verified_header = header + @last_open = Open.new(max_amount: 100_000, channel_id: "chan_test", released: false) + end + + 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 + + def payment_required(resource:) + "CHALLENGE:#{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) { + 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 + + 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 + + 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) + 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 + + 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) + 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 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",