Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0e0ab9e
feat(ruby): payment-channels client and legacy message builder
EfeDurmaz16 Jun 29, 2026
1bb5399
feat(ruby): x402 upto (usage-based) scheme, payment-channel profile
EfeDurmaz16 Jun 29, 2026
52024da
feat(ruby): wire x402 upto into the harness and CI
EfeDurmaz16 Jun 29, 2026
96e76d0
fix(ruby): harden upto open-arg validation and usage settle-after
EfeDurmaz16 Jun 29, 2026
2e49fb9
fix(ruby): pin upto open grace period to the canonical default
EfeDurmaz16 Jun 29, 2026
5df772f
fix(ruby): settle and release the channel when the protected app raises
EfeDurmaz16 Jun 29, 2026
bfdc857
fix(ruby): advertise extra.facilitator on upto requirements
EfeDurmaz16 Jun 29, 2026
6d1c95f
fix(ruby): align Charge to replace semantics (parity with Go/Python/TS)
EfeDurmaz16 Jun 29, 2026
f13a7fc
fix(ruby): generate payment-channels client with codama
EfeDurmaz16 Jun 29, 2026
3d7baf5
fix(ruby): surface stranded channel when app-failure zero settle fails
EfeDurmaz16 Jun 30, 2026
ebd4226
Merge remote-tracking branch 'origin/main' into pr192-merge
EfeDurmaz16 Jun 30, 2026
57dea05
fix(ci): point ruby upto harness at paykit-harness-bins client
EfeDurmaz16 Jun 30, 2026
b4e4651
fix(ruby): require signer privileges on upto open payer + rent_payer
EfeDurmaz16 Jun 30, 2026
4288c1b
fix(ruby): log orphaned upto channel when post-broadcast read fails
EfeDurmaz16 Jun 30, 2026
6288684
fix(ruby): pin upto open signature count and log on send failure
EfeDurmaz16 Jun 30, 2026
8a24537
fix(ruby): verify upto open co-signer signatures and reject lookup ta…
EfeDurmaz16 Jun 30, 2026
78be359
fix(ruby): verify duplicate operator signer slots on upto open
EfeDurmaz16 Jun 30, 2026
f91b971
fix(ruby): log channel id when post-resource settlement fails
EfeDurmaz16 Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 8 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──

Expand Down
60 changes: 57 additions & 3 deletions harness/ruby-server/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
require "stringio"

require_relative "../../ruby/lib/solana_pay_kit"
require "pay_kit/usage"

# --- env helpers -------------------------------------------------------

Expand All @@ -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
Expand All @@ -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 -------------------------------------------------

Expand Down Expand Up @@ -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 -----------------------------------------

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions harness/src/implementations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
];
2 changes: 2 additions & 0 deletions ruby/.standard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ignore:
- 'lib/pay_core/solana/generated/**/*'
42 changes: 40 additions & 2 deletions ruby/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)]()

---
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions ruby/lib/pay_core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions ruby/lib/pay_core/solana/generated/payment_channels.rb
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading