rail0-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,753 @@
1
+ # rail0-ruby
2
+
3
+ Ruby SDK for the [RAIL0](https://github.com/commercelayer/rail0) stablecoin payment gateway.
4
+
5
+ RAIL0 is an immutable smart contract that brings the authorize → capture → refund
6
+ lifecycle of card networks to stablecoin (USDC / EIP-3009) payments — no
7
+ intermediaries, no protocol fees, no permission required. This SDK is a REST client
8
+ for the RAIL0 gateway that sits in front of the contract, covering the full payment
9
+ lifecycle plus account, wallet, catalog, and webhook management. It mirrors the
10
+ [rail0-go](https://github.com/commercelayer/rail0-go) and
11
+ [rail0-ts](https://github.com/commercelayer/rail0-ts) SDKs.
12
+
13
+ ## Requirements
14
+
15
+ - Ruby ≥ 3.0
16
+ - For SIWE login and off-chain signing: `eth` (`~> 0.5`) and `siwe-rb` (`~> 0.2`)
17
+
18
+ The core HTTP client has **no runtime dependencies** (Ruby stdlib only). The `eth`
19
+ and `siwe-rb` gems are loaded lazily — `require "rail0"` works without them, and
20
+ they are needed only when you call `client.auth.login` or `Rail0::Signing`.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ gem install rail0-sdk
26
+ ```
27
+
28
+ Or, in a Gemfile:
29
+
30
+ ```ruby
31
+ gem "rail0-sdk"
32
+
33
+ # Only if you use SIWE login or off-chain signing:
34
+ gem "eth", "~> 0.5"
35
+ gem "siwe-rb", "~> 0.2"
36
+ ```
37
+
38
+ The gem is **`rail0-sdk`**; the require path is **`rail0`**:
39
+
40
+ ```ruby
41
+ require "rail0" # canonical
42
+ require "rail0-sdk" # also works, for whoever reaches for the installed name
43
+ ```
44
+
45
+ RubyGems has one global namespace and no scopes, so the gem is the SDK's name rather than
46
+ the protocol's — while `Rail0` stays the module every caller already uses.
47
+
48
+ ## Quick start
49
+
50
+ A full authorize → capture flow. Every on-chain operation is two-phase: a
51
+ `*_prepare` call returns an unsigned transaction, which you sign locally with
52
+ `Rail0::Signing.sign_transaction`, then the matching submit call broadcasts it.
53
+
54
+ **The whole `/payments` surface is authenticated**, and `create` requires the
55
+ caller to be the payer — so sign in first (see
56
+ [Authentication](#authentication-siwe)).
57
+
58
+ ```ruby
59
+ require "rail0"
60
+ require "rail0/signing"
61
+
62
+ GATEWAY = "https://api.rail0.xyz"
63
+
64
+ # 0. Sign in as the payer — POST /payments requires payer == caller.
65
+ auth = Rail0::Client.new(base_url: GATEWAY).auth.login(private_key: BUYER_PRIVATE_KEY, domain: "api.rail0.xyz")
66
+ client = Rail0::Client.new(base_url: GATEWAY, headers: { "Authorization" => "Bearer #{auth[:token]}" })
67
+
68
+ # 1. Payer creates the payment — response embeds the EIP-3009 signing payload.
69
+ payment = client.payments.create(
70
+ chain_id: 84532,
71
+ mode: "authorize",
72
+ amount: "50.00", # human decimals, NOT base units
73
+ token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
74
+ payer: "0xBuyer…",
75
+ payee: "0xMerchant…"
76
+ )
77
+ rail0_id = payment[:rail0_id]
78
+
79
+ # 2. Payer signs the EIP-3009 payload off-chain and deposits the signature.
80
+ sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
81
+ client.payments.sign(rail0_id, { signature: sig.to_hex })
82
+
83
+ # 3. Payee prepares + broadcasts the on-chain authorize tx (signs it locally).
84
+ prep = client.payments.authorize_prepare(rail0_id)
85
+ raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
86
+ client.payments.authorize(rail0_id, { signed_transaction: raw }) # HTTP 202 (async)
87
+
88
+ # 4. Poll until the authorization confirms.
89
+ loop do
90
+ state = client.payments.get(rail0_id)
91
+ break if state[:status] == "authorized"
92
+ sleep 2
93
+ end
94
+
95
+ # 5. Payee captures the escrowed funds (partial capture is supported).
96
+ cap = client.payments.capture_prepare(rail0_id, "50.00")
97
+ raw = Rail0::Signing.sign_transaction(cap[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
98
+ client.payments.capture(rail0_id, { signed_transaction: raw })
99
+ ```
100
+
101
+ All methods return a `Hash` with symbol keys (or raise `Rail0::ApiError`). A
102
+ payment id argument accepts either the payment UUID or its `rail0_id` (bytes32).
103
+
104
+ ## Payment lifecycle
105
+
106
+ Each on-chain operation is a `prepare` → `submit` pair:
107
+
108
+ 1. **prepare** — `client.payments.<op>_prepare(...)` returns a transaction whose
109
+ `unsigned_transaction` is the EIP-1559 field-set to sign.
110
+ 2. **sign** — `Rail0::Signing.sign_transaction(unsigned, private_key)` returns the
111
+ signed raw tx.
112
+ 3. **submit** — `client.payments.<op>(id, { signed_transaction: raw })` broadcasts
113
+ it. The gateway acknowledges with HTTP 202 and confirms asynchronously — poll
114
+ `client.payments.get(id)` until the status settles.
115
+
116
+ Wallets that sign **and** broadcast in one step (e.g. MetaMask via
117
+ `eth_sendTransaction`) skip steps 2–3 and report the hash instead:
118
+
119
+ ```ruby
120
+ client.payments.submit_by_hash(rail0_id, "capture", { transaction_hash: "0x…" })
121
+ ```
122
+
123
+ | Operation | Caller | What it does |
124
+ |-----------|--------|--------------|
125
+ | `authorize_prepare` + `authorize` | payee | Broadcast the authorize tx; funds move to escrow |
126
+ | `charge_prepare` + `charge` | payee | One-shot authorize + capture; no escrow window |
127
+ | `capture_prepare` + `capture` | payee | Move escrowed funds to the merchant (partial supported) |
128
+ | `void_prepare` + `void` | payee | Cancel the hold, return funds to the payer (only before any capture) |
129
+ | `release_prepare` + `release` | anyone | Return uncaptured escrow to the payer |
130
+ | `refund_prepare` (phase 1+2) + `refund` | payee | Return captured funds to the payer via EIP-3009 |
131
+ | `dispute_prepare` + `dispute` | payer | Open a dispute (signal-only) |
132
+ | `close_dispute_prepare` + `close_dispute` | payer | Close an open dispute |
133
+ | `dispute_submit_by_hash` / `close_dispute_submit_by_hash` | payer | Report a dispute tx the wallet already broadcast |
134
+
135
+ **Payment statuses:** `unsigned`, `signed`, `authorized`, `charged`, `captured`,
136
+ `partially_captured`, `voided`, `released`, `refunded`, `expired` — plus
137
+ `partially_refunded`, which is no longer produced (a partial refund deliberately leaves
138
+ the status alone) but is still a legal value on historical rows, so don't write an
139
+ exhaustive `case` that raises on it.
140
+
141
+ `expired` is a never-captured authorization whose window lapsed. It is **not** terminal:
142
+ the escrow is still on-chain and `release` still works from it (closing the payment as
143
+ `released`), so treating it as closed leaves the buyer's funds where they are.
144
+ **Transaction statuses:** `pending`, `submitting`, `submitted`, `confirmed`, `failed`.
145
+
146
+ ## Authentication (SIWE)
147
+
148
+ Sign-In With Ethereum gates **the entire `/payments` sub-tree** (create, sign,
149
+ every prepare/submit, reads and list) plus wallet management, webhooks, disputes
150
+ and analytics. The public surface is small: `chains`, `tokens`, `health` and
151
+ `payment_methods` (buyer-facing discovery). `login` runs the full handshake; pass
152
+ the returned token to the client via `headers`.
153
+
154
+ Two role rules are worth knowing before the first call, because both surface as a
155
+ 403 rather than a validation error:
156
+
157
+ - `payments.create` requires the caller to **be the payer** (`payer_must_be_caller`);
158
+ - the merchant operations (authorize, capture, charge, void, refund) are
159
+ **payee-only**, while `release` and the prepare steps accept either participant,
160
+ and `dispute`/`close_dispute` submits are **payer-only**.
161
+
162
+ A wallet that signs *and broadcasts* in one step (MetaMask) reports the result by hash
163
+ instead of handing over a signed transaction: `submit_by_hash` covers the merchant
164
+ operations, and the two dispute paths have their own payer-only methods
165
+ (`dispute_submit_by_hash`, `close_dispute_submit_by_hash`) because `dispute/close` is two
166
+ path segments and does not fit the generic shape.
167
+
168
+ ```ruby
169
+ auth = client.auth.login(private_key: "0x…", domain: "api.rail0.xyz")
170
+ # => { token:, address:, account_id:, name:, expires_at: }
171
+ # login embeds chain_id 1 by default; pass chain_id: to match a gateway whose
172
+ # SIWE_CHAIN_ID policy differs (e.g. login(private_key:, domain:, chain_id: 5042002)).
173
+
174
+ authed = Rail0::Client.new(
175
+ base_url: "https://api.rail0.xyz",
176
+ headers: { "Authorization" => "Bearer #{auth[:token]}" }
177
+ )
178
+ ```
179
+
180
+ For a long-lived process, pass a **callable** instead of a fixed header so one
181
+ shared client survives a token refresh — it is resolved per request:
182
+
183
+ ```ruby
184
+ client = Rail0::Client.new(base_url: GATEWAY, token: -> { Current.rail0_jwt })
185
+ ```
186
+
187
+ A String `token:` works for the simple case, and an explicit `Authorization` in
188
+ `headers` still takes precedence.
189
+
190
+ Lower-level building blocks are also available:
191
+
192
+ ```ruby
193
+ nonce = client.auth.nonce # POST /auth/nonces
194
+ session = client.auth.verify(message: siwe_msg, signature: sig) # POST /auth
195
+ client.auth.logout # POST /auth/logout
196
+ client.auth.revoke_all # POST /auth/revoke_all
197
+ ```
198
+
199
+ `logout` revokes **the token this client carries**, not every session for the address —
200
+ signing out one process leaves the others signed in. Read the answer: the gateway's
201
+ denylist **fails open** by design (a store outage must not sign out the whole platform),
202
+ so `{ revoked: false }` means the token is *still usable* until it expires, and the
203
+ caller should treat its own copy as compromised rather than assume the session is gone.
204
+
205
+ `revoke_all` is the other question, and `logout` cannot answer it: an address with five
206
+ live sessions would need five tokens you do not have. This is per **address** and reaches
207
+ the ones you never saw — including any an attacker is holding — which makes it the call
208
+ for a key you no longer trust. The gateway records a cutoff **instant** rather than
209
+ enumerating tokens, so a session minted a moment before the call is refused by its own
210
+ `iat`; that is what makes it durable where a denylist is not. The returned `cutoff` is
211
+ the field worth logging: it says exactly which sessions died, which `revoked: true`
212
+ cannot.
213
+
214
+ ## Catalog (public)
215
+
216
+ ```ruby
217
+ client.chains.list # GET /blockchains
218
+ client.chains.list(network_type: "testnet", symbol: "ETH")
219
+ client.tokens.list # GET /tokens
220
+ client.tokens.list(chain_id: 84532, symbol: "USDC")
221
+ ```
222
+
223
+ ## Health
224
+
225
+ ```ruby
226
+ client.health.get # GET /health → { status:, api_version:, contract_version:, db:, … }
227
+ ```
228
+
229
+ ## Pagination
230
+
231
+ Paginated calls return `{ data:, meta: }`. `meta` is `{ page:, per_page:, total:, total_pages:, links: }`.
232
+
233
+ `total_pages` is **zero** for an empty collection — "no pages" is what there are, so a pager rendered off it renders none. `links` comes from the `Link` header: `:first` and `:last` are always present, `:prev` and `:next` only where they exist, and the hash is empty when the collection is. The URIs are **relative** (path + query) and resolve against the URL you requested — the gateway emits them that way so they cannot advertise the wrong scheme through a TLS-terminating proxy.
234
+
235
+ ```ruby
236
+ page = client.payments.list(per_page: 100)
237
+ while page[:meta][:links][:next]
238
+ page = client.payments.list(page: page[:meta][:page] + 1, per_page: 100)
239
+ end
240
+ ```
241
+
242
+ ### Idempotent prepares
243
+
244
+ Every `*_prepare` takes an optional `idempotency_key:`. Without one, a retry that arrives after the first transaction was signed and broadcast opens a **second** one — right for a genuine sequential partial capture, wrong for a retry, and only the caller can tell those apart. Replaying a key returns the first transaction; the same key with different terms is refused `422 idempotency_key_reused`.
245
+
246
+ ```ruby
247
+ client.payments.capture_prepare(rail0_id, "50.00", idempotency_key: order_id)
248
+ ```
249
+
250
+ `get_transaction(id, transaction_id)` reads one of a payment's transactions — the lookup for an action id, so a caller holding one resolves it directly instead of listing and scanning.
251
+
252
+ ## Payment methods (public discovery)
253
+
254
+ Buyer-facing discovery of a merchant's accepted wallets/tokens — no JWT. Provide
255
+ **exactly one** of `account_id` or `address`.
256
+
257
+ ```ruby
258
+ client.payment_methods.list(account_id: "018e…") # all the merchant's wallets
259
+ client.payment_methods.list(address: "0xMerchant…") # just that wallet
260
+ ```
261
+
262
+ ## Wallets (account-scoped, JWT)
263
+
264
+ Wallets live under `/accounts/{account_id}/wallets`; every method takes the
265
+ account id first. `id_or_address` accepts the wallet UUID or its 0x address.
266
+
267
+ ```ruby
268
+ client.wallets.list(account_id, chain_id: 84532, active: true)
269
+ # => { data: [ { id:, address:, label:, active:, tokens: [...] } ], meta: { page:, per_page:, total: } }
270
+
271
+ client.wallets.get(account_id, id_or_address)
272
+ client.wallets.update(account_id, id_or_address, label: "Renamed", active: false)
273
+ client.wallets.delete(account_id, id_or_address) # 204
274
+ client.wallets.balances(account_id, id_or_address, chain_id: 84532) # live on-chain balances
275
+ ```
276
+
277
+ **Registering a wallet needs a SIWE proof-of-ownership of the address being
278
+ added**, not merely the session JWT. `auth.prove_address` runs that handshake and
279
+ returns the `message` + `signature` to splat into `create`. Sign with **the added
280
+ wallet's own key**, not the session key: the gateway rejects a signature that does
281
+ not recover to `address` (422), and an address already registered anywhere (409 —
282
+ addresses are globally unique). This is what lets one account control several
283
+ payee wallets.
284
+
285
+ ```ruby
286
+ proof = client.auth.prove_address(private_key: added_wallet_key, domain: "api.rail0.xyz")
287
+ client.wallets.create(account_id, address: "0x…", **proof, label: "Treasury")
288
+ ```
289
+
290
+ The proof is **purpose-bound**, and a login proof will not do — the gateway pins
291
+ each endpoint to one statement and refuses the other with 422
292
+ `siwe_purpose_mismatch`:
293
+
294
+ | Endpoint | Statement | Constant |
295
+ |---|---|---|
296
+ | `POST /auth` | `Sign in to RAIL0` | `Rail0::Resources::Auth::LOGIN_STATEMENT` |
297
+ | `POST /accounts/:id/wallets` | `Add this wallet to your RAIL0 account` | `Rail0::Resources::Auth::WALLET_LINK_STATEMENT` |
298
+
299
+ That is a security boundary rather than a label: a login signature is handed out
300
+ on every sign-in, so a wallet-link endpoint that accepted one would let anyone
301
+ holding a captured login proof bind that address to their **own** account.
302
+
303
+ Which tokens a wallet accepts — this is what `payment_methods` then exposes to
304
+ buyers, so it is the last step of merchant onboarding:
305
+
306
+ ```ruby
307
+ holding = client.wallets.add_token(account_id, id_or_address, chain_id: 84532, token: "0x…", default: true)
308
+ client.wallets.enable_token(account_id, id_or_address, holding[:id])
309
+ client.wallets.disable_token(account_id, id_or_address, holding[:id]) # keeps the holding
310
+ client.wallets.remove_token(account_id, id_or_address, holding[:id]) # 204, soft delete
311
+ ```
312
+
313
+ `add_token` is idempotent by (wallet, chain, token): re-adding an existing holding
314
+ re-enables it and answers 200 instead of creating a second row. The id passed to
315
+ the other three is the **holding's** id, not the token address.
316
+
317
+ ## Payments
318
+
319
+ ```ruby
320
+ client.payments.create(params, idempotency_key: nil) # or keyword fields
321
+ # Reusing a key for DIFFERENT terms raises Rail0::ApiError with code
322
+ # "idempotency_key_reused" (422) instead of returning the first payment.
323
+ client.payments.get(id)
324
+ client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at")
325
+ client.payments.transactions(id, operation: "capture")
326
+ client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast
327
+ # Offer `redrive` on the row's `redrivable` flag — the same predicate the gateway guards
328
+ # the route with — and not on `status == "pending"`: a pending row holding no signed
329
+ # transaction is not redrivable, and there the next step is submitting the signature, not
330
+ # retrying a send that never happened.
331
+ # Each row carries the on-chain gas data (gas_used, effective_gas_price, gas_cost) and
332
+ # `sender`: the address the gateway RECOVERED from the signature at submit. Null for a
333
+ # report-by-hash submit, where the wallet broadcast it itself and the gateway held no
334
+ # signature — which is why a `release` (payer OR payee) is only counted as the merchant's
335
+ # gas when that field names one of its wallets.
336
+ client.payments.sign(id, { signature: "0x…" })
337
+ client.payments.disputes(id, status: "open") # one payment's dispute history
338
+
339
+ # Account-level: every dispute (open AND closed) across your payments, each with
340
+ # the parent payment embedded. A closed dispute drops out of the payments
341
+ # `disputed` filter (current-state) but still appears here.
342
+ client.disputes.list(status: "closed", sort: "-opened_at")
343
+ ```
344
+
345
+ `payments.list`/`transactions`/`disputes` and `disputes.list` return a paginated
346
+ `{ data:, meta: { page:, per_page:, total: } }` envelope.
347
+
348
+ Every payment row carries `chain_id`, list rows included. You need it to display
349
+ an amount: `amount` is in base units and the token's decimals resolve from
350
+ `token` **plus** its chain — an address alone identifies a token only within one
351
+ chain — so a listing never needs a `get(id)` per row to render totals.
352
+
353
+ ### Refund (two-phase EIP-3009)
354
+
355
+ ```ruby
356
+ # Phase 1 — amount only → returns a signing payload for the payee to sign.
357
+ p1 = client.payments.refund_prepare(rail0_id, amount: "20.00")
358
+ sig = Rail0::Signing.sign_payload(MERCHANT_PRIVATE_KEY, p1[:signing_payload])
359
+
360
+ # Phase 2 — amount + signature → returns the unsigned on-chain tx.
361
+ p2 = client.payments.refund_prepare(rail0_id, amount: "20.00", signature: sig.to_hex)
362
+ raw = Rail0::Signing.sign_transaction(p2[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
363
+ client.payments.refund(rail0_id, { signed_transaction: raw })
364
+ ```
365
+
366
+ ### Disputes (payer-driven)
367
+
368
+ Disputes are authorized on-chain by the payer (no JWT) and follow the same
369
+ prepare → submit pattern:
370
+
371
+ ```ruby
372
+ prep = client.payments.dispute_prepare(rail0_id, reason: "0x…") # reason optional
373
+ raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
374
+ client.payments.dispute(rail0_id, { signed_transaction: raw })
375
+ # … later …
376
+ client.payments.close_dispute_prepare(rail0_id)
377
+ client.payments.close_dispute(rail0_id, { signed_transaction: raw })
378
+ ```
379
+
380
+ ### Generic prepare/submit
381
+
382
+ Every wrapper delegates to the generic form, useful for dynamic operations:
383
+
384
+ ```ruby
385
+ client.payments.prepare(id, "capture", { amount: "1" })
386
+ client.payments.submit(id, "capture", { signed_transaction: raw })
387
+ client.payments.submit_by_hash(id, "capture", { transaction_hash: "0x…" })
388
+ ```
389
+
390
+ ## Webhooks (JWT)
391
+
392
+ One subscription carries a **set** of topics (see `Rail0::Resources::Webhooks::TOPICS`):
393
+ one shared secret and one circuit breaker for all of them, with each delivery naming the
394
+ event that fired in `X-Rail0-Topic`. Two subscriptions for the same `callback_url` must not
395
+ overlap — the gateway answers 409 and names the topic that collided.
396
+
397
+ ```ruby
398
+ hook = client.webhooks.create(
399
+ name: "order-lifecycle", callback_url: "https://merchant.example/hook",
400
+ topics: ["payments.authorized", "payments.captured", "payments.voided", "payments.refunded"]
401
+ )
402
+ hook[:shared_secret] # shown once — verify delivery signatures with it
403
+
404
+ # `topic:` is singular here on purpose: which subscriptions deliver THIS event.
405
+ client.webhooks.list(topic: "payments.captured", active: true)
406
+ client.webhooks.get(id)
407
+ client.webhooks.update(id, callback_url: "https://new.example/hook")
408
+ # topics REPLACES the set, so this is also how a topic is removed. The secret is untouched.
409
+ client.webhooks.update(id, topics: ["payments.captured", "payments.refunded"])
410
+ client.webhooks.enable(id)
411
+ client.webhooks.disable(id)
412
+ client.webhooks.rotate_secret(id) # returns a fresh shared_secret
413
+ client.webhooks.reset_circuit(id)
414
+ client.webhooks.event_callbacks(id, status: "failed")
415
+ client.webhooks.delete(id) # 204
416
+ ```
417
+
418
+ ### Verifying a delivery
419
+
420
+ Every delivery carries `X-Rail0-Topic`, `X-Rail0-Timestamp` and
421
+ `X-Rail0-Signature` — a hex HMAC-SHA256 over `"{timestamp}.{body}"` keyed by the
422
+ webhook's `shared_secret`.
423
+
424
+ ```ruby
425
+ # Rack / Rails controller
426
+ def receive
427
+ raw = request.body.read
428
+
429
+ unless Rail0::WebhookSignature.verify(
430
+ body: raw, # the RAW body, not a re-serialised hash
431
+ signature: request.headers["X-Rail0-Signature"],
432
+ timestamp: request.headers["X-Rail0-Timestamp"],
433
+ secret: ENV.fetch("RAIL0_WEBHOOK_SECRET")
434
+ )
435
+ return head :unauthorized
436
+ end
437
+
438
+ handle(JSON.parse(raw, symbolize_names: true))
439
+ head :ok
440
+ end
441
+ ```
442
+
443
+ The timestamp is inside the signed string on purpose, and `verify` rejects one
444
+ outside ±300s (`tolerance:` to change it) **even when the digest matches** — that
445
+ window is what bounds a replay of a captured delivery. Pass the body exactly as
446
+ received: re-serialising a parsed hash changes key order and whitespace, and the
447
+ digest with it. Comparison is constant-time.
448
+
449
+ ## Account (JWT)
450
+
451
+ ```ruby
452
+ # The caller's OWN profile. The gateway guards /accounts/:id with an ownership check
453
+ # (a JWT whose account matches the path), so there is no way to read another
454
+ # merchant's account — and an id that is not an account answers 404 exactly as
455
+ # another account's id does, so the pair says nothing about existence.
456
+ client.accounts.get(account_id)
457
+ # => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test",
458
+ # created_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-20T00:00:00Z" }
459
+ ```
460
+
461
+ `email` is in the response because the holder is this endpoint's only possible caller.
462
+ The account's wallets are a collection under the same path (`client.wallets`), and
463
+ buyer-facing discovery is `client.payment_methods`.
464
+
465
+ ## Analytics (JWT)
466
+
467
+ Account-scoped: the numbers are the merchant's own, so a buyer's account-less token is
468
+ refused with `account_required`.
469
+
470
+ These methods return the parsed JSON as-is, so what follows is the contract — there is no
471
+ typed wrapper between your code and the gateway.
472
+
473
+ ```ruby
474
+ kpis = client.analytics.summary(status: "captured")
475
+ # => { orders: 42, disputed: 1, refund_rate: 0.07, dispute_rate: 0.02,
476
+ # failed_rate: 0.05, by_status: { captured: 40, refunded: 2 },
477
+ # volume: [...], gas: [...], gas_by_status: [...], gas_by_operation: [...] }
478
+
479
+ # Volume is per (token, chain) and always present — amounts in different tokens (or the
480
+ # same symbol on different chains) are different units, so they are grouped rather than
481
+ # added. `settled`/`escrowed` say where the money IS (the on-chain residuals the indexer
482
+ # mirrors); `captured`/`refunded` say what HAPPENED (the confirmed transactions). All are
483
+ # base-unit integer strings — format with the row's `decimals`.
484
+ kpis[:volume].first
485
+ # => { chain_id: 84532, chain_name: "Base Sepolia", token: "0x…", symbol: "USDC",
486
+ # decimals: 6, orders: 12, gross: "12000000", settled: "9000000",
487
+ # escrowed: "0", captured: "12000000", refunded: "3000000" }
488
+
489
+ # Gas is per CHAIN and in that chain's NATIVE token (decimals 18, never the payment
490
+ # token), so it is never summed across chains: Base ETH and Polygon POL are different
491
+ # currencies. `wasted` is gas an on-chain revert burned — money spent for nothing — and
492
+ # `orders` is the denominator for the average cost of an order.
493
+ kpis[:gas].first
494
+ # => { chain_id: 84532, chain_name: "Base Sepolia", symbol: "ETH", decimals: 18,
495
+ # orders: 12, spent: "72000", wasted: "10000", confirmed: 14, failed: 2,
496
+ # confirmation_secs: 45 }
497
+ # confirmation_secs is the mean broadcast->confirmation for THAT chain (weighted by its
498
+ # confirmations), nil when none confirmed — not 0, which would read as instant.
499
+
500
+ # Why the failures failed, commonest first. failed_rate says how much fails; this says what
501
+ # to act on: a revert is a state problem, a rejection that never reached the chain is a
502
+ # wallet problem.
503
+ kpis[:failures]
504
+ # => [{ code: "insufficient_gas_funds", transactions: 2 }, { code: "nonce_too_low", … }]
505
+
506
+ # The same rows regrouped, each with the `key` naming the cut; every cut adds back up to
507
+ # its chain's `gas` row. `orders` is nil on the operation cut — one order spans several
508
+ # operations — and the status cut is a SNAPSHOT: an authorize's gas sits under
509
+ # "authorized" until you capture, then under "captured".
510
+ kpis[:gas_by_status].first # => { key: "captured", spent: "62000", orders: 2, … }
511
+ kpis[:gas_by_operation].first # => { key: "capture", spent: "20000", orders: nil, … }
512
+
513
+ # `failed_rate` is per resolved TRANSACTION, not per order: one order can carry several
514
+ # attempts, and a retried capture that eventually confirms is what it exists to surface.
515
+
516
+ client.analytics.timeseries(interval: "day", from: "2026-07-01T00:00:00Z")
517
+ # => [{ bucket: "2026-07-01T00:00:00Z", orders: 3, volume: nil }, …] oldest first
518
+ # A bucket is ONE number, so it can carry volume only when both token and chain_id are
519
+ # filtered; otherwise `volume` is nil. "day" (default), "week" or "month" — no hourly.
520
+
521
+ client.analytics.breakdown(by: "chain")
522
+ # => [{ key: "Base Sepolia", chain_id: 84532, orders: 9 }, …]
523
+ # by: token | chain | mode | status | operation
524
+
525
+ # "operation" is the one dimension that groups the merchant's own CONFIRMED transactions
526
+ # rather than payments, so it reports both counts: `transactions` is how often it ran (a
527
+ # partial capture runs several times on one order), `orders` how many it touched.
528
+ client.analytics.breakdown(by: "operation")
529
+ # => [{ key: "capture", orders: 1, transactions: 2 }, …]
530
+ ```
531
+
532
+ All three take the same filters — `mode`, `status`, `token`, `chain_id`, `from`, `to` — so
533
+ the same question can be asked at three resolutions: one total, a series over time, a split
534
+ by dimension. `from`/`to` filter the payment's CREATION date, which matters when reading
535
+ gas: a payment created in one period and captured in the next books its capture gas in the
536
+ first.
537
+
538
+ ## Signing helpers (`Rail0::Signing`)
539
+
540
+ Requires the `eth` gem. No private key ever leaves your process.
541
+
542
+ | Method | Use |
543
+ |--------|-----|
544
+ | `sign_payload(signer, signing_payload)` | Sign the EIP-3009 payload from a create/refund response — the recommended path, and the only one that follows the gateway across contract versions |
545
+ | `sign_transaction(unsigned_transaction, key)` | Sign a prepare step's unsigned EIP-1559 transaction; returns the 0x raw tx |
546
+ | `sign_transfer_with_authorization(signer, domain, params)` | Raw EIP-3009 `TransferWithAuthorization` signer, for talking to a token directly |
547
+
548
+ ```ruby
549
+ require "rail0/signing"
550
+ sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
551
+ client.payments.sign(rail0_id, { signature: sig.to_hex })
552
+ ```
553
+
554
+ **The gateway builds the payload; you sign it verbatim.** `sign_payload` takes the
555
+ typehash from the payload's `primaryType` and every field from its `message` — only
556
+ the gateway knows which contract version a payment lives on, and the version selects
557
+ the typehash, the EIP-712 domain and the field layout. An unrecognised `primaryType`
558
+ raises rather than falling back, because a guessed typehash yields a *valid*
559
+ signature over the wrong digest, which fails only on-chain, after gas.
560
+
561
+ `sign_authorize` / `sign_charge` were removed for that reason — they rebuilt the
562
+ digest from a payment record. They raise with a pointer here.
563
+
564
+ ### Signing without a raw key
565
+
566
+ `sign_payload` and `sign_transfer_with_authorization` accept a private-key String,
567
+ an `Eth::Key`, or **any object responding to `#sign(digest)`** that returns a
568
+ 65-byte hex signature — a KMS or HSM client, a remote signer, a hardware-wallet
569
+ bridge. The SDK builds the EIP-712 digest and hands only that over, so the secret
570
+ need never be materialised as a String in your process:
571
+
572
+ ```ruby
573
+ class KmsSigner
574
+ def sign(digest) = MyKms.sign(key_id: KEY_ID, digest: digest) # -> "0x…" (65 bytes)
575
+ end
576
+
577
+ sig = Rail0::Signing.sign_payload(KmsSigner.new, payment[:signing_payload])
578
+ ```
579
+
580
+ `sign_transaction` is narrower on purpose: `Eth::Tx#sign` derives an EIP-155 `v`
581
+ from the chain id, so it needs a full `Eth::Key` (a String or an `Eth::Key`), not a
582
+ bare digest signer.
583
+
584
+ ## Logging
585
+
586
+ Pass any callable as `logger` to receive a `Rail0::LogEntry` per request attempt.
587
+
588
+ ```ruby
589
+ client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)
590
+ # D, [...] DEBUG -- : [rail0] GET 200 https://.../payments/0x… 87ms
591
+
592
+ # Rail0::DefaultLogger is a Logger subclass, so it takes any Logger.new argument:
593
+ client = Rail0::Client.new(
594
+ base_url: "https://api.rail0.xyz",
595
+ logger: Rail0::DefaultLogger.new("rail0.log", level: Logger::WARN)
596
+ )
597
+
598
+ # Or route into your own logger:
599
+ log = Logger.new($stdout)
600
+ client = Rail0::Client.new(
601
+ base_url: "https://api.rail0.xyz",
602
+ logger: ->(e) { e.error ? log.error("rail0: #{e.error}") : log.debug("rail0: #{e.method} #{e.status} #{e.duration_ms}ms") }
603
+ )
604
+ ```
605
+
606
+ ## Error handling
607
+
608
+ Non-2xx responses raise `Rail0::ApiError`, carrying the gateway's code/title/detail
609
+ triple:
610
+
611
+ ```ruby
612
+ begin
613
+ client.payments.capture(rail0_id, { signed_transaction: raw })
614
+ rescue Rail0::ApiError => e
615
+ e.status # 422
616
+ e.error # "insufficient_token_balance" — branch on this, and only this
617
+ e.title # "Not enough balance" — a heading
618
+ e.detail # a sentence you can show a user verbatim (also e.message)
619
+ e.hint # this SDK's own extra advice, nil when it has none
620
+ end
621
+ ```
622
+
623
+ **`error` is the only field to branch on.** It is the specific condition, read from the
624
+ gateway's `code` and falling back to the older `error` sub-code and then to `status` (the
625
+ wider family), so an older gateway still yields the most specific value it sent.
626
+
627
+ `title` and `detail` come from the gateway's error catalogue, so the same condition always
628
+ reads the same way whichever endpoint surfaced it; `detail` is written to be shown to a
629
+ user as-is.
630
+
631
+ `hint` (or `Rail0.describe_error(code)`) is this SDK's own advice — a *supplement* to
632
+ `detail`, present only for codes with a next step worth adding. The codes span four
633
+ families, and the last two are the ones most requests actually hit, neither raised by
634
+ RAIL0 itself:
635
+
636
+ | Family | Examples |
637
+ | --- | --- |
638
+ | Request & state guards | `not_capturable`, `amount_exceeds_refundable`, `not_the_payee` |
639
+ | RAIL0 custom errors | `not_payee`, `already_captured`, `refund_expired` |
640
+ | Token reverts | `insufficient_token_balance`, `invalid_token_signature`, `authorization_already_used` |
641
+ | Broadcast rejections | `insufficient_gas_funds`, `nonce_too_low`, `replacement_underpriced` |
642
+
643
+ A failed transaction carries the same triple as `error_code`, `error_title` and
644
+ `error_detail`, whether it reverted on-chain or was refused before broadcast.
645
+
646
+ ## Configuration
647
+
648
+ ```ruby
649
+ Rail0::Client.new(
650
+ base_url: "https://api.rail0.xyz",
651
+ headers: { "Authorization" => "Bearer …" }, # for JWT-protected endpoints
652
+ timeout: 30, # seconds (default 30)
653
+ max_retries: 0, # network-error retries (default 0)
654
+ retry_delay: 0.2, # base delay, doubles each attempt
655
+ retry_on_429: false, # retry a rate limit (default false)
656
+ retry_after_cap: 60, # longest Retry-After to honour, seconds
657
+ logger: Rail0::DEFAULT_LOGGER # optional
658
+ )
659
+ ```
660
+
661
+ ### Rate limits
662
+
663
+ The gateway throttles two surfaces independently: the public, unauthenticated one **per
664
+ IP** (100 requests / 60s by default — SIWE nonce + verify, `/payment_methods`, the
665
+ catalog reads, `/health`) and everything authenticated **per session**, keyed on the
666
+ JWT's subject (300 / 60s). Over budget it answers **429** with `code: "rate_limited"` and
667
+ a `Retry-After`.
668
+
669
+ `Rail0::ApiError#retry_after` carries that header as whole seconds — nil on every other
670
+ error, and nil when the header is absent or unusable. Read it rather than guessing:
671
+
672
+ ```ruby
673
+ begin
674
+ client.payments.list
675
+ rescue Rail0::ApiError => e
676
+ raise unless e.error == "rate_limited"
677
+ sleep(e.retry_after || 5) # the gateway's own pacing
678
+ end
679
+ ```
680
+
681
+ Note what the number means: the gateway sends **the whole throttle period**, not the time
682
+ left in the current window, so it is an upper bound on the wait rather than a measurement.
683
+
684
+ `retry_on_429: true` makes the SDK do that waiting for you — Retry-After, clamped to
685
+ `retry_after_cap`, plus a little jitter (see `Rail0::Backoff`; callers sharing one session
686
+ are told the same number and would otherwise wake in lockstep). The jitter never shortens
687
+ a wait below what it is for: additive on the server's own number, and equal jitter — half
688
+ fixed, half random — on a guessed one. It is **off by default**
689
+ on purpose: an automatic sleep hides back-pressure from the process that could react to
690
+ it, and in a request/response app it turns a rate limit into a stalled page. Turn it on in
691
+ a job — and note it sleeps the **calling thread**. It also works on its own: you do not
692
+ need to set `max_retries` as well (that pairing would make the flag a silent no-op).
693
+
694
+ Only network errors, timeouts and — when opted in — a 429 are retried; no other HTTP
695
+ error is. The 429 is safe to retry on **any** method, `POST` included, because the
696
+ gateway rejects it in middleware before the request reaches the application: nothing ran,
697
+ so nothing can run twice. That is not true of a 502 or a timeout on a capture, where the
698
+ broadcast may already be in flight.
699
+
700
+ ## Project structure
701
+
702
+ ```text
703
+ gen/generate.rb regenerates lib/rail0/types.rb from the gateway OpenAPI schema
704
+
705
+ lib/rail0/
706
+ client.rb Rail0::Client — entry point
707
+ http_client.rb thin per-verb facade (get/post/put/patch/delete) over Request
708
+ request.rb Rail0::Request — one HTTP call: retry, pagination, error mapping, logging
709
+ default_logger.rb Rail0::LogEntry + Rail0::DefaultLogger (Logger subclass) for `logger:`
710
+ api_error.rb Rail0::ApiError (code/title/detail + #hint)
711
+ error_hints.rb Rail0.describe_error — per-code next steps, shared with the other SDKs
712
+ signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
713
+ stablecoins.rb stablecoin address registry
714
+ types.rb generated Struct docs of the gateway schema (reference only)
715
+ version.rb Rail0::VERSION
716
+ resources/
717
+ auth.rb SIWE authentication
718
+ chains.rb public blockchain catalog
719
+ tokens.rb public token catalog
720
+ health.rb gateway health check
721
+ payment_methods.rb public payment-method discovery
722
+ wallets.rb account-scoped wallet management (JWT)
723
+ payments.rb payment lifecycle + disputes
724
+ webhooks.rb webhook subscription management (JWT)
725
+ analytics.rb account-scoped payment analytics (JWT)
726
+ query.rb shared query-string helper
727
+ ```
728
+
729
+ ## Development
730
+
731
+ ```bash
732
+ bundle install
733
+ bundle exec rake # rubocop, then the specs — the same gate CI runs
734
+
735
+ bundle exec rubocop # style only
736
+ bundle exec rubocop -a # and fix what is safely fixable
737
+ bundle exec rspec # specs only
738
+
739
+ # Regenerate lib/rail0/types.rb after a gateway schema change:
740
+ # defaults to ../rail0-gateway/docs/openapi.json, or set RAIL0_SCHEMA_PATH.
741
+ ruby gen/generate.rb
742
+ ```
743
+
744
+ `.rubocop.yml` is calibrated to the code that already exists rather than to rubocop's
745
+ defaults, and every relaxation in it carries the reason — double quotes because the project
746
+ uses them everywhere, table-aligned hashes because some literals are tables, and
747
+ `Naming/VariableNumber` off because the numbers in these names are protocol identifiers
748
+ (`retry_on_429`, `eip712`, `secp256k1`) that each spell themselves their own way. A linter
749
+ that argues with the codebase teaches people to ignore it.
750
+
751
+ ## License
752
+
753
+ [MIT](LICENSE)