rail0-sdk 1.0.0 → 1.2.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.
- checksums.yaml +4 -4
- data/README.md +124 -30
- data/lib/rail0/api_error.rb +4 -3
- data/lib/rail0/backoff.rb +4 -5
- data/lib/rail0/error_hints.rb +9 -3
- data/lib/rail0/http_client.rb +2 -2
- data/lib/rail0/resources/accounts.rb +35 -8
- data/lib/rail0/resources/analytics.rb +8 -2
- data/lib/rail0/resources/auth.rb +47 -14
- data/lib/rail0/resources/chains.rb +3 -1
- data/lib/rail0/resources/disputes.rb +1 -1
- data/lib/rail0/resources/payments.rb +18 -8
- data/lib/rail0/resources/tokens.rb +11 -4
- data/lib/rail0/resources/wallets.rb +18 -5
- data/lib/rail0/resources/webhooks.rb +36 -8
- data/lib/rail0/stablecoins.rb +38 -1
- data/lib/rail0/types.rb +100 -51
- data/lib/rail0/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60c25c2843d57ee49df3b17267e56a612f56701d61e48430786b1c6435987eb9
|
|
4
|
+
data.tar.gz: d8ef2d01d59b1a775f116753608bba6ab90d0d868a360040c0bc8fdc64f64de0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e7e6441337f7daf05f4e55ba22d94a380755f5775fd203b0699e8f16029fc493c88e9863a81e2d1559e71c383db7b79330503cd724c2219dd0a1b169badfac81
|
|
7
|
+
data.tar.gz: 943a28ce8740139e30e4966ce1021b9a85271789418a2274d505a6457733a09e8cc84c3c8f435ff34962dec3ed7e5beded3fc0f7cec131de626d8e5d6535fc5d
|
data/README.md
CHANGED
|
@@ -15,7 +15,8 @@ lifecycle plus account, wallet, catalog, and webhook management. It mirrors the
|
|
|
15
15
|
- Ruby ≥ 3.0
|
|
16
16
|
- For SIWE login and off-chain signing: `eth` (`~> 0.5`) and `siwe-rb` (`~> 0.2`)
|
|
17
17
|
|
|
18
|
-
The core HTTP client
|
|
18
|
+
The core HTTP client needs only the Ruby stdlib plus the `logger` gem (a declared
|
|
19
|
+
dependency, since it is no longer a default gem from Ruby 4.0). The `eth`
|
|
19
20
|
and `siwe-rb` gems are loaded lazily — `require "rail0"` works without them, and
|
|
20
21
|
they are needed only when you call `client.auth.login` or `Rail0::Signing`.
|
|
21
22
|
|
|
@@ -126,12 +127,17 @@ client.payments.submit_by_hash(rail0_id, "capture", { transaction_hash: "0x…"
|
|
|
126
127
|
| `charge_prepare` + `charge` | payee | One-shot authorize + capture; no escrow window |
|
|
127
128
|
| `capture_prepare` + `capture` | payee | Move escrowed funds to the merchant (partial supported) |
|
|
128
129
|
| `void_prepare` + `void` | payee | Cancel the hold, return funds to the payer (only before any capture) |
|
|
129
|
-
| `release_prepare` + `release` |
|
|
130
|
+
| `release_prepare` + `release` | payer or payee | Return uncaptured escrow to the payer |
|
|
130
131
|
| `refund_prepare` (phase 1+2) + `refund` | payee | Return captured funds to the payer via EIP-3009 |
|
|
131
132
|
| `dispute_prepare` + `dispute` | payer | Open a dispute (signal-only) |
|
|
132
133
|
| `close_dispute_prepare` + `close_dispute` | payer | Close an open dispute |
|
|
133
134
|
| `dispute_submit_by_hash` / `close_dispute_submit_by_hash` | payer | Report a dispute tx the wallet already broadcast |
|
|
134
135
|
|
|
136
|
+
`release` is the one merchant-side operation either participant may send: the contract
|
|
137
|
+
accepts only the payer or the payee as its sender, and anyone else is refused with 422
|
|
138
|
+
`release_submitter_not_a_party`. `release_prepare(id, from: "0x…")` builds the transaction
|
|
139
|
+
for that sender; omitted, `from` is the signed-in caller.
|
|
140
|
+
|
|
135
141
|
**Payment statuses:** `unsigned`, `signed`, `authorized`, `charged`, `captured`,
|
|
136
142
|
`partially_captured`, `voided`, `released`, `refunded`, `expired` — plus
|
|
137
143
|
`partially_refunded`, which is no longer produced (a partial refund deliberately leaves
|
|
@@ -141,6 +147,13 @@ exhaustive `case` that raises on it.
|
|
|
141
147
|
`expired` is a never-captured authorization whose window lapsed. It is **not** terminal:
|
|
142
148
|
the escrow is still on-chain and `release` still works from it (closing the payment as
|
|
143
149
|
`released`), so treating it as closed leaves the buyer's funds where they are.
|
|
150
|
+
|
|
151
|
+
How a `release` settles depends on the balances it leaves, not on the order operations
|
|
152
|
+
arrived in: a total release from `authorized` or `expired` closes the payment as
|
|
153
|
+
`released`; a release that leaves **both** `capturable_amount` and `refundable_amount` at
|
|
154
|
+
zero (say capture 40 → refund 40 → release 60) closes it as `refunded`; any other release
|
|
155
|
+
returns the escrow but leaves the status unchanged, so a captured residual stays
|
|
156
|
+
refundable and disputable.
|
|
144
157
|
**Transaction statuses:** `pending`, `submitting`, `submitted`, `confirmed`, `failed`.
|
|
145
158
|
|
|
146
159
|
## Authentication (SIWE)
|
|
@@ -155,9 +168,9 @@ Two role rules are worth knowing before the first call, because both surface as
|
|
|
155
168
|
403 rather than a validation error:
|
|
156
169
|
|
|
157
170
|
- `payments.create` requires the caller to **be the payer** (`payer_must_be_caller`);
|
|
158
|
-
- the merchant operations (authorize, capture, charge, void, refund)
|
|
159
|
-
**payee-only
|
|
160
|
-
and `dispute`/`close_dispute` submits are **payer-only**.
|
|
171
|
+
- the merchant operations (authorize, capture, charge, void, refund) and their prepare
|
|
172
|
+
steps are **payee-only**; `release` — prepare and submit — is the one that accepts
|
|
173
|
+
either participant; and `dispute`/`close_dispute` submits are **payer-only**.
|
|
161
174
|
|
|
162
175
|
A wallet that signs *and broadcasts* in one step (MetaMask) reports the result by hash
|
|
163
176
|
instead of handing over a signed transaction: `submit_by_hash` covers the merchant
|
|
@@ -167,7 +180,10 @@ path segments and does not fit the generic shape.
|
|
|
167
180
|
|
|
168
181
|
```ruby
|
|
169
182
|
auth = client.auth.login(private_key: "0x…", domain: "api.rail0.xyz")
|
|
170
|
-
# => { token:, address:, account_id:, name:, expires_at: }
|
|
183
|
+
# => { token:, address:, account_id:, name:, expires_at:, admin: }
|
|
184
|
+
# admin is always a boolean (verify too): true only for an account holding the
|
|
185
|
+
# operator grant, false otherwise (visibility only). account_id/name are nil for an
|
|
186
|
+
# account-less session.
|
|
171
187
|
# login embeds chain_id 1 by default; pass chain_id: to match a gateway whose
|
|
172
188
|
# SIWE_CHAIN_ID policy differs (e.g. login(private_key:, domain:, chain_id: 5042002)).
|
|
173
189
|
|
|
@@ -191,9 +207,9 @@ Lower-level building blocks are also available:
|
|
|
191
207
|
|
|
192
208
|
```ruby
|
|
193
209
|
nonce = client.auth.nonce # POST /auth/nonces
|
|
194
|
-
session = client.auth.verify(message: siwe_msg, signature: sig) # POST /auth
|
|
210
|
+
session = client.auth.verify(message: siwe_msg, signature: sig) # POST /auth — same hash as login
|
|
195
211
|
client.auth.logout # POST /auth/logout
|
|
196
|
-
client.auth.revoke_all
|
|
212
|
+
client.auth.revoke_all(private_key: "0x...", domain: "api.rail0.xyz") # POST /auth/revoke_all
|
|
197
213
|
```
|
|
198
214
|
|
|
199
215
|
`logout` revokes **the token this client carries**, not every session for the address —
|
|
@@ -207,19 +223,31 @@ live sessions would need five tokens you do not have. This is per **address** an
|
|
|
207
223
|
the ones you never saw — including any an attacker is holding — which makes it the call
|
|
208
224
|
for a key you no longer trust. The gateway records a cutoff **instant** rather than
|
|
209
225
|
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.
|
|
211
|
-
|
|
212
|
-
|
|
226
|
+
`iat`; that is what makes it durable where a denylist is not.
|
|
227
|
+
|
|
228
|
+
It is authorized by a **fresh SIWE proof** of the address, not by the session — whoever
|
|
229
|
+
is reacting to a leaked key holds the wallet, not the stolen token — so it takes the
|
|
230
|
+
private key and domain (and optional `chain_id:`, default 1) exactly like `login`, and
|
|
231
|
+
needs the same optional `eth`/`siwe-rb` gems. The proof carries its own statement
|
|
232
|
+
(`"Sign out of RAIL0 everywhere"`), so a login signature cannot be replayed here. It is
|
|
233
|
+
self-revoking: any token you hold dies too, so sign in again afterwards. It returns
|
|
234
|
+
`{ revoked_all: true, cutoff_at: "<ISO-8601>" }`; `cutoff_at` is the field worth
|
|
235
|
+
logging — it says exactly which sessions died, which `revoked_all: true` cannot.
|
|
213
236
|
|
|
214
237
|
## Catalog (public)
|
|
215
238
|
|
|
216
239
|
```ruby
|
|
217
|
-
client.chains.list # GET /blockchains
|
|
240
|
+
client.chains.list # GET /blockchains (each chain's :contract is the active RAIL0 deployment)
|
|
218
241
|
client.chains.list(network_type: "testnet", symbol: "ETH")
|
|
219
|
-
client.tokens.list # GET /tokens
|
|
242
|
+
client.tokens.list # GET /tokens — every token, retired ones included
|
|
220
243
|
client.tokens.list(chain_id: 84532, symbol: "USDC")
|
|
244
|
+
client.tokens.list(active: true) # what a NEW payment may use
|
|
221
245
|
```
|
|
222
246
|
|
|
247
|
+
`tokens.list` without `active:` is the **historical** catalogue — a payment references its
|
|
248
|
+
token forever, so a retired one must still resolve. A checkout picker should pass
|
|
249
|
+
`active: true`: `payments.create` answers 422 `unknown_token` for a retired token.
|
|
250
|
+
|
|
223
251
|
## Health
|
|
224
252
|
|
|
225
253
|
```ruby
|
|
@@ -230,6 +258,9 @@ client.health.get # GET /health → { status:, api_version:, contract_version:
|
|
|
230
258
|
|
|
231
259
|
Paginated calls return `{ data:, meta: }`. `meta` is `{ page:, per_page:, total:, total_pages:, links: }`.
|
|
232
260
|
|
|
261
|
+
`page` is 1-based and bounded to 1..1,000,000 — a value outside is a 400, not an empty
|
|
262
|
+
page. `per_page` defaults to 25 and is capped at 100.
|
|
263
|
+
|
|
233
264
|
`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
265
|
|
|
235
266
|
```ruby
|
|
@@ -267,6 +298,8 @@ account id first. `id_or_address` accepts the wallet UUID or its 0x address.
|
|
|
267
298
|
```ruby
|
|
268
299
|
client.wallets.list(account_id, chain_id: 84532, active: true)
|
|
269
300
|
# => { data: [ { id:, address:, label:, active:, tokens: [...] } ], meta: { page:, per_page:, total: } }
|
|
301
|
+
# chain_id / token_symbol / default / token_active narrow the NESTED holdings, never the wallets.
|
|
302
|
+
client.wallets.list(account_id, token_active: true)
|
|
270
303
|
|
|
271
304
|
client.wallets.get(account_id, id_or_address)
|
|
272
305
|
client.wallets.update(account_id, id_or_address, label: "Renamed", active: false)
|
|
@@ -288,13 +321,14 @@ client.wallets.create(account_id, address: "0x…", **proof, label: "Treasury")
|
|
|
288
321
|
```
|
|
289
322
|
|
|
290
323
|
The proof is **purpose-bound**, and a login proof will not do — the gateway pins
|
|
291
|
-
each endpoint to one statement and refuses the
|
|
324
|
+
each endpoint to one statement and refuses the others with 422
|
|
292
325
|
`siwe_purpose_mismatch`:
|
|
293
326
|
|
|
294
327
|
| Endpoint | Statement | Constant |
|
|
295
328
|
|---|---|---|
|
|
296
329
|
| `POST /auth` | `Sign in to RAIL0` | `Rail0::Resources::Auth::LOGIN_STATEMENT` |
|
|
297
330
|
| `POST /accounts/:id/wallets` | `Add this wallet to your RAIL0 account` | `Rail0::Resources::Auth::WALLET_LINK_STATEMENT` |
|
|
331
|
+
| `POST /auth/revoke_all` | `Sign out of RAIL0 everywhere` | `Rail0::Resources::Auth::REVOKE_ALL_STATEMENT` |
|
|
298
332
|
|
|
299
333
|
That is a security boundary rather than a label: a login signature is handed out
|
|
300
334
|
on every sign-in, so a wallet-link endpoint that accepted one would let anyone
|
|
@@ -305,14 +339,20 @@ buyers, so it is the last step of merchant onboarding:
|
|
|
305
339
|
|
|
306
340
|
```ruby
|
|
307
341
|
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[:
|
|
309
|
-
client.wallets.disable_token(account_id, id_or_address, holding[:
|
|
310
|
-
client.wallets.remove_token(account_id, id_or_address, holding[:
|
|
342
|
+
client.wallets.enable_token(account_id, id_or_address, holding[:token_id])
|
|
343
|
+
client.wallets.disable_token(account_id, id_or_address, holding[:token_id]) # keeps the holding
|
|
344
|
+
client.wallets.remove_token(account_id, id_or_address, holding[:token_id]) # 204, soft delete
|
|
311
345
|
```
|
|
312
346
|
|
|
313
347
|
`add_token` is idempotent by (wallet, chain, token): re-adding an existing holding
|
|
314
348
|
re-enables it and answers 200 instead of creating a second row. The id passed to
|
|
315
|
-
the other three is the **
|
|
349
|
+
the other three is the **token's** UUID (`token_id` on the holding), not an id of the
|
|
350
|
+
holding row and not the token address — the gateway looks the holding up by (wallet, token).
|
|
351
|
+
|
|
352
|
+
The wallet's active **default** holding cannot be disabled or removed — that would leave it
|
|
353
|
+
with no preferred payment method — and the gateway answers 422 `default_payment_method`.
|
|
354
|
+
Make another token the default first (`add_token(..., default: true)` demotes the old one
|
|
355
|
+
in the same transaction), then disable or remove it.
|
|
316
356
|
|
|
317
357
|
## Payments
|
|
318
358
|
|
|
@@ -322,7 +362,9 @@ client.payments.create(params, idempotency_key: nil) # or keyword fields
|
|
|
322
362
|
# "idempotency_key_reused" (422) instead of returning the first payment.
|
|
323
363
|
client.payments.get(id)
|
|
324
364
|
client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at")
|
|
365
|
+
client.payments.list(operation: "refund") # payments with at least one refund transaction
|
|
325
366
|
client.payments.transactions(id, operation: "capture")
|
|
367
|
+
# `operation` on both takes Payments::TRANSACTION_OPERATIONS — dispute and close_dispute included.
|
|
326
368
|
client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast
|
|
327
369
|
# Offer `redrive` on the row's `redrivable` flag — the same predicate the gateway guards
|
|
328
370
|
# the route with — and not on `status == "pending"`: a pending row holding no signed
|
|
@@ -365,8 +407,11 @@ client.payments.refund(rail0_id, { signed_transaction: raw })
|
|
|
365
407
|
|
|
366
408
|
### Disputes (payer-driven)
|
|
367
409
|
|
|
368
|
-
|
|
369
|
-
|
|
410
|
+
> **Operations go through the gateway.** The gateway mirrors only the operations it prepared. A dispute, close-dispute, capture, void, refund or release sent to the RAIL0 contract directly — outside this SDK's prepare/submit calls — happens on-chain, but the payment's status and balances on the gateway do not change and no webhook is sent; a merchant is not told about a dispute its buyer opened that way. The gateway records it for its operators as a sync error (`external_operation`).
|
|
411
|
+
|
|
412
|
+
Disputes are signed on-chain by the payer and follow the same prepare → submit
|
|
413
|
+
pattern. Like the rest of `/payments` they need a session, but the payer's
|
|
414
|
+
account-less SIWE login is enough:
|
|
370
415
|
|
|
371
416
|
```ruby
|
|
372
417
|
prep = client.payments.dispute_prepare(rail0_id, reason: "0x…") # reason optional
|
|
@@ -411,10 +456,18 @@ client.webhooks.enable(id)
|
|
|
411
456
|
client.webhooks.disable(id)
|
|
412
457
|
client.webhooks.rotate_secret(id) # returns a fresh shared_secret
|
|
413
458
|
client.webhooks.reset_circuit(id)
|
|
414
|
-
|
|
459
|
+
# status is "delivered" or "failed" only; payment_id takes a UUID or a rail0_id.
|
|
460
|
+
client.webhooks.event_callbacks(id, status: "failed", response_code: "500")
|
|
461
|
+
client.webhooks.redeliver(id, callback_id) # 202 { status: "queued" } — replay one delivery
|
|
415
462
|
client.webhooks.delete(id) # 204
|
|
416
463
|
```
|
|
417
464
|
|
|
465
|
+
`redeliver` replays a recorded delivery's exact payload — the same event id, so a receiver
|
|
466
|
+
that already processed it deduplicates — under a fresh signature. It is the way back for
|
|
467
|
+
events lost while the circuit breaker was open. Delivery is async and the dispatcher drops
|
|
468
|
+
anything for an inactive webhook, so `enable` (if disabled) or `reset_circuit` (if the
|
|
469
|
+
breaker opened) **first**, or the 202 is answered and the replay silently dropped.
|
|
470
|
+
|
|
418
471
|
### Verifying a delivery
|
|
419
472
|
|
|
420
473
|
Every delivery carries `X-Rail0-Topic`, `X-Rail0-Timestamp` and
|
|
@@ -456,9 +509,19 @@ digest with it. Comparison is constant-time.
|
|
|
456
509
|
client.accounts.get(account_id)
|
|
457
510
|
# => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test",
|
|
458
511
|
# created_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-20T00:00:00Z" }
|
|
512
|
+
|
|
513
|
+
# Update the same profile — only the fields passed are sent.
|
|
514
|
+
client.accounts.update(account_id, email: "ops@merchant.example")
|
|
515
|
+
client.accounts.update(account_id, name: "Merchant Ltd", email: "ops@merchant.example")
|
|
459
516
|
```
|
|
460
517
|
|
|
461
518
|
`email` is in the response because the holder is this endpoint's only possible caller.
|
|
519
|
+
|
|
520
|
+
`update` needs at least one of `name`/`email` (it raises `ArgumentError` before sending an
|
|
521
|
+
empty PATCH, which the gateway would answer with 400). Both are unique across accounts, so
|
|
522
|
+
a value another account holds answers **409**. It is a write on the account, so a
|
|
523
|
+
deactivated wallet (403 `wallet_deactivated`) or account (403 `account_deactivated`) cannot
|
|
524
|
+
make it. The gateway's `active` field is operator-only and not exposed here.
|
|
462
525
|
The account's wallets are a collection under the same path (`client.wallets`), and
|
|
463
526
|
buyer-facing discovery is `client.payment_methods`.
|
|
464
527
|
|
|
@@ -529,9 +592,17 @@ client.analytics.breakdown(by: "operation")
|
|
|
529
592
|
# => [{ key: "capture", orders: 1, transactions: 2 }, …]
|
|
530
593
|
```
|
|
531
594
|
|
|
532
|
-
All three take the same filters — `mode`, `status`, `token`, `chain_id`, `from`, `to
|
|
533
|
-
the same question can be asked at three resolutions: one total, a series over
|
|
534
|
-
by dimension.
|
|
595
|
+
All three take the same filters — `mode`, `status`, `token`, `chain_id`, `from`, `to`,
|
|
596
|
+
`payee` — so the same question can be asked at three resolutions: one total, a series over
|
|
597
|
+
time, a split by dimension. Figures cover all of the account's wallets unless `payee` pins
|
|
598
|
+
one of them (a 0x address the account owns — another address is refused with 403, a
|
|
599
|
+
malformed one with 400):
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
client.analytics.summary(payee: "0xYourWallet…")
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
`from`/`to` filter the payment's CREATION date, which matters when reading
|
|
535
606
|
gas: a payment created in one period and captured in the next books its capture gas in the
|
|
536
607
|
first.
|
|
537
608
|
|
|
@@ -621,8 +692,7 @@ end
|
|
|
621
692
|
```
|
|
622
693
|
|
|
623
694
|
**`error` is the only field to branch on.** It is the specific condition, read from the
|
|
624
|
-
gateway's `code
|
|
625
|
-
wider family), so an older gateway still yields the most specific value it sent.
|
|
695
|
+
gateway's `code`.
|
|
626
696
|
|
|
627
697
|
`title` and `detail` come from the gateway's error catalogue, so the same condition always
|
|
628
698
|
reads the same way whichever endpoint surfaced it; `detail` is written to be shown to a
|
|
@@ -661,8 +731,8 @@ Rail0::Client.new(
|
|
|
661
731
|
### Rate limits
|
|
662
732
|
|
|
663
733
|
The gateway throttles two surfaces independently: the public, unauthenticated one **per
|
|
664
|
-
IP** (100 requests / 60s by default — SIWE nonce + verify, `/
|
|
665
|
-
catalog reads, `/health`) and everything authenticated **per session**, keyed on the
|
|
734
|
+
IP** (100 requests / 60s by default — SIWE nonce + verify, `/auth/revoke_all`,
|
|
735
|
+
`/payment_methods`, the catalog reads, `/health`) and everything authenticated **per session**, keyed on the
|
|
666
736
|
JWT's subject (300 / 60s). Over budget it answers **429** with `code: "rate_limited"` and
|
|
667
737
|
a `Retry-After`.
|
|
668
738
|
|
|
@@ -678,8 +748,8 @@ rescue Rail0::ApiError => e
|
|
|
678
748
|
end
|
|
679
749
|
```
|
|
680
750
|
|
|
681
|
-
Note what the number means: the gateway sends **the
|
|
682
|
-
|
|
751
|
+
Note what the number means: the gateway sends **the time left in the current window**
|
|
752
|
+
(the same value as `RateLimit-Reset`), not the window's length, so it is the wait itself.
|
|
683
753
|
|
|
684
754
|
`retry_on_429: true` makes the SDK do that waiting for you — Retry-After, clamped to
|
|
685
755
|
`retry_after_cap`, plus a little jitter (see `Rail0::Backoff`; callers sharing one session
|
|
@@ -709,6 +779,8 @@ lib/rail0/
|
|
|
709
779
|
default_logger.rb Rail0::LogEntry + Rail0::DefaultLogger (Logger subclass) for `logger:`
|
|
710
780
|
api_error.rb Rail0::ApiError (code/title/detail + #hint)
|
|
711
781
|
error_hints.rb Rail0.describe_error — per-code next steps, shared with the other SDKs
|
|
782
|
+
backoff.rb Rail0::Backoff — retry delays and 429 jitter
|
|
783
|
+
webhook_signature.rb Rail0::WebhookSignature — delivery verification
|
|
712
784
|
signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
|
|
713
785
|
stablecoins.rb stablecoin address registry
|
|
714
786
|
types.rb generated Struct docs of the gateway schema (reference only)
|
|
@@ -721,6 +793,8 @@ lib/rail0/
|
|
|
721
793
|
payment_methods.rb public payment-method discovery
|
|
722
794
|
wallets.rb account-scoped wallet management (JWT)
|
|
723
795
|
payments.rb payment lifecycle + disputes
|
|
796
|
+
disputes.rb account-level dispute listing (JWT)
|
|
797
|
+
accounts.rb the caller's own account profile, read and update (JWT)
|
|
724
798
|
webhooks.rb webhook subscription management (JWT)
|
|
725
799
|
analytics.rb account-scoped payment analytics (JWT)
|
|
726
800
|
query.rb shared query-string helper
|
|
@@ -748,6 +822,26 @@ uses them everywhere, table-aligned hashes because some literals are tables, and
|
|
|
748
822
|
(`retry_on_429`, `eip712`, `secp256k1`) that each spell themselves their own way. A linter
|
|
749
823
|
that argues with the codebase teaches people to ignore it.
|
|
750
824
|
|
|
825
|
+
## Publishing
|
|
826
|
+
|
|
827
|
+
Releases publish from GitHub Actions (`.github/workflows/release.yml`) through RubyGems
|
|
828
|
+
**Trusted Publishing**: RubyGems.org trusts that workflow in this repo, so there is no API
|
|
829
|
+
key to store or rotate and no OTP prompt.
|
|
830
|
+
|
|
831
|
+
1. Merge a PR that bumps `Rail0::VERSION` in `lib/rail0/version.rb`.
|
|
832
|
+
2. Publish a GitHub release on `main` tagged `v<version>` (e.g. `v1.2.0`).
|
|
833
|
+
|
|
834
|
+
The workflow checks the tag against `Rail0::VERSION`, runs `rake build` (rubocop and the
|
|
835
|
+
specs first, then the gem) and pushes it. A version already on RubyGems.org is skipped, so
|
|
836
|
+
a re-run (Actions → release → Run workflow, from `main`, with the tag as input) is always
|
|
837
|
+
safe — that is also how a release cut before the workflow existed gets published.
|
|
838
|
+
|
|
839
|
+
One-time setup, by an owner of the gem on rubygems.org: rail0-sdk → Trusted publishers →
|
|
840
|
+
GitHub Actions, repository `commercelayer/rail0-ruby`, workflow `release.yml`.
|
|
841
|
+
|
|
842
|
+
Publishing by hand still works as a fallback (`gem build rail0-sdk.gemspec && gem push
|
|
843
|
+
rail0-sdk-<version>.gem`, with the OTP the gem's `rubygems_mfa_required` asks for).
|
|
844
|
+
|
|
751
845
|
## License
|
|
752
846
|
|
|
753
847
|
[MIT](LICENSE)
|
data/lib/rail0/api_error.rb
CHANGED
|
@@ -21,9 +21,10 @@ module Rail0
|
|
|
21
21
|
# otherwise. Surfaced because the alternative is a caller guessing: the SDK used
|
|
22
22
|
# to drop the header, so "rate limited" arrived with no idea of for how long.
|
|
23
23
|
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
24
|
+
# It is the time LEFT in the current throttle window, not the window's length:
|
|
25
|
+
# the limiter buckets by wall clock, so the gateway computes the remainder exactly
|
|
26
|
+
# (the same number it sends as RateLimit-Reset). Rail0::Backoff still clamps it,
|
|
27
|
+
# against a hostile or misconfigured value from anything in between.
|
|
27
28
|
attr_reader :status, :error, :title, :detail, :retry_after
|
|
28
29
|
|
|
29
30
|
# @param status [Integer]
|
data/lib/rail0/backoff.rb
CHANGED
|
@@ -24,11 +24,10 @@ module Rail0
|
|
|
24
24
|
# per-session bucket and would all be told the same Retry-After, wake together, and
|
|
25
25
|
# recreate the burst the limiter just rejected.
|
|
26
26
|
#
|
|
27
|
-
# 2. THE CAP IS NOT PARANOIA. The gateway sends the
|
|
28
|
-
# (
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# from anything between the client and the gateway.
|
|
27
|
+
# 2. THE CAP IS NOT PARANOIA. The gateway sends the time LEFT in the throttle window as
|
|
28
|
+
# Retry-After (it used to send the whole period, which over-waited by up to a full
|
|
29
|
+
# window). Even an exact number comes through whatever sits between the client and
|
|
30
|
+
# the gateway, so capping bounds a hostile or misconfigured value.
|
|
32
31
|
module Backoff
|
|
33
32
|
module_function
|
|
34
33
|
|
data/lib/rail0/error_hints.rb
CHANGED
|
@@ -51,6 +51,11 @@ module Rail0
|
|
|
51
51
|
"not_payee" => "only the merchant (payee) may do this",
|
|
52
52
|
"not_payer" => "only the buyer (payer) may do this",
|
|
53
53
|
"not_payer_or_payee" => "only the payer or the payee may do this",
|
|
54
|
+
"release_submitter_not_a_party" => "a release must be sent by the payment's payer or payee — omit `from` to build it for the signed-in wallet",
|
|
55
|
+
"payer_must_be_caller" => "the payer must be the signed-in wallet — sign in as the buyer to create its payment",
|
|
56
|
+
"wallet_deactivated" => "this wallet is deactivated: it can still read, but not act on payments or change the account — reactivate it or sign in with another of the account's wallets",
|
|
57
|
+
"account_deactivated" => "the merchant account is deactivated: void, release and refund still work, but authorize, charge, capture and account changes do not — ask an operator to reactivate it",
|
|
58
|
+
"not_redrivable" => "only a pending transaction that already holds its signed bytes can be redriven — check the transaction's `redrivable` flag, and submit the signature if it has none",
|
|
54
59
|
"refund_expired" => "the refund window has closed (refundExpiry passed) — refund/dispute is no longer possible",
|
|
55
60
|
"authorization_not_expired" => "release opens only after authorizationExpiry — wait until it passes",
|
|
56
61
|
"already_captured" => "already (partially) captured — use release for the remainder, not void",
|
|
@@ -63,9 +68,10 @@ module Rail0
|
|
|
63
68
|
"no_active_contract" => "no active RAIL0 contract on that chain",
|
|
64
69
|
"missing_param" => "a required parameter is missing from the request",
|
|
65
70
|
# A BARE forbidden is not a party mismatch: the gateway split those into codes of
|
|
66
|
-
# their own (not_the_payee, not_the_payer, wallet_deactivated,
|
|
67
|
-
# because they need different fixes. This entry kept describing
|
|
68
|
-
# after the split — and the rule it named no longer exists in the
|
|
71
|
+
# their own (not_the_payee, not_the_payer, wallet_deactivated, account_deactivated,
|
|
72
|
+
# not_your_account) because they need different fixes. This entry kept describing
|
|
73
|
+
# one of them long after the split — and the rule it named no longer exists in the
|
|
74
|
+
# gateway at all.
|
|
69
75
|
"forbidden" => "not permitted for this session — typically the operator grant, a resource owned by another account, or a transaction signed by the wrong wallet"
|
|
70
76
|
}.freeze
|
|
71
77
|
|
data/lib/rail0/http_client.rb
CHANGED
|
@@ -17,8 +17,8 @@ module Rail0
|
|
|
17
17
|
# It does NOT need `max_retries` to be set as well. That pairing is a footgun —
|
|
18
18
|
# the flag would silently do nothing — so on its own it allows one retry.
|
|
19
19
|
# @param retry_after_cap [Numeric] longest wait to honour, in seconds. The gateway
|
|
20
|
-
# sends its
|
|
21
|
-
#
|
|
20
|
+
# sends the time left in its throttle window as Retry-After; this bounds any
|
|
21
|
+
# hostile or misconfigured value from in between.
|
|
22
22
|
def initialize(base_url:, headers: {}, token: nil, timeout: 30, logger: nil,
|
|
23
23
|
max_retries: 0, retry_delay: 0.2, retry_on_429: false,
|
|
24
24
|
retry_after_cap: 60)
|
|
@@ -4,12 +4,14 @@ module Rail0
|
|
|
4
4
|
module Resources
|
|
5
5
|
# The merchant account itself (requires JWT).
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
7
|
+
# Read and update. The gateway guards `/accounts/:account_id` with an
|
|
8
|
+
# owner-or-operator check: a merchant session reaches only its OWN account, and an
|
|
9
|
+
# operator (an admin session) may reach any. For anyone else another account's id
|
|
10
|
+
# answers the same 403 `not_your_account` as a plain ownership miss, so the pair
|
|
11
|
+
# cannot be used to learn whether an account exists.
|
|
12
|
+
#
|
|
13
|
+
# The gateway's PATCH also takes `active`, but that field is the OPERATOR's (an owner
|
|
14
|
+
# sending it gets 403), so it is deliberately not exposed here.
|
|
13
15
|
#
|
|
14
16
|
# The account's wallets are a collection under the same path and live on
|
|
15
17
|
# {Resources::Wallets}; buyer-facing discovery of what a merchant accepts lives on
|
|
@@ -25,11 +27,36 @@ module Rail0
|
|
|
25
27
|
# The account's own profile.
|
|
26
28
|
# @param account_id [String] The account UUID — must be the one this JWT belongs to.
|
|
27
29
|
# @return [Hash] `id`, `name`, `email`, `created_at`, `updated_at`. `email` is part of
|
|
28
|
-
# the response because the
|
|
29
|
-
# merchant reading its own contact address, never another's.
|
|
30
|
+
# the response because only the account's owner, or an operator, can read it.
|
|
30
31
|
def get(account_id)
|
|
31
32
|
http.get("/accounts/#{account_id}")
|
|
32
33
|
end
|
|
34
|
+
|
|
35
|
+
# Update the account's own profile (PATCH /accounts/:account_id).
|
|
36
|
+
#
|
|
37
|
+
# Only the fields passed are sent, and at least one is required: a PATCH with
|
|
38
|
+
# nothing to change is a caller bug, which the gateway answers with 400 — so this
|
|
39
|
+
# raises ArgumentError first rather than spending a request on it. Both fields are
|
|
40
|
+
# UNIQUE across accounts, so a value another account already holds answers 409.
|
|
41
|
+
#
|
|
42
|
+
# This is a write on the account itself, so a deactivated wallet (403
|
|
43
|
+
# wallet_deactivated) or a deactivated account (403 account_deactivated) cannot
|
|
44
|
+
# make it: a revoked key must not be able to redirect the contact address where
|
|
45
|
+
# the merchant's operational notifications go.
|
|
46
|
+
#
|
|
47
|
+
# @param account_id [String] The account UUID — must be the one this JWT belongs to.
|
|
48
|
+
# @param name [String, nil] New display name (non-blank; unique across accounts).
|
|
49
|
+
# @param email [String, nil] New contact email (non-blank; unique across accounts).
|
|
50
|
+
# @return [Hash] the updated profile, same shape as {get}.
|
|
51
|
+
# @raise [ArgumentError] when neither name nor email is given.
|
|
52
|
+
def update(account_id, name: nil, email: nil)
|
|
53
|
+
body = {}
|
|
54
|
+
body[:name] = name unless name.nil?
|
|
55
|
+
body[:email] = email unless email.nil?
|
|
56
|
+
raise ArgumentError, "at least one of name or email is required" if body.empty?
|
|
57
|
+
|
|
58
|
+
http.patch("/accounts/#{account_id}", body)
|
|
59
|
+
end
|
|
33
60
|
end
|
|
34
61
|
end
|
|
35
62
|
end
|
|
@@ -16,13 +16,15 @@ module Rail0
|
|
|
16
16
|
# chain: it is denominated in the chain's native token, so it is reported per chain and
|
|
17
17
|
# never totalled.
|
|
18
18
|
#
|
|
19
|
+
# Figures cover every wallet of the account unless `payee` pins one of them.
|
|
20
|
+
#
|
|
19
21
|
# These methods return the parsed JSON as-is, so the keys documented below are the
|
|
20
22
|
# contract this SDK offers — there is no typed wrapper standing between them and the
|
|
21
23
|
# gateway.
|
|
22
24
|
class Analytics
|
|
23
25
|
include Query
|
|
24
26
|
|
|
25
|
-
FILTERS = %i[mode status token chain_id from to].freeze
|
|
27
|
+
FILTERS = %i[mode status token chain_id from to payee].freeze
|
|
26
28
|
|
|
27
29
|
attr_reader :http
|
|
28
30
|
|
|
@@ -38,6 +40,10 @@ module Rail0
|
|
|
38
40
|
# @param chain_id [Integer, nil] Chain ID. Pass nil or 0 for all chains.
|
|
39
41
|
# @param from [String, nil] ISO-8601 — payments created at/after this time.
|
|
40
42
|
# @param to [String, nil] ISO-8601 — payments created at/before this time.
|
|
43
|
+
# @param payee [String, nil] One of the account's wallet addresses (0x…) — scopes the
|
|
44
|
+
# figures to that wallet instead of all the account's wallets. The gateway refuses an
|
|
45
|
+
# address the account does not own (403) and a malformed one (400). Accepted by all
|
|
46
|
+
# three views.
|
|
41
47
|
# @return [Hash] the headline KPIs:
|
|
42
48
|
# * `orders`, `disputed` — counts.
|
|
43
49
|
# * `refund_rate`, `dispute_rate` — fractions in [0,1], per ORDER.
|
|
@@ -95,7 +101,7 @@ module Rail0
|
|
|
95
101
|
# often the operation ran — a partial capture runs several times on one order — and
|
|
96
102
|
# `orders` is how many orders it touched.
|
|
97
103
|
def breakdown(by:, **filters)
|
|
98
|
-
raise ArgumentError, "by is required (token, chain, mode or
|
|
104
|
+
raise ArgumentError, "by is required (token, chain, mode, status or operation)" if by.nil? || by.to_s.empty?
|
|
99
105
|
|
|
100
106
|
http.get("/analytics/breakdown#{build_query(**only_filters(filters), by: by)}")
|
|
101
107
|
end
|
data/lib/rail0/resources/auth.rb
CHANGED
|
@@ -31,6 +31,13 @@ module Rail0
|
|
|
31
31
|
# account. Never collapse the two into a single constant.
|
|
32
32
|
WALLET_LINK_STATEMENT = "Add this wallet to your RAIL0 account"
|
|
33
33
|
|
|
34
|
+
# The SIWE statement for revoking every session of an address —
|
|
35
|
+
# POST /auth/revoke_all. The gateway asserts it exactly
|
|
36
|
+
# (Policy::SIWE_REVOKE_ALL_STATEMENT) for the reason the other two are
|
|
37
|
+
# separate: a proof is spendable at exactly one endpoint, so a captured login
|
|
38
|
+
# signature cannot be replayed to sign its address out everywhere.
|
|
39
|
+
REVOKE_ALL_STATEMENT = "Sign out of RAIL0 everywhere"
|
|
40
|
+
|
|
34
41
|
attr_reader :http
|
|
35
42
|
|
|
36
43
|
def initialize(http)
|
|
@@ -47,9 +54,15 @@ module Rail0
|
|
|
47
54
|
# Submit a pre-built SIWE message and its signature, returning a JWT.
|
|
48
55
|
# @param message [String] EIP-4361 formatted message string.
|
|
49
56
|
# @param signature [String] 0x-prefixed hex signature.
|
|
50
|
-
# @return [Hash] { token:, address:, account_id:, name:, expires_at: }
|
|
57
|
+
# @return [Hash] { token:, address:, account_id:, name:, expires_at:, admin: }
|
|
58
|
+
#
|
|
59
|
+
# +admin+ is true only for an account holding the operator (administrators)
|
|
60
|
+
# grant: the gateway adds the field on an admin's session alone, and it is
|
|
61
|
+
# normalised here to a boolean so a standard login reads false rather than nil.
|
|
62
|
+
# Visibility only — every gated route re-checks the grant per request.
|
|
51
63
|
def verify(message:, signature:)
|
|
52
|
-
http.post("/auth", { message: message, signature: signature })
|
|
64
|
+
session = http.post("/auth", { message: message, signature: signature })
|
|
65
|
+
session.merge(admin: session[:admin] == true)
|
|
53
66
|
end
|
|
54
67
|
|
|
55
68
|
# End the session whose token this client carries.
|
|
@@ -70,23 +83,43 @@ module Rail0
|
|
|
70
83
|
http.post("/auth/logout", {})
|
|
71
84
|
end
|
|
72
85
|
|
|
73
|
-
# End EVERY session of the
|
|
86
|
+
# End EVERY session of the address controlled by +private_key+
|
|
87
|
+
# (POST /auth/revoke_all).
|
|
74
88
|
#
|
|
75
89
|
# The answer to a key you no longer trust, and {#logout} cannot be that answer: it
|
|
76
90
|
# is per TOKEN, so an address with five live sessions needs five tokens the caller
|
|
77
91
|
# does not have. This is per ADDRESS and reaches the ones it never saw — including
|
|
78
92
|
# any an attacker is holding.
|
|
79
93
|
#
|
|
94
|
+
# Authorized by a FRESH SIWE PROOF of the address, not by the session: someone
|
|
95
|
+
# reacting to a leaked key holds the wallet, not the stolen token, so requiring the
|
|
96
|
+
# session would make this useless exactly when it is needed. The proof is the same
|
|
97
|
+
# handshake as {#login} — fetch a single-use nonce, build an EIP-4361 message, sign
|
|
98
|
+
# it with EIP-191 personal_sign — but carries {REVOKE_ALL_STATEMENT}, so a login
|
|
99
|
+
# proof cannot be replayed here (the gateway answers 422 siwe_purpose_mismatch).
|
|
100
|
+
# This method used to post an empty body, which the gateway refused on every call,
|
|
101
|
+
# and read `revoked`/`cutoff`, which the gateway never sends.
|
|
102
|
+
#
|
|
80
103
|
# The gateway records a cutoff INSTANT rather than enumerating tokens, so a session
|
|
81
|
-
# minted a moment before the call is refused by its own `iat`.
|
|
82
|
-
#
|
|
104
|
+
# minted a moment before the call is refused by its own `iat`. It is self-revoking:
|
|
105
|
+
# the cutoff is just after now, so any token this caller holds dies with the rest —
|
|
106
|
+
# sign in again afterwards. (The client does not store the JWT, so there is nothing
|
|
107
|
+
# local to clear; drop your own copy.)
|
|
83
108
|
#
|
|
84
|
-
# `
|
|
85
|
-
# `
|
|
109
|
+
# `cutoff_at` is the field worth logging. It says exactly which sessions died, which
|
|
110
|
+
# `revoked_all: true` cannot.
|
|
86
111
|
#
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
112
|
+
# Requires the optional 'eth' and 'siwe-rb' gems.
|
|
113
|
+
#
|
|
114
|
+
# @param private_key [String] 0x-prefixed hex private key of the address to sign out.
|
|
115
|
+
# @param domain [String] Host of the API server (e.g. "api.rail0.xyz").
|
|
116
|
+
# @param chain_id [Integer] Chain ID to embed; same meaning and default as #login.
|
|
117
|
+
# @return [Hash] { revoked_all: true, cutoff_at: "2026-09-24T18:00:01Z" } — cutoff_at
|
|
118
|
+
# is an ISO-8601 instant; every token of the address issued before it is refused.
|
|
119
|
+
def revoke_all(private_key:, domain:, chain_id: 1)
|
|
120
|
+
message, signature = sign_proof(private_key, domain, chain_id, REVOKE_ALL_STATEMENT)
|
|
121
|
+
result = http.post("/auth/revoke_all", { message: message, signature: signature })
|
|
122
|
+
{ revoked_all: result[:revoked_all], cutoff_at: result[:cutoff_at] }
|
|
90
123
|
end
|
|
91
124
|
|
|
92
125
|
# Perform the full SIWE authentication flow:
|
|
@@ -102,7 +135,7 @@ module Rail0
|
|
|
102
135
|
# @param chain_id [Integer] Chain ID to embed in the SIWE message. Must match
|
|
103
136
|
# the gateway's SIWE_CHAIN_ID policy (default 1); override only when the
|
|
104
137
|
# gateway is configured with a different login chain.
|
|
105
|
-
# @return [Hash] { token:, address:, account_id:, name:, expires_at: }
|
|
138
|
+
# @return [Hash] { token:, address:, account_id:, name:, expires_at:, admin: }
|
|
106
139
|
def login(private_key:, domain:, chain_id: 1)
|
|
107
140
|
message, signature = sign_proof(private_key, domain, chain_id, LOGIN_STATEMENT)
|
|
108
141
|
verify(message: message, signature: signature)
|
|
@@ -135,9 +168,9 @@ module Rail0
|
|
|
135
168
|
|
|
136
169
|
private
|
|
137
170
|
|
|
138
|
-
# The shared core of
|
|
139
|
-
# identical, which is precisely why the
|
|
140
|
-
# default — see the note on the
|
|
171
|
+
# The shared core of every SIWE handshake (login, prove_address, revoke_all).
|
|
172
|
+
# Everything but the STATEMENT is identical, which is precisely why the
|
|
173
|
+
# statement is a parameter and never a default — see the note on the constants.
|
|
141
174
|
def sign_proof(private_key, domain, chain_id, statement)
|
|
142
175
|
ensure_signing_deps!
|
|
143
176
|
|
|
@@ -20,7 +20,9 @@ module Rail0
|
|
|
20
20
|
# @param symbol [String, nil] Filter by native symbol (case-insensitive, e.g. "ETH").
|
|
21
21
|
# @return [Array<Hash>] chain_id, name, native_symbol, network_type, explorer_url,
|
|
22
22
|
# required_confirmations, finality_tag (the settlement rule: the tag where the
|
|
23
|
-
# chain serves one, the count only where it does not)
|
|
23
|
+
# chain serves one, the count only where it does not), and contract — the chain's
|
|
24
|
+
# active RAIL0 deployment as `{ address:, version:, deployed_at: }` (what holds
|
|
25
|
+
# the money; nullable in principle, present in practice)
|
|
24
26
|
def list(network_type: nil, symbol: nil)
|
|
25
27
|
http.get("/blockchains#{build_query(network_type: network_type, symbol: symbol)}")
|
|
26
28
|
end
|
|
@@ -23,7 +23,7 @@ module Rail0
|
|
|
23
23
|
# List the account's disputes.
|
|
24
24
|
# @param status [String, nil] Filter by "open" or "closed".
|
|
25
25
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
26
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
26
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
27
27
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
28
28
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
29
29
|
def list(status: nil, sort: nil, page: nil, per_page: nil)
|
|
@@ -18,6 +18,11 @@ module Rail0
|
|
|
18
18
|
# Operations accepted by the prepare/submit endpoints.
|
|
19
19
|
OPERATIONS = %w[authorize capture charge void release refund].freeze
|
|
20
20
|
|
|
21
|
+
# Every operation a transaction row can carry — a superset of {OPERATIONS}, since
|
|
22
|
+
# dispute/close_dispute have routes of their own. The values the `operation`
|
|
23
|
+
# filters on {list} and {transactions} accept.
|
|
24
|
+
TRANSACTION_OPERATIONS = (OPERATIONS + %w[dispute close_dispute]).freeze
|
|
25
|
+
|
|
21
26
|
attr_reader :http
|
|
22
27
|
|
|
23
28
|
def initialize(http)
|
|
@@ -35,6 +40,8 @@ module Rail0
|
|
|
35
40
|
# @param rail0_id [String, nil] Filter by the logical on-chain payment id (0x…).
|
|
36
41
|
# @param chain_id [Integer, nil] Filter by the payment's chain.
|
|
37
42
|
# @param disputed [Boolean, nil] Filter by whether an open dispute exists.
|
|
43
|
+
# @param operation [String, nil] Only payments carrying at least one transaction with
|
|
44
|
+
# this operation (see {TRANSACTION_OPERATIONS}).
|
|
38
45
|
# @param min_amount [String, nil] Minimum amount in token BASE UNITS (inclusive).
|
|
39
46
|
# @param max_amount [String, nil] Maximum amount in token BASE UNITS (inclusive).
|
|
40
47
|
# These two really are base units — the filter runs against the stored column.
|
|
@@ -43,14 +50,15 @@ module Rail0
|
|
|
43
50
|
# @param created_from [String, nil] Only payments created at/after this ISO-8601 time.
|
|
44
51
|
# @param created_to [String, nil] Only payments created at/before this ISO-8601 time.
|
|
45
52
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
46
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
53
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
47
54
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
48
55
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
49
56
|
def list(status: nil, mode: nil, payer: nil, payee: nil, token: nil, rail0_id: nil,
|
|
50
|
-
chain_id: nil, disputed: nil, min_amount: nil, max_amount: nil,
|
|
57
|
+
chain_id: nil, disputed: nil, operation: nil, min_amount: nil, max_amount: nil,
|
|
51
58
|
created_from: nil, created_to: nil, sort: nil, page: nil, per_page: nil)
|
|
52
59
|
query = build_query(status: status, mode: mode, payer: payer, payee: payee, token: token,
|
|
53
60
|
rail0_id: rail0_id, chain_id: chain_id, disputed: disputed,
|
|
61
|
+
operation: operation,
|
|
54
62
|
min_amount: min_amount, max_amount: max_amount,
|
|
55
63
|
created_from: created_from, created_to: created_to,
|
|
56
64
|
sort: sort, page: page, per_page: per_page)
|
|
@@ -87,10 +95,11 @@ module Rail0
|
|
|
87
95
|
|
|
88
96
|
# List on-chain transactions for a payment.
|
|
89
97
|
# @param id [String] Payment UUID or rail0_id.
|
|
90
|
-
# @param operation [String, nil] Filter by operation (see {
|
|
98
|
+
# @param operation [String, nil] Filter by operation (see {TRANSACTION_OPERATIONS} —
|
|
99
|
+
# dispute and close_dispute included).
|
|
91
100
|
# @param status [String, nil] Filter by transaction status.
|
|
92
101
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
93
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
102
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
94
103
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
95
104
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
96
105
|
def transactions(id, operation: nil, status: nil, sort: nil, page: nil, per_page: nil)
|
|
@@ -149,7 +158,7 @@ module Rail0
|
|
|
149
158
|
# @param id [String] Payment UUID or rail0_id.
|
|
150
159
|
# @param status [String, nil] Filter by dispute status ("open" or "closed").
|
|
151
160
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
152
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
161
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
153
162
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
154
163
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
155
164
|
def disputes(id, status: nil, sort: nil, page: nil, per_page: nil)
|
|
@@ -239,9 +248,10 @@ module Rail0
|
|
|
239
248
|
submit(id, "void", params)
|
|
240
249
|
end
|
|
241
250
|
|
|
242
|
-
# Phase 1 — build the unsigned release() transaction
|
|
243
|
-
#
|
|
244
|
-
#
|
|
251
|
+
# Phase 1 — build the unsigned release() transaction for +from+, whose nonce it
|
|
252
|
+
# carries. +from+ must be the payment's payer or payee (the gateway answers 422
|
|
253
|
+
# release_submitter_not_a_party otherwise); omitted, it is the signed-in caller.
|
|
254
|
+
# Returns uncaptured escrow to the payer.
|
|
245
255
|
def release_prepare(id, from: nil, idempotency_key: nil)
|
|
246
256
|
prepare(id, "release", from ? { from: from } : {}, idempotency_key: idempotency_key)
|
|
247
257
|
end
|
|
@@ -15,13 +15,20 @@ module Rail0
|
|
|
15
15
|
freeze
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# List
|
|
18
|
+
# List tokens, optionally filtered by chain, symbol and/or active flag.
|
|
19
|
+
#
|
|
20
|
+
# Without `active:` this is the HISTORICAL catalogue — retired tokens included — so
|
|
21
|
+
# a payment created long ago still resolves its token. Only an active token may be
|
|
22
|
+
# used for a NEW payment (POST /payments answers 422 unknown_token otherwise), so a
|
|
23
|
+
# checkout picker should pass `active: true`.
|
|
24
|
+
#
|
|
19
25
|
# @param chain_id [Integer, nil] Chain ID to filter by. Pass nil or 0 for all chains.
|
|
20
26
|
# @param symbol [String, nil] Filter by token symbol (case-insensitive, e.g. "USDC").
|
|
21
|
-
# @
|
|
22
|
-
|
|
27
|
+
# @param active [Boolean, nil] Filter by active flag; omitted returns every token.
|
|
28
|
+
# @return [Array<Hash>] chain_id, symbol, address, decimals, active
|
|
29
|
+
def list(chain_id: nil, symbol: nil, active: nil)
|
|
23
30
|
chain_id = nil if chain_id == 0
|
|
24
|
-
http.get("/tokens#{build_query(chain_id: chain_id, symbol: symbol)}")
|
|
31
|
+
http.get("/tokens#{build_query(chain_id: chain_id, symbol: symbol, active: active)}")
|
|
25
32
|
end
|
|
26
33
|
end
|
|
27
34
|
end
|
|
@@ -27,14 +27,17 @@ module Rail0
|
|
|
27
27
|
# @param token_symbol [String, nil] Restrict nested tokens to this symbol (e.g. "USDC").
|
|
28
28
|
# @param active [Boolean, nil] Filter wallets by active flag.
|
|
29
29
|
# @param default [Boolean, nil] Restrict nested holdings to the default one.
|
|
30
|
+
# @param token_active [Boolean, nil] Restrict nested holdings to this active status
|
|
31
|
+
# (does not hide wallets). Omitted returns holdings whatever their flag.
|
|
30
32
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
31
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
33
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
32
34
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
33
35
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
34
36
|
def list(account_id, chain_id: nil, token_symbol: nil, active: nil, default: nil,
|
|
35
|
-
sort: nil, page: nil, per_page: nil)
|
|
37
|
+
token_active: nil, sort: nil, page: nil, per_page: nil)
|
|
36
38
|
query = build_query(chain_id: chain_id, token_symbol: token_symbol, active: active,
|
|
37
|
-
default: default,
|
|
39
|
+
default: default, token_active: token_active,
|
|
40
|
+
sort: sort, page: page, per_page: per_page)
|
|
38
41
|
http.get_list("/accounts/#{account_id}/wallets#{query}")
|
|
39
42
|
end
|
|
40
43
|
|
|
@@ -135,8 +138,15 @@ module Rail0
|
|
|
135
138
|
|
|
136
139
|
# Stop accepting a token on this wallet (soft delete). Returns HTTP 204.
|
|
137
140
|
#
|
|
138
|
-
# `token_id` is the
|
|
139
|
-
# nested `tokens
|
|
141
|
+
# `token_id` is the TOKEN's UUID — the `token_id` field on a holding returned
|
|
142
|
+
# by {add_token} / {list}'s nested `tokens` — not an id of the holding row and
|
|
143
|
+
# not the token's contract address. The gateway looks the holding up by
|
|
144
|
+
# (wallet, token_id); the same applies to {enable_token} and {disable_token}.
|
|
145
|
+
#
|
|
146
|
+
# The wallet's active DEFAULT holding cannot be removed: that would leave it with no
|
|
147
|
+
# preferred payment method and nothing saying so, and the gateway answers 422
|
|
148
|
+
# default_payment_method. Make another token the default first ({add_token} with
|
|
149
|
+
# `default: true` demotes this one in the same transaction), then remove it.
|
|
140
150
|
#
|
|
141
151
|
# @return [nil]
|
|
142
152
|
def remove_token(account_id, id_or_address, token_id)
|
|
@@ -151,6 +161,9 @@ module Rail0
|
|
|
151
161
|
|
|
152
162
|
# Disable an existing token holding without forgetting it — the holding (and
|
|
153
163
|
# its default flag) survives, so re-enabling restores the previous setup.
|
|
164
|
+
#
|
|
165
|
+
# Like {remove_token}, refused with 422 default_payment_method for the wallet's
|
|
166
|
+
# active default holding — promote another token to default first.
|
|
154
167
|
# @return [Hash] the token holding
|
|
155
168
|
def disable_token(account_id, id_or_address, token_id)
|
|
156
169
|
http.patch("/accounts/#{account_id}/wallets/#{id_or_address}/tokens/#{token_id}/disable")
|
|
@@ -45,7 +45,7 @@ module Rail0
|
|
|
45
45
|
# @param active [Boolean, nil] Filter by active flag.
|
|
46
46
|
# @param circuit_state [String, nil] Filter by circuit state ("closed" or "open").
|
|
47
47
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
48
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
48
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
49
49
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
50
50
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
51
51
|
def list(topic: nil, active: nil, circuit_state: nil, sort: nil, page: nil, per_page: nil)
|
|
@@ -119,22 +119,50 @@ module Rail0
|
|
|
119
119
|
|
|
120
120
|
# List delivery attempts for a webhook.
|
|
121
121
|
# @param id [String] Webhook UUID.
|
|
122
|
-
# @param status [String, nil] Filter by delivery status
|
|
122
|
+
# @param status [String, nil] Filter by delivery status: "delivered" or "failed" only
|
|
123
|
+
# (anything else, "pending" included, is a 400).
|
|
123
124
|
# @param topic [String, nil] Filter by event topic.
|
|
124
|
-
# @param payment_id [String, nil] Filter by the payment the delivery is for
|
|
125
|
+
# @param payment_id [String, nil] Filter by the payment the delivery is for: its UUID
|
|
126
|
+
# or its rail0_id (0x…). One that names no payment matches nothing rather than erroring.
|
|
127
|
+
# @param response_code [String, Integer, nil] Filter by the subscriber's exact HTTP
|
|
128
|
+
# response code (e.g. 500) — `status: "failed"` finds the failures, this says which.
|
|
125
129
|
# @param since [String, nil] Only deliveries at/after this ISO-8601 time.
|
|
126
130
|
# @param until_time [String, nil] Only deliveries at/before this ISO-8601 time (query key: "until").
|
|
127
131
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
128
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
132
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
129
133
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
130
134
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
131
|
-
def event_callbacks(id, status: nil, topic: nil, payment_id: nil,
|
|
132
|
-
until_time: nil, sort: nil, page: nil, per_page: nil)
|
|
133
|
-
query = build_query(status: status, topic: topic, payment_id: payment_id,
|
|
134
|
-
|
|
135
|
+
def event_callbacks(id, status: nil, topic: nil, payment_id: nil, response_code: nil,
|
|
136
|
+
since: nil, until_time: nil, sort: nil, page: nil, per_page: nil)
|
|
137
|
+
query = build_query(status: status, topic: topic, payment_id: payment_id,
|
|
138
|
+
response_code: response_code, since: since, until: until_time,
|
|
139
|
+
sort: sort, page: page, per_page: per_page)
|
|
135
140
|
http.get_list("/webhooks/#{id}/event_callbacks#{query}")
|
|
136
141
|
end
|
|
137
142
|
|
|
143
|
+
# Re-deliver one recorded delivery's exact payload
|
|
144
|
+
# (POST /webhooks/:id/event_callbacks/:callback_id/redeliver).
|
|
145
|
+
#
|
|
146
|
+
# The recovery lever for events lost while the circuit breaker was open: the
|
|
147
|
+
# gateway stores each delivery's payload and replays THAT payload verbatim — the
|
|
148
|
+
# same embedded event id, so a receiver that already processed it deduplicates —
|
|
149
|
+
# under a fresh timestamped signature.
|
|
150
|
+
#
|
|
151
|
+
# Delivery is ASYNC (the standard dispatcher: same SSRF checks, retries and circuit
|
|
152
|
+
# accounting), and the dispatcher drops a delivery for a webhook that is not active.
|
|
153
|
+
# So re-activate the webhook FIRST — {enable} if it was disabled, {reset_circuit} if
|
|
154
|
+
# the breaker opened — or the 202 below is answered and the replay silently dropped.
|
|
155
|
+
#
|
|
156
|
+
# A callback id that is malformed, unknown, belongs to another webhook, or was
|
|
157
|
+
# recorded before payloads were stored all answer 404.
|
|
158
|
+
#
|
|
159
|
+
# @param id [String] Webhook UUID.
|
|
160
|
+
# @param callback_id [String] The event callback's UUID (from {event_callbacks}).
|
|
161
|
+
# @return [Hash] `{ status: "queued" }` (HTTP 202).
|
|
162
|
+
def redeliver(id, callback_id)
|
|
163
|
+
http.post("/webhooks/#{id}/event_callbacks/#{callback_id}/redeliver", {})
|
|
164
|
+
end
|
|
165
|
+
|
|
138
166
|
# Delete a webhook. Returns HTTP 204.
|
|
139
167
|
# @param id [String] Webhook UUID.
|
|
140
168
|
# @return [nil]
|
data/lib/rail0/stablecoins.rb
CHANGED
|
@@ -78,13 +78,50 @@ module Rail0
|
|
|
78
78
|
"cUSD" => StablecoinInfo.new(address: "0x765DE816845861e75A25fCA122bb6898B8B1282a", decimals: 18, eip3009: true, eip2612: false),
|
|
79
79
|
"cEUR" => StablecoinInfo.new(address: "0xD8763CBa276a3738E6DE85b4b3bF5FDed6D6cA73", decimals: 18, eip3009: true, eip2612: false)
|
|
80
80
|
}
|
|
81
|
+
),
|
|
82
|
+
# ── Testnets ───────────────────────────────────────────────────────────
|
|
83
|
+
# Every address below was read off its own chain, and the token lists are
|
|
84
|
+
# the immutable acceptedTokens() allowlists of the RAIL0 deployments there
|
|
85
|
+
# — which is what a payment on that chain can actually use.
|
|
86
|
+
"arc-testnet" => ChainStablecoins.new(
|
|
87
|
+
chain_id: 5042002,
|
|
88
|
+
tokens: {
|
|
89
|
+
"USDC" => StablecoinInfo.new(address: "0x3600000000000000000000000000000000000000", decimals: 6, eip3009: true, eip2612: true),
|
|
90
|
+
"EURC" => StablecoinInfo.new(address: "0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a", decimals: 6, eip3009: true, eip2612: true)
|
|
91
|
+
}
|
|
92
|
+
),
|
|
93
|
+
# Celo Sepolia is 11142220. The rail0-go registry carried 44787 here, which is
|
|
94
|
+
# ALFAJORES — a different, now-retired network — alongside a USDC address with
|
|
95
|
+
# no code on this chain. Corrected there in the same change; do not "restore" it.
|
|
96
|
+
"celo-sepolia" => ChainStablecoins.new(
|
|
97
|
+
chain_id: 11142220,
|
|
98
|
+
tokens: {
|
|
99
|
+
"USDC" => StablecoinInfo.new(address: "0x01C5C0122039549AD1493B8220cABEdD739BC44E", decimals: 6, eip3009: true, eip2612: true),
|
|
100
|
+
"USD₮" => StablecoinInfo.new(address: "0xd077A400968890Eacc75cdc901F0356c943e4fDb", decimals: 6, eip3009: true, eip2612: true)
|
|
101
|
+
}
|
|
102
|
+
),
|
|
103
|
+
# PYUSD is the first non-Circle token served, and the reason this chain is
|
|
104
|
+
# served at all — it is issued nowhere else.
|
|
105
|
+
#
|
|
106
|
+
# Signing against PYUSD directly: its EIP-712 domain name is "PayPal USD", not
|
|
107
|
+
# the symbol, and its version is "1", which version() does not expose (it
|
|
108
|
+
# reverts). Through a RAIL0 payment this never bites — the domain comes down in
|
|
109
|
+
# the gateway's signing_payload — but building the domain by hand from the
|
|
110
|
+
# symbol yields a signature the token rejects.
|
|
111
|
+
"ethereum-sepolia" => ChainStablecoins.new(
|
|
112
|
+
chain_id: 11155111,
|
|
113
|
+
tokens: {
|
|
114
|
+
"USDC" => StablecoinInfo.new(address: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", decimals: 6, eip3009: true, eip2612: true),
|
|
115
|
+
"PYUSD" => StablecoinInfo.new(address: "0xCaC524BcA292aaade2DF8A05cC58F0a65B1B3bB9", decimals: 6, eip3009: true, eip2612: true)
|
|
116
|
+
}
|
|
81
117
|
)
|
|
82
118
|
}.freeze
|
|
83
119
|
|
|
84
120
|
# Returns the registry entry for a chain, or +nil+ if the chain is unknown.
|
|
85
121
|
#
|
|
86
122
|
# Supported chain names: "ethereum", "base", "polygon", "arbitrumOne",
|
|
87
|
-
# "optimism", "avalanche", "celo"
|
|
123
|
+
# "optimism", "avalanche", "celo", "arc-testnet", "celo-sepolia",
|
|
124
|
+
# "ethereum-sepolia".
|
|
88
125
|
#
|
|
89
126
|
# @param chain [String]
|
|
90
127
|
# @return [ChainStablecoins, nil]
|
data/lib/rail0/types.rb
CHANGED
|
@@ -46,36 +46,43 @@ module Rail0
|
|
|
46
46
|
keyword_init: true
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
# reads the same way wherever it surfaces. `status` and `message` are the pre-code/title/detail
|
|
53
|
-
# names, derived from the same entry and kept for existing clients: `status` carries the family a
|
|
54
|
-
# client used to switch on (e.g. `forbidden`, `invalid_state`) where `code` is narrower, and
|
|
55
|
-
# `message` equals `detail`. Context fields may also be present (`resource`, `param`, `chain_id`,
|
|
56
|
-
# `token`, `payee`, `errors`).
|
|
49
|
+
# The error envelope: exactly code (stable, the only field to branch on), title (short label) and
|
|
50
|
+
# detail (a sentence a UI can print verbatim). The legacy status/message/error aliases were removed
|
|
51
|
+
# in #252.
|
|
57
52
|
ApiErrorBody = Struct.new(
|
|
58
|
-
:code,
|
|
59
|
-
:title,
|
|
60
|
-
:detail,
|
|
61
|
-
:status, # String — Legacy: the error family, or the code itself when there is no wider family.
|
|
62
|
-
:message, # String — Legacy alias of `detail`.
|
|
53
|
+
:code, # String
|
|
54
|
+
:title, # String
|
|
55
|
+
:detail, # String
|
|
63
56
|
keyword_init: true
|
|
64
57
|
)
|
|
65
58
|
|
|
66
|
-
# Gateway liveness/readiness
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
59
|
+
# Gateway liveness/readiness — public and token-blind: one body for every caller, and every field is
|
|
60
|
+
# public by nature (the versions are on-chain / on the root descriptor; the counts are derivable
|
|
61
|
+
# from the public catalog reads). Only the database gates the HTTP code (200 healthy, 503 when the
|
|
62
|
+
# DB is unreachable); the Sidekiq worker fleet never flips the code, since the API still serves
|
|
63
|
+
# synchronous requests when workers are down. `status` is the global signal: `ok` (all good),
|
|
64
|
+
# `degraded` (DB ok but the worker fleet not ok), `error` (DB down — the only 503). The fleet
|
|
65
|
+
# FIGURES (queue depth, worker count, Redis reachability) are operational internals and live on the
|
|
66
|
+
# operator-only GET /admin/health.
|
|
70
67
|
Health = Struct.new(
|
|
71
68
|
:status, # String
|
|
69
|
+
:db, # String
|
|
70
|
+
:timestamp, # String
|
|
72
71
|
:api_version, # String
|
|
73
72
|
:contract_version, # String
|
|
74
|
-
:db, # String
|
|
75
|
-
:sidekiq, # Hash — Worker fleet health (does not gate liveness).
|
|
76
73
|
:active_chains, # Integer
|
|
77
74
|
:active_contracts, # Integer
|
|
78
|
-
:
|
|
75
|
+
keyword_init: true
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Operator diagnostics for GET /admin/health: one verdict plus a named check per standing failure
|
|
79
|
+
# condition, always 200 (a degraded system is the content, not an error — this is not a liveness
|
|
80
|
+
# probe). Every figure is computed on request from state the gateway already keeps; nothing here is
|
|
81
|
+
# a counter anyone increments.
|
|
82
|
+
AdminHealth = Struct.new(
|
|
83
|
+
:status, # String — The worst of the checks. Exists so "is anything wrong" can be read without knowing which checks there are, nor which will be added later.
|
|
84
|
+
:checks, # Hash
|
|
85
|
+
:timestamp, # String
|
|
79
86
|
keyword_init: true
|
|
80
87
|
)
|
|
81
88
|
|
|
@@ -89,7 +96,9 @@ module Rail0
|
|
|
89
96
|
# Issued after a successful SIWE verification. SIWE alone proves control of the address, so a token
|
|
90
97
|
# is issued even when the address is not registered to any account; in that case `account_id` and
|
|
91
98
|
# `name` are null (an account-less session, e.g. a buyer). Clients that require an account must
|
|
92
|
-
# treat a null `account_id` as not-allowed.
|
|
99
|
+
# treat a null `account_id` as not-allowed. A session whose account holds the operator grant
|
|
100
|
+
# additionally carries `admin: true` (the Session::Admin variant); a standard or account-less
|
|
101
|
+
# session has no admin/role field at all.
|
|
93
102
|
Session = Struct.new(
|
|
94
103
|
:token, # String — JWT bearer token.
|
|
95
104
|
:address, # String — Resolved wallet address.
|
|
@@ -99,20 +108,31 @@ module Rail0
|
|
|
99
108
|
keyword_init: true
|
|
100
109
|
)
|
|
101
110
|
|
|
111
|
+
# The RAIL0 deployment a NEW payment on this chain is opened against — what escrows the payer's
|
|
112
|
+
# funds, and which version of it. Nothing here is a disclosure: the address is on-chain, every
|
|
113
|
+
# payment response already carries it as `rail0_contract`, and the explorer shows its verified
|
|
114
|
+
# source. It is published so a client does not carry its own address-per-chain table and go quietly
|
|
115
|
+
# stale on the next rollout. Null only for a chain with no deployment, which cannot appear in this
|
|
116
|
+
# listing anyway. The operator's bookkeeping (start_block, active/archived) stays on
|
|
117
|
+
# /admin/contracts: it describes how this gateway and its indexer are run, not what a payer is
|
|
118
|
+
# signing into.
|
|
119
|
+
ChainContract = Struct.new(
|
|
120
|
+
:address, # String
|
|
121
|
+
:version, # String — Semver of the deployed contract. Every active deployment agrees on the one the gateway targets.
|
|
122
|
+
:deployed_at, # String
|
|
123
|
+
keyword_init: true
|
|
124
|
+
)
|
|
125
|
+
|
|
102
126
|
# Public blockchain view.
|
|
103
|
-
#
|
|
104
|
-
# required_confirmations and finality_tag together say how long a payer waits. Read
|
|
105
|
-
# the PAIR, not the number: where a chain serves a finality tag the gateway gates on
|
|
106
|
-
# that tag, and the count is the fallback for chains serving none — so quoting the
|
|
107
|
-
# count on a tagged chain names a wait nobody applies.
|
|
108
127
|
Blockchain = Struct.new(
|
|
109
|
-
:chain_id,
|
|
110
|
-
:name,
|
|
111
|
-
:native_symbol,
|
|
112
|
-
:network_type,
|
|
113
|
-
:explorer_url,
|
|
114
|
-
:required_confirmations,
|
|
115
|
-
:finality_tag,
|
|
128
|
+
:chain_id, # Integer
|
|
129
|
+
:name, # String
|
|
130
|
+
:native_symbol, # String
|
|
131
|
+
:network_type, # String
|
|
132
|
+
:explorer_url, # String
|
|
133
|
+
:required_confirmations, # Integer — Confirmations this gateway waits for before treating a transaction as settled. The FALLBACK rule: where the chain serves a finality tag (see finality_tag) that tag governs instead, so a client showing this number on such a chain is describing a rule that is not in force.
|
|
134
|
+
:finality_tag, # String — The block tag the chain calls settled (`safe`, `finalized`), when it serves one — and what the gateway actually gates on. Null where the chain serves none, in which case required_confirmations is counted.
|
|
135
|
+
:contract, # ChainContract
|
|
116
136
|
keyword_init: true
|
|
117
137
|
)
|
|
118
138
|
|
|
@@ -150,9 +170,10 @@ module Rail0
|
|
|
150
170
|
# A wallet's token holding as nested under its wallet (GET /accounts/:id/wallets): the token plus
|
|
151
171
|
# this wallet's per-token flags, without re-nesting the wallet.
|
|
152
172
|
WalletTokenHolding = Struct.new(
|
|
153
|
-
:
|
|
154
|
-
:
|
|
155
|
-
:
|
|
173
|
+
:token_id, # String — The token's UUID — the handle for PATCH/DELETE /accounts/{account_id}/wallets/{id}/tokens/{token_id}.
|
|
174
|
+
:token, # Token
|
|
175
|
+
:active, # Boolean
|
|
176
|
+
:default, # Boolean
|
|
156
177
|
keyword_init: true
|
|
157
178
|
)
|
|
158
179
|
|
|
@@ -167,28 +188,25 @@ module Rail0
|
|
|
167
188
|
keyword_init: true
|
|
168
189
|
)
|
|
169
190
|
|
|
170
|
-
# Base persisted payment fields.
|
|
191
|
+
# Base persisted payment fields, plus the `chain_id` of the payment's deployment.
|
|
171
192
|
Payment = Struct.new(
|
|
172
193
|
:id, # String
|
|
173
194
|
:contract_id, # String
|
|
174
|
-
:chain_id, # Integer — EVM chain id of the deployment.
|
|
175
|
-
# base units, and the token's decimals resolve from `token` PLUS its chain.
|
|
195
|
+
:chain_id, # Integer — EVM chain id of the payment's deployment. Present on the list view too: `amount` is in base units, and the token's `decimals` can only be resolved from `token` together with its chain.
|
|
176
196
|
:rail0_id, # String — Protocol-level identifier (66-char hex).
|
|
177
|
-
:status, # String
|
|
197
|
+
:status, # String — Lifecycle state. Deliberately a HAPPY-PATH label, not a ledger: a partial operation does NOT move it, so a payment captured 100 and refunded 40 still reads `captured`, and a full refund that leaves uncaptured escrow does too. `capturable_amount` / `refundable_amount` are the authoritative residuals — reconcile on those, not on this. `partially_refunded` is retained for historical rows only and is no longer produced.
|
|
178
198
|
:mode, # String
|
|
179
199
|
:amount, # String
|
|
180
200
|
:capturable_amount, # String — Mirrors on-chain capturableAmount (escrow still held); base units.
|
|
181
201
|
:refundable_amount, # String — Mirrors on-chain refundableAmount (held by payee, still refundable); base units.
|
|
182
|
-
# The window after a PARTIAL capture where neither void nor release can return the
|
|
183
|
-
# buyer's remaining escrow — the answer to "why did both just refuse?".
|
|
184
|
-
:escrow_stranded, # Boolean
|
|
185
|
-
:escrow_returnable_at, # String, nil — ISO-8601 end of that window; nil outside it.
|
|
186
202
|
:config_hash, # String
|
|
187
203
|
:payer, # String
|
|
188
204
|
:payee, # String
|
|
189
205
|
:token, # String
|
|
190
206
|
:authorization_expiry, # Integer
|
|
191
207
|
:refund_expiry, # Integer
|
|
208
|
+
:escrow_stranded, # Boolean — True exactly inside the stranded-escrow window (#233): a partial capture has permanently ruled void out, and release only opens at authorization_expiry — so no verb can return the buyer's uncaptured escrow until then. Mirrors RAIL0.sol; the gateway names the window, it cannot shorten it.
|
|
209
|
+
:escrow_returnable_at, # String — When the stranded escrow becomes returnable (release opens) — the authorization expiry as ISO-8601. Null whenever nothing is stranded, so presence alone is the signal.
|
|
192
210
|
:disputed, # Boolean — True while an open dispute exists.
|
|
193
211
|
:last_error_code, # Decoded reason of the last failed on-chain attempt; null once the payment makes forward progress. Non-null means the latest attempt failed.
|
|
194
212
|
:last_error_message, # Human-readable form of last_error_code.
|
|
@@ -218,15 +236,16 @@ module Rail0
|
|
|
218
236
|
Transaction = Struct.new(
|
|
219
237
|
:id, # String
|
|
220
238
|
:payment_id, # String
|
|
221
|
-
:operation, # String
|
|
239
|
+
:operation, # String — Which on-chain call this row attempts. A SUPERSET of the `{operation}` path enum: `dispute` and `close_dispute` have their own hand-written routes rather than living under the generic namespace, but they mint transaction rows like any other operation, so a generated client must be able to represent them.
|
|
222
240
|
:status, # String
|
|
241
|
+
:redrivable, # Boolean — True when this transaction's broadcast can be re-enqueued: it is `pending` and the gateway holds its signed transaction, i.e. a send that was prepared and signed but never reached the chain. The same predicate `POST /payments/{id}/transactions/{transaction_id}/redrive` guards on, so a client can offer the action exactly when it will succeed instead of on a 422. False on a `pending` row that holds no signed transaction - there the next step is submitting the signature, not a redrive.
|
|
223
242
|
:error_code, # Decoded failure code, null unless `status` is "failed". Same catalogue as an error body's `code`: a RAIL0 custom error (`not_payee`), a token-level revert (`insufficient_token_balance`, `invalid_token_signature`, `authorization_already_used`), a Solidity panic, or a rejection that stopped the broadcast before the chain saw it (`insufficient_gas_funds`, `nonce_too_low`).
|
|
224
243
|
:error_title, # String — Short label for `error_code`; null unless failed.
|
|
225
244
|
:error_detail, # String — Sentence explaining the failure; null unless failed. Carries the chain's own words when the revert was not one the gateway recognises.
|
|
226
|
-
:error_message, # Legacy alias of `error_detail`.
|
|
227
245
|
:unsigned_transaction,
|
|
228
246
|
:transaction_hash,
|
|
229
|
-
:
|
|
247
|
+
:sender, # The address that SIGNED the submitted transaction, recovered from the signature by the gateway at submit — a fact, not a client claim. Null where the gateway held no signature to recover from (a report-by-hash submit, where the wallet broadcast it itself) or where nothing has been submitted yet. This is what makes `release` gas attributable: that operation is payer-OR-payee, so whose cost it is depends on who signed.
|
|
248
|
+
:amount, # Amount in token BASE units. On capture and refund it is the amount the caller asked for. On void and release it is PROVISIONAL — those operations carry no amount and move the whole uncaptured escrow, so this is the capturable residual as of prepare, re-sealed with the exact on-chain amount when the indexer confirms.
|
|
230
249
|
:block_number,
|
|
231
250
|
:gas_used, # Gas units used, mirrored from the indexer on confirm.
|
|
232
251
|
:gas_limit, # Gas limit, mirrored from the indexer on confirm.
|
|
@@ -241,11 +260,41 @@ module Rail0
|
|
|
241
260
|
keyword_init: true
|
|
242
261
|
)
|
|
243
262
|
|
|
263
|
+
# Full RAIL0 deployment row (admin surfaces only). Read alongside AdminBlockchain: a chain with no
|
|
264
|
+
# row here cannot take a payment, which is what keeps it out of service.
|
|
265
|
+
AdminContract = Struct.new(
|
|
266
|
+
:id, # String
|
|
267
|
+
:blockchain_id, # String
|
|
268
|
+
:address, # String
|
|
269
|
+
:version, # String — Semver, and every ACTIVE deployment must agree on the one the code targets.
|
|
270
|
+
:deployed_at, # String
|
|
271
|
+
:start_block, # Where the indexer starts. Null means the deployment block.
|
|
272
|
+
:active, # Boolean — The single deployment new payments are opened against, at most one per chain.
|
|
273
|
+
:archived, # Boolean — Always false here: the listing excludes archived deployments.
|
|
274
|
+
:archived_at,
|
|
275
|
+
:created_at, # String
|
|
276
|
+
:updated_at, # String
|
|
277
|
+
keyword_init: true
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# The account's own profile as the holder reads it (GET) and as a PATCH returns it — id, name,
|
|
281
|
+
# email, timestamps. Deliberately no admin/role field: the operator grant lives in a separate table,
|
|
282
|
+
# so a standard account's profile carries no trace of that axis. An ADMIN reading any account gets
|
|
283
|
+
# the whole record plus `admin` instead, which is a different shape and not this one.
|
|
284
|
+
Account = Struct.new(
|
|
285
|
+
:id, # String
|
|
286
|
+
:name, # String
|
|
287
|
+
:email,
|
|
288
|
+
:created_at, # String
|
|
289
|
+
:updated_at, # String
|
|
290
|
+
keyword_init: true
|
|
291
|
+
)
|
|
292
|
+
|
|
244
293
|
Webhook = Struct.new(
|
|
245
294
|
:id, # String
|
|
246
295
|
:name, # String
|
|
247
296
|
:callback_url, # String
|
|
248
|
-
:topics, # Array
|
|
297
|
+
:topics, # Array — Every event this subscription delivers. The delivery itself names the one that fired, in X-Rail0-Topic.
|
|
249
298
|
:active, # Boolean
|
|
250
299
|
:circuit_state, # String
|
|
251
300
|
:circuit_failure_count, # Integer
|
|
@@ -272,9 +321,10 @@ module Rail0
|
|
|
272
321
|
# Sweeper view of a stale submitted transaction.
|
|
273
322
|
SyncTransaction = Struct.new(
|
|
274
323
|
:transaction_hash, # String
|
|
275
|
-
:operation, # String
|
|
324
|
+
:operation, # String — Same vocabulary as `Transaction.operation` — dispute rows go stale too.
|
|
276
325
|
:payment_id, # String — Protocol-level rail0_id.
|
|
277
326
|
:chain_id, # Integer
|
|
327
|
+
:contract_address, # String — Address (lowercase) of the RAIL0 deployment the payment is bound to, archived versions included. The sweeper accepts a receipt's event only when this contract emitted it.
|
|
278
328
|
keyword_init: true
|
|
279
329
|
)
|
|
280
330
|
|
|
@@ -283,8 +333,7 @@ module Rail0
|
|
|
283
333
|
:chain_id, # Integer
|
|
284
334
|
:start_block, # Integer
|
|
285
335
|
:required_confirmations, # Integer — Fallback burial depth, used where the chain serves no finality tag.
|
|
286
|
-
:finality_tag, # String — Which block the chain calls settled
|
|
287
|
-
# "depth"); the indexer gates every notify on it.
|
|
336
|
+
:finality_tag, # String — Which block the chain calls settled; the indexer gates every notify on it.
|
|
288
337
|
:explorer_url, # Block explorer base URL; null when the chain has none.
|
|
289
338
|
:network_type, # String — "testnet" or "mainnet"; selects which chains a deployment indexes.
|
|
290
339
|
:rpc_urls, # Array — Ordered list of public RPC endpoints tried in turn (serial fallback).
|
data/lib/rail0/version.rb
CHANGED
|
@@ -5,5 +5,5 @@ module Rail0
|
|
|
5
5
|
# consumed by path from a sibling checkout, where the number bought nothing: no registry
|
|
6
6
|
# ever served a 1.x, no tag was ever cut, and the one consumer resolves the working tree.
|
|
7
7
|
# Publishing from 1.2.0 would have implied two releases nobody can install.
|
|
8
|
-
VERSION = "1.
|
|
8
|
+
VERSION = "1.2.0"
|
|
9
9
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rail0-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Commerce Layer
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|
|
@@ -84,7 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
84
84
|
- !ruby/object:Gem::Version
|
|
85
85
|
version: '0'
|
|
86
86
|
requirements: []
|
|
87
|
-
rubygems_version: 3.5.
|
|
87
|
+
rubygems_version: 3.5.22
|
|
88
88
|
signing_key:
|
|
89
89
|
specification_version: 4
|
|
90
90
|
summary: Ruby SDK for the RAIL0 stablecoin payment protocol
|