Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

45 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rail0-ruby

Ruby SDK for the RAIL0 stablecoin payment gateway.

RAIL0 is an immutable smart contract that brings the authorize → capture → refund lifecycle of card networks to stablecoin (USDC / EIP-3009) payments — no intermediaries, no protocol fees, no permission required. This SDK is a REST client for the RAIL0 gateway that sits in front of the contract, covering the full payment lifecycle plus account, wallet, catalog, and webhook management. It mirrors the rail0-go and rail0-ts SDKs.

Requirements

  • Ruby ≥ 2.6
  • For SIWE login and off-chain signing: eth (~> 0.5) and siwe-rb (~> 0.2)

The core HTTP client has no runtime dependencies (Ruby stdlib only). The eth and siwe-rb gems are loaded lazily — require "rail0" works without them, and they are needed only when you call client.auth.login or Rail0::Signing.

Installation

Add to your Gemfile:

gem "rail0"

# Only if you use SIWE login or off-chain signing:
gem "eth",     "~> 0.5"
gem "siwe-rb", "~> 0.2"

Quick start

A full authorize → capture flow. Every on-chain operation is two-phase: a *_prepare call returns an unsigned transaction, which you sign locally with Rail0::Signing.sign_transaction, then the matching submit call broadcasts it.

require "rail0"
require "rail0/signing"

client = Rail0::Client.new(base_url: "https://api.rail0.xyz")

# 1. Payer creates the payment — response embeds the EIP-3009 signing payload.
payment = client.payments.create(
  chain_id: 84532,
  mode:     "authorize",
  amount:   "50000000",       # 50 USDC (6 decimals)
  token:    "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  payer:    "0xBuyer…",
  payee:    "0xMerchant…"
)
rail0_id = payment[:rail0_id]

# 2. Payer signs the EIP-3009 payload off-chain and deposits the signature.
sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
client.payments.sign(rail0_id, { signature: sig.to_hex })

# 3. Payee prepares + broadcasts the on-chain authorize tx (signs it locally).
prep = client.payments.authorize_prepare(rail0_id)
raw  = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
client.payments.authorize(rail0_id, { signed_transaction: raw })   # HTTP 202 (async)

# 4. Poll until the authorization confirms.
loop do
  state = client.payments.get(rail0_id)
  break if state[:status] == "authorized"
  sleep 2
end

# 5. Payee captures the escrowed funds (partial capture is supported).
cap = client.payments.capture_prepare(rail0_id, "50000000")
raw = Rail0::Signing.sign_transaction(cap[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
client.payments.capture(rail0_id, { signed_transaction: raw })

All methods return a Hash with symbol keys (or raise Rail0::ApiError). A payment id argument accepts either the payment UUID or its rail0_id (bytes32).

Payment lifecycle

Each on-chain operation is a preparesubmit pair:

  1. prepareclient.payments.<op>_prepare(...) returns a transaction whose unsigned_transaction is the EIP-1559 field-set to sign.
  2. signRail0::Signing.sign_transaction(unsigned, private_key) returns the signed raw tx.
  3. submitclient.payments.<op>(id, { signed_transaction: raw }) broadcasts it. The gateway acknowledges with HTTP 202 and confirms asynchronously — poll client.payments.get(id) until the status settles.

Wallets that sign and broadcast in one step (e.g. MetaMask via eth_sendTransaction) skip steps 2–3 and report the hash instead:

client.payments.submit_by_hash(rail0_id, "capture", { transaction_hash: "0x…" })
Operation Caller What it does
authorize_prepare + authorize payee Broadcast the authorize tx; funds move to escrow
charge_prepare + charge payee One-shot authorize + capture; no escrow window
capture_prepare + capture payee Move escrowed funds to the merchant (partial supported)
void_prepare + void payee Cancel the hold, return funds to the payer (only before any capture)
release_prepare + release anyone Return uncaptured escrow to the payer
refund_prepare (phase 1+2) + refund payee Return captured funds to the payer via EIP-3009
dispute_prepare + dispute payer Open a dispute (signal-only)
close_dispute_prepare + close_dispute payer Close an open dispute

Payment statuses: unsigned, signed, authorized, charged, captured, partially_captured, voided, released, refunded. Transaction statuses: pending, submitting, submitted, confirmed, failed.

Authentication (SIWE)

JWT-protected endpoints (wallet management, webhooks, payments.list) need a Sign-In With Ethereum token. login runs the full handshake; pass the returned token to the client via headers.

auth = client.auth.login(private_key: "0x…", domain: "api.rail0.xyz")
# => { token:, address:, account_id:, name:, expires_at: }
# login embeds chain_id 1 by default; pass chain_id: to match a gateway whose
# SIWE_CHAIN_ID policy differs (e.g. login(private_key:, domain:, chain_id: 5042002)).

authed = Rail0::Client.new(
  base_url: "https://api.rail0.xyz",
  headers:  { "Authorization" => "Bearer #{auth[:token]}" }
)

Lower-level building blocks are also available:

nonce   = client.auth.nonce                                  # POST /auth/nonces
session = client.auth.verify(message: siwe_msg, signature: sig)  # POST /auth

Catalog (public)

client.chains.list                                    # GET /blockchains
client.chains.list(network_type: "testnet", symbol: "ETH")
client.tokens.list                                    # GET /tokens
client.tokens.list(chain_id: 84532, symbol: "USDC")

Health

client.health.get   # GET /health → { status:, api_version:, contract_version:, db:, … }

Payment methods (public discovery)

Buyer-facing discovery of a merchant's accepted wallets/tokens — no JWT. Provide exactly one of account_id or address.

client.payment_methods.list(account_id: "018e…")   # all the merchant's wallets
client.payment_methods.list(address: "0xMerchant…") # just that wallet

Wallets (account-scoped, JWT)

Wallets live under /accounts/{account_id}/wallets; every method takes the account id first. id_or_address accepts the wallet UUID or its 0x address.

client.wallets.list(account_id, chain_id: 84532, active: true)
# => { data: [ { id:, address:, label:, active:, tokens: [...] } ], meta: { page:, per_page:, total: } }

client.wallets.get(account_id, id_or_address)
client.wallets.create(account_id, address: "0x…", label: "Treasury")
client.wallets.update(account_id, id_or_address, label: "Renamed", active: false)
client.wallets.delete(account_id, id_or_address)                    # 204
client.wallets.balances(account_id, id_or_address, chain_id: 84532) # live on-chain balances

Payments

client.payments.create(params, idempotency_key: nil)  # or keyword fields
client.payments.get(id)
client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at")
client.payments.transactions(id, operation: "capture")
client.payments.sign(id, { signature: "0x…" })
client.payments.disputes(id, status: "open")   # one payment's dispute history

# Account-level: every dispute (open AND closed) across your payments, each with
# the parent payment embedded. A closed dispute drops out of the payments
# `disputed` filter (current-state) but still appears here.
client.disputes.list(status: "closed", sort: "-opened_at")

payments.list/transactions/disputes and disputes.list return a paginated { data:, meta: { page:, per_page:, total: } } envelope.

Refund (two-phase EIP-3009)

# Phase 1 — amount only → returns a signing payload for the payee to sign.
p1  = client.payments.refund_prepare(rail0_id, amount: "20000000")
sig = Rail0::Signing.sign_payload(MERCHANT_PRIVATE_KEY, p1[:signing_payload])

# Phase 2 — amount + signature → returns the unsigned on-chain tx.
p2  = client.payments.refund_prepare(rail0_id, amount: "20000000", signature: sig.to_hex)
raw = Rail0::Signing.sign_transaction(p2[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
client.payments.refund(rail0_id, { signed_transaction: raw })

Disputes (payer-driven)

Disputes are authorized on-chain by the payer (no JWT) and follow the same prepare → submit pattern:

prep = client.payments.dispute_prepare(rail0_id, reason: "0x…") # reason optional
raw  = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
client.payments.dispute(rail0_id, { signed_transaction: raw })
# … later …
client.payments.close_dispute_prepare(rail0_id)
client.payments.close_dispute(rail0_id, { signed_transaction: raw })

Generic prepare/submit

Every wrapper delegates to the generic form, useful for dynamic operations:

client.payments.prepare(id, "capture", { amount: "1" })
client.payments.submit(id, "capture", { signed_transaction: raw })
client.payments.submit_by_hash(id, "capture", { transaction_hash: "0x…" })

Webhooks (JWT)

A webhook subscribes to exactly one topic (see Rail0::Resources::Webhooks::TOPICS).

hook = client.webhooks.create(
  name: "orders", callback_url: "https://merchant.example/hook", topic: "payments.captured"
)
hook[:shared_secret]                 # shown once — verify delivery signatures with it

client.webhooks.list(topic: "payments.captured", active: true)
client.webhooks.get(id)
client.webhooks.update(id, callback_url: "https://new.example/hook")
client.webhooks.enable(id)
client.webhooks.disable(id)
client.webhooks.rotate_secret(id)    # returns a fresh shared_secret
client.webhooks.reset_circuit(id)
client.webhooks.event_callbacks(id, status: "failed")
client.webhooks.delete(id)           # 204

Analytics (JWT)

Account-scoped: the numbers are the merchant's own, so a buyer's account-less token is refused with account_required.

client.analytics.summary(status: "captured")
# => { count: 42, disputed_count: 1 }

# Volume needs BOTH token and chain_id — amounts in different tokens (or the same symbol
# on different chains) are different units, and summing them would produce a number that
# looks meaningful and isn't.
client.analytics.summary(token: usdc, chain_id: 84_532)
# => { count: 12, disputed_count: 0, volume: { token: "0x…", chain_id: 84532, total: "12000000" } }

client.analytics.timeseries(interval: "day", from: "2026-07-01T00:00:00Z")
# => [{ bucket: "2026-07-01", count: 3 }, …]  oldest first

client.analytics.breakdown(by: "chain")
# => [{ chain_id: 84532, count: 9 }, …]   by: token | chain | mode | status

All three take the same filters — mode, status, token, chain_id, from, to — so the same question can be asked at three resolutions: one total, a series over time, a split by dimension. Token and chain rows carry per-token volume; mode and status rows are counts only, for the reason above.

Signing helpers (Rail0::Signing)

Requires the eth gem. No private key ever leaves your process.

Method Use
sign_payload(private_key, signing_payload) Sign the EIP-3009 payload from a create/refund response (picks Transfer vs Receive by primaryType) — the recommended path
sign_transaction(unsigned_transaction, private_key) Sign a prepare step's unsigned EIP-1559 transaction; returns the 0x raw tx
sign_transfer_with_authorization(private_key, domain, params) Raw EIP-3009 TransferWithAuthorization signer
sign_authorize(params) / sign_charge(params) Lower-level payer signers built from an explicit SignPaymentParams
require "rail0/signing"
sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
client.payments.sign(rail0_id, { signature: sig.to_hex })

Logging

Pass any callable as logger to receive a Rail0::LogEntry per request attempt.

client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER)
# [rail0] GET 200 https://.../payments/0x… 87ms

# Or route into your own logger:
log = Logger.new($stdout)
client = Rail0::Client.new(
  base_url: "https://api.rail0.xyz",
  logger: ->(e) { e.error ? log.error("rail0: #{e.error}") : log.debug("rail0: #{e.method} #{e.status} #{e.duration_ms}ms") }
)

Error handling

Non-2xx responses raise Rail0::ApiError, carrying the gateway's code/title/detail triple:

begin
  client.payments.capture(rail0_id, { signed_transaction: raw })
rescue Rail0::ApiError => e
  e.status  # 422
  e.error   # "insufficient_token_balance" — branch on this, and only this
  e.title   # "Not enough balance" — a heading
  e.detail  # a sentence you can show a user verbatim (also e.message)
  e.hint    # this SDK's own extra advice, nil when it has none
end

error is the only field to branch on. It is the specific condition, read from the gateway's code and falling back to the older error sub-code and then to status (the wider family), so an older gateway still yields the most specific value it sent.

title and detail come from the gateway's error catalogue, so the same condition always reads the same way whichever endpoint surfaced it; detail is written to be shown to a user as-is.

hint (or Rail0.describe_error(code)) is this SDK's own advice — a supplement to detail, present only for codes with a next step worth adding. The codes span four families, and the last two are the ones most requests actually hit, neither raised by RAIL0 itself:

Family Examples
Request & state guards not_capturable, amount_exceeds_refundable, not_the_payee
RAIL0 custom errors not_payee, already_captured, refund_expired
Token reverts insufficient_token_balance, invalid_token_signature, authorization_already_used
Broadcast rejections insufficient_gas_funds, nonce_too_low, replacement_underpriced

A failed transaction carries the same triple as error_code, error_title and error_detail, whether it reverted on-chain or was refused before broadcast.

Configuration

Rail0::Client.new(
  base_url:    "https://api.rail0.xyz",
  headers:     { "Authorization" => "Bearer …" }, # for JWT-protected endpoints
  timeout:     30,                                # seconds (default 30)
  max_retries: 0,                                 # network-error retries (default 0)
  retry_delay: 0.2,                               # base delay, doubles each attempt
  logger:      Rail0::DEBUG_LOGGER                # optional
)

Only network errors and timeouts are retried; HTTP error responses are not.

Project structure

gen/generate.rb        regenerates lib/rail0/types.rb from the gateway OpenAPI schema

lib/rail0/
  client.rb            Rail0::Client — entry point
  http_client.rb       internal HTTP client (Net::HTTP, retry, pagination, logging)
  api_error.rb         Rail0::ApiError (code/title/detail + #hint)
  error_hints.rb       Rail0.describe_error — per-code next steps, shared with the other SDKs
  signing.rb           EIP-3009 + EIP-1559 signing (requires 'eth')
  stablecoins.rb       stablecoin address registry
  types.rb             generated Struct docs of the gateway schema (reference only)
  version.rb           Rail0::VERSION
  resources/
    auth.rb            SIWE authentication
    chains.rb          public blockchain catalog
    tokens.rb          public token catalog
    health.rb          gateway health check
    payment_methods.rb public payment-method discovery
    wallets.rb         account-scoped wallet management (JWT)
    payments.rb        payment lifecycle + disputes
    webhooks.rb        webhook subscription management (JWT)
    analytics.rb       account-scoped payment analytics (JWT)
    query.rb           shared query-string helper

Development

bundle install
bundle exec rspec        # run the test suite

# Regenerate lib/rail0/types.rb after a gateway schema change:
#   defaults to ../rail0-gateway/docs/openapi.json, or set RAIL0_SCHEMA_PATH.
ruby gen/generate.rb

License

MIT

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages