rail0-sdk 1.1.0 → 1.3.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 +156 -22
- 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/chains.rb +9 -1
- data/lib/rail0/resources/disputes.rb +1 -1
- data/lib/rail0/resources/payments.rb +32 -12
- data/lib/rail0/resources/query.rb +21 -1
- data/lib/rail0/resources/tokens.rb +11 -4
- data/lib/rail0/resources/wallets.rb +14 -3
- data/lib/rail0/resources/webhooks.rb +37 -8
- data/lib/rail0/types.rb +21 -1
- 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: 1304e38f4530a6bfdbfe8f3196ed562b4869b9e6ab7d455cae744afb21ad21ed
|
|
4
|
+
data.tar.gz: 87090219ed6aef2133815ca4a704a5c7a8e926cead00e59688a42aac25f29297
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77f22e52615cd7cac1b1535084a388111e20850e01a89a39628322bf825f8ea023c1db7e9d185bbb9c0616fa1827add2ebbdb2d8749cbf60fbfe099f6f0719f8
|
|
7
|
+
data.tar.gz: ae3d34f5cceffb1ea59f33114b53c5308eb115b929bfa581717e7e3357fedbb38a527ef0a35136943eadbc4fa0e93a589ebbacff4f13c880f885c95869644a4a
|
data/README.md
CHANGED
|
@@ -127,12 +127,17 @@ client.payments.submit_by_hash(rail0_id, "capture", { transaction_hash: "0x…"
|
|
|
127
127
|
| `charge_prepare` + `charge` | payee | One-shot authorize + capture; no escrow window |
|
|
128
128
|
| `capture_prepare` + `capture` | payee | Move escrowed funds to the merchant (partial supported) |
|
|
129
129
|
| `void_prepare` + `void` | payee | Cancel the hold, return funds to the payer (only before any capture) |
|
|
130
|
-
| `release_prepare` + `release` |
|
|
130
|
+
| `release_prepare` + `release` | payer or payee | Return uncaptured escrow to the payer |
|
|
131
131
|
| `refund_prepare` (phase 1+2) + `refund` | payee | Return captured funds to the payer via EIP-3009 |
|
|
132
132
|
| `dispute_prepare` + `dispute` | payer | Open a dispute (signal-only) |
|
|
133
133
|
| `close_dispute_prepare` + `close_dispute` | payer | Close an open dispute |
|
|
134
134
|
| `dispute_submit_by_hash` / `close_dispute_submit_by_hash` | payer | Report a dispute tx the wallet already broadcast |
|
|
135
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
|
+
|
|
136
141
|
**Payment statuses:** `unsigned`, `signed`, `authorized`, `charged`, `captured`,
|
|
137
142
|
`partially_captured`, `voided`, `released`, `refunded`, `expired` — plus
|
|
138
143
|
`partially_refunded`, which is no longer produced (a partial refund deliberately leaves
|
|
@@ -142,6 +147,13 @@ exhaustive `case` that raises on it.
|
|
|
142
147
|
`expired` is a never-captured authorization whose window lapsed. It is **not** terminal:
|
|
143
148
|
the escrow is still on-chain and `release` still works from it (closing the payment as
|
|
144
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.
|
|
145
157
|
**Transaction statuses:** `pending`, `submitting`, `submitted`, `confirmed`, `failed`.
|
|
146
158
|
|
|
147
159
|
## Authentication (SIWE)
|
|
@@ -156,9 +168,9 @@ Two role rules are worth knowing before the first call, because both surface as
|
|
|
156
168
|
403 rather than a validation error:
|
|
157
169
|
|
|
158
170
|
- `payments.create` requires the caller to **be the payer** (`payer_must_be_caller`);
|
|
159
|
-
- the merchant operations (authorize, capture, charge, void, refund)
|
|
160
|
-
**payee-only
|
|
161
|
-
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**.
|
|
162
174
|
|
|
163
175
|
A wallet that signs *and broadcasts* in one step (MetaMask) reports the result by hash
|
|
164
176
|
instead of handing over a signed transaction: `submit_by_hash` covers the merchant
|
|
@@ -225,12 +237,34 @@ logging — it says exactly which sessions died, which `revoked_all: true` canno
|
|
|
225
237
|
## Catalog (public)
|
|
226
238
|
|
|
227
239
|
```ruby
|
|
228
|
-
client.chains.list # GET /blockchains (each chain's :contract is the active RAIL0 deployment)
|
|
240
|
+
client.chains.list # GET /blockchains (each chain's :contract is the active RAIL0 deployment, :settlement its measured confirm time)
|
|
229
241
|
client.chains.list(network_type: "testnet", symbol: "ETH")
|
|
230
|
-
client.tokens.list # GET /tokens
|
|
242
|
+
client.tokens.list # GET /tokens — every token, retired ones included
|
|
231
243
|
client.tokens.list(chain_id: 84532, symbol: "USDC")
|
|
244
|
+
client.tokens.list(active: true) # what a NEW payment may use
|
|
232
245
|
```
|
|
233
246
|
|
|
247
|
+
Each chain's `settlement` is how long an operation on it has taken to confirm **on this
|
|
248
|
+
gateway**, end to end (the chain's finality lag plus the gateway/indexer pipeline):
|
|
249
|
+
`{ p50_seconds:, p90_seconds:, sample_size:, window_days: }`, percentiles of broadcast →
|
|
250
|
+
confirmation over the trailing `window_days`, in whole seconds rounded up. It is always
|
|
251
|
+
present, but the percentiles are `nil` below the gateway's minimum sample (20), so size a
|
|
252
|
+
polling deadline from it with a fixed fallback:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
FALLBACK = 15 * 60 # seconds
|
|
256
|
+
chain = client.chains.list.find { |c| c[:chain_id] == 84532 }
|
|
257
|
+
p90 = chain.dig(:settlement, :p90_seconds)
|
|
258
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (p90 ? [3 * p90, FALLBACK].max : FALLBACK)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
It is a trailing-window measurement (a capacity figure, cached by the gateway for 10
|
|
262
|
+
minutes), not a promise, so treat it as a hint and never as a timeout that fails a payment.
|
|
263
|
+
|
|
264
|
+
`tokens.list` without `active:` is the **historical** catalogue — a payment references its
|
|
265
|
+
token forever, so a retired one must still resolve. A checkout picker should pass
|
|
266
|
+
`active: true`: `payments.create` answers 422 `unknown_token` for a retired token.
|
|
267
|
+
|
|
234
268
|
## Health
|
|
235
269
|
|
|
236
270
|
```ruby
|
|
@@ -241,6 +275,9 @@ client.health.get # GET /health → { status:, api_version:, contract_version:
|
|
|
241
275
|
|
|
242
276
|
Paginated calls return `{ data:, meta: }`. `meta` is `{ page:, per_page:, total:, total_pages:, links: }`.
|
|
243
277
|
|
|
278
|
+
`page` is 1-based and bounded to 1..1,000,000 — a value outside is a 400, not an empty
|
|
279
|
+
page. `per_page` defaults to 25 and is capped at 100.
|
|
280
|
+
|
|
244
281
|
`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.
|
|
245
282
|
|
|
246
283
|
```ruby
|
|
@@ -278,6 +315,8 @@ account id first. `id_or_address` accepts the wallet UUID or its 0x address.
|
|
|
278
315
|
```ruby
|
|
279
316
|
client.wallets.list(account_id, chain_id: 84532, active: true)
|
|
280
317
|
# => { data: [ { id:, address:, label:, active:, tokens: [...] } ], meta: { page:, per_page:, total: } }
|
|
318
|
+
# chain_id / token_symbol / default / token_active narrow the NESTED holdings, never the wallets.
|
|
319
|
+
client.wallets.list(account_id, token_active: true)
|
|
281
320
|
|
|
282
321
|
client.wallets.get(account_id, id_or_address)
|
|
283
322
|
client.wallets.update(account_id, id_or_address, label: "Renamed", active: false)
|
|
@@ -299,13 +338,14 @@ client.wallets.create(account_id, address: "0x…", **proof, label: "Treasury")
|
|
|
299
338
|
```
|
|
300
339
|
|
|
301
340
|
The proof is **purpose-bound**, and a login proof will not do — the gateway pins
|
|
302
|
-
each endpoint to one statement and refuses the
|
|
341
|
+
each endpoint to one statement and refuses the others with 422
|
|
303
342
|
`siwe_purpose_mismatch`:
|
|
304
343
|
|
|
305
344
|
| Endpoint | Statement | Constant |
|
|
306
345
|
|---|---|---|
|
|
307
346
|
| `POST /auth` | `Sign in to RAIL0` | `Rail0::Resources::Auth::LOGIN_STATEMENT` |
|
|
308
347
|
| `POST /accounts/:id/wallets` | `Add this wallet to your RAIL0 account` | `Rail0::Resources::Auth::WALLET_LINK_STATEMENT` |
|
|
348
|
+
| `POST /auth/revoke_all` | `Sign out of RAIL0 everywhere` | `Rail0::Resources::Auth::REVOKE_ALL_STATEMENT` |
|
|
309
349
|
|
|
310
350
|
That is a security boundary rather than a label: a login signature is handed out
|
|
311
351
|
on every sign-in, so a wallet-link endpoint that accepted one would let anyone
|
|
@@ -326,6 +366,11 @@ re-enables it and answers 200 instead of creating a second row. The id passed to
|
|
|
326
366
|
the other three is the **token's** UUID (`token_id` on the holding), not an id of the
|
|
327
367
|
holding row and not the token address — the gateway looks the holding up by (wallet, token).
|
|
328
368
|
|
|
369
|
+
The wallet's active **default** holding cannot be disabled or removed — that would leave it
|
|
370
|
+
with no preferred payment method — and the gateway answers 422 `default_payment_method`.
|
|
371
|
+
Make another token the default first (`add_token(..., default: true)` demotes the old one
|
|
372
|
+
in the same transaction), then disable or remove it.
|
|
373
|
+
|
|
329
374
|
## Payments
|
|
330
375
|
|
|
331
376
|
```ruby
|
|
@@ -334,7 +379,11 @@ client.payments.create(params, idempotency_key: nil) # or keyword fields
|
|
|
334
379
|
# "idempotency_key_reused" (422) instead of returning the first payment.
|
|
335
380
|
client.payments.get(id)
|
|
336
381
|
client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at")
|
|
382
|
+
client.payments.list(status: %w[authorized expired]) # any of several states → ?status=authorized,expired
|
|
383
|
+
client.payments.list(operation: "refund") # payments with at least one refund transaction
|
|
337
384
|
client.payments.transactions(id, operation: "capture")
|
|
385
|
+
client.payments.transactions(id, status: %w[submitting submitted]) # on its way to the chain
|
|
386
|
+
# `operation` on both takes Payments::TRANSACTION_OPERATIONS — dispute and close_dispute included.
|
|
338
387
|
client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast
|
|
339
388
|
# Offer `redrive` on the row's `redrivable` flag — the same predicate the gateway guards
|
|
340
389
|
# the route with — and not on `status == "pending"`: a pending row holding no signed
|
|
@@ -357,10 +406,29 @@ client.disputes.list(status: "closed", sort: "-opened_at")
|
|
|
357
406
|
`payments.list`/`transactions`/`disputes` and `disputes.list` return a paginated
|
|
358
407
|
`{ data:, meta: { page:, per_page:, total: } }` envelope.
|
|
359
408
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
`
|
|
363
|
-
|
|
409
|
+
`status:` on `payments.list` and `payments.transactions` takes one value or an Array; an
|
|
410
|
+
Array is sent comma-separated and matches **any** of the listed states. An empty Array is
|
|
411
|
+
dropped like `nil` (no filter). An unknown value raises `Rail0::ApiError` 400
|
|
412
|
+
`validation_failed`, naming it. Any query filter given an Array is sent the same way.
|
|
413
|
+
|
|
414
|
+
Every payment row carries `chain_id` and `decimals`, list rows included (and the payment
|
|
415
|
+
embedded in `disputes.list`). Amounts (`amount`, `capturable_amount`, `refundable_amount`)
|
|
416
|
+
are in base units: divide by `10**decimals` to render them, with no `tokens.list` join and no
|
|
417
|
+
`get(id)` per row. `decimals` is `nil` only when the gateway cannot resolve the token (a
|
|
418
|
+
contract it has not loaded, a token deleted outright). Fall back to `tokens.list` then,
|
|
419
|
+
matching on `token` **plus** `chain_id`, since an address alone identifies a token only
|
|
420
|
+
within one chain.
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
BigDecimal(row[:amount]) / (10**row[:decimals]) # "100000000", 6 → 100.0
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Every payment row also carries `in_flight`: `true` while one of its transactions is on its
|
|
427
|
+
way to the chain, meaning `submitting`, `submitted`, or `pending` holding its signed
|
|
428
|
+
transaction (the `redrivable` rows). A `pending` row still waiting for its signature does not
|
|
429
|
+
count, nor do `confirmed`/`failed` rows. While it is true the balances are about to move, so
|
|
430
|
+
disable further actions on the payment. It comes on list rows too, which embed no
|
|
431
|
+
transactions, so a bulk action can skip busy rows without a `get(id)` each.
|
|
364
432
|
|
|
365
433
|
### Refund (two-phase EIP-3009)
|
|
366
434
|
|
|
@@ -377,6 +445,8 @@ client.payments.refund(rail0_id, { signed_transaction: raw })
|
|
|
377
445
|
|
|
378
446
|
### Disputes (payer-driven)
|
|
379
447
|
|
|
448
|
+
> **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`).
|
|
449
|
+
|
|
380
450
|
Disputes are signed on-chain by the payer and follow the same prepare → submit
|
|
381
451
|
pattern. Like the rest of `/payments` they need a session, but the payer's
|
|
382
452
|
account-less SIWE login is enough:
|
|
@@ -424,10 +494,37 @@ client.webhooks.enable(id)
|
|
|
424
494
|
client.webhooks.disable(id)
|
|
425
495
|
client.webhooks.rotate_secret(id) # returns a fresh shared_secret
|
|
426
496
|
client.webhooks.reset_circuit(id)
|
|
427
|
-
|
|
497
|
+
# status is "delivered" or "failed" only; payment_id takes a UUID or a rail0_id.
|
|
498
|
+
client.webhooks.event_callbacks(id, status: "failed", response_code: "500")
|
|
499
|
+
client.webhooks.redeliver(id, callback_id) # 202 { status: "queued" } — replay one delivery
|
|
428
500
|
client.webhooks.delete(id) # 204
|
|
429
501
|
```
|
|
430
502
|
|
|
503
|
+
Every delivery's `payment` object carries `capturable_amount` (base units) and
|
|
504
|
+
`authorization_expiry` (epoch seconds), whatever the topic. That is what makes
|
|
505
|
+
`payments.authorization_expiring` actionable. The gateway sends it **once per payment**, a
|
|
506
|
+
configurable notice (24h by default) before `authorization_expiry`, for a payment that can
|
|
507
|
+
still be captured (`authorized`, `partially_captured`, `partially_refunded`) and still holds
|
|
508
|
+
escrow. It is the last chance to capture; `payments.expired` says the chance has gone. Its
|
|
509
|
+
`transaction` is `null`, and the time left is `authorization_expiry` minus the event's
|
|
510
|
+
`emitted_at` (ISO-8601). There is no separate field for it because it would be stale on
|
|
511
|
+
every retry:
|
|
512
|
+
|
|
513
|
+
```ruby
|
|
514
|
+
event = JSON.parse(raw, symbolize_names: true)
|
|
515
|
+
if event[:topic] == "payments.authorization_expiring"
|
|
516
|
+
payment = event[:payment]
|
|
517
|
+
secs_left = payment[:authorization_expiry] - Time.iso8601(event[:emitted_at]).to_i
|
|
518
|
+
# capture payment[:capturable_amount] (base units) within secs_left, or let it expire
|
|
519
|
+
end
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
`redeliver` replays a recorded delivery's exact payload — the same event id, so a receiver
|
|
523
|
+
that already processed it deduplicates — under a fresh signature. It is the way back for
|
|
524
|
+
events lost while the circuit breaker was open. Delivery is async and the dispatcher drops
|
|
525
|
+
anything for an inactive webhook, so `enable` (if disabled) or `reset_circuit` (if the
|
|
526
|
+
breaker opened) **first**, or the 202 is answered and the replay silently dropped.
|
|
527
|
+
|
|
431
528
|
### Verifying a delivery
|
|
432
529
|
|
|
433
530
|
Every delivery carries `X-Rail0-Topic`, `X-Rail0-Timestamp` and
|
|
@@ -469,9 +566,19 @@ digest with it. Comparison is constant-time.
|
|
|
469
566
|
client.accounts.get(account_id)
|
|
470
567
|
# => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test",
|
|
471
568
|
# created_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-20T00:00:00Z" }
|
|
569
|
+
|
|
570
|
+
# Update the same profile — only the fields passed are sent.
|
|
571
|
+
client.accounts.update(account_id, email: "ops@merchant.example")
|
|
572
|
+
client.accounts.update(account_id, name: "Merchant Ltd", email: "ops@merchant.example")
|
|
472
573
|
```
|
|
473
574
|
|
|
474
575
|
`email` is in the response because the holder is this endpoint's only possible caller.
|
|
576
|
+
|
|
577
|
+
`update` needs at least one of `name`/`email` (it raises `ArgumentError` before sending an
|
|
578
|
+
empty PATCH, which the gateway would answer with 400). Both are unique across accounts, so
|
|
579
|
+
a value another account holds answers **409**. It is a write on the account, so a
|
|
580
|
+
deactivated wallet (403 `wallet_deactivated`) or account (403 `account_deactivated`) cannot
|
|
581
|
+
make it. The gateway's `active` field is operator-only and not exposed here.
|
|
475
582
|
The account's wallets are a collection under the same path (`client.wallets`), and
|
|
476
583
|
buyer-facing discovery is `client.payment_methods`.
|
|
477
584
|
|
|
@@ -542,9 +649,17 @@ client.analytics.breakdown(by: "operation")
|
|
|
542
649
|
# => [{ key: "capture", orders: 1, transactions: 2 }, …]
|
|
543
650
|
```
|
|
544
651
|
|
|
545
|
-
All three take the same filters — `mode`, `status`, `token`, `chain_id`, `from`, `to
|
|
546
|
-
the same question can be asked at three resolutions: one total, a series over
|
|
547
|
-
by dimension.
|
|
652
|
+
All three take the same filters — `mode`, `status`, `token`, `chain_id`, `from`, `to`,
|
|
653
|
+
`payee` — so the same question can be asked at three resolutions: one total, a series over
|
|
654
|
+
time, a split by dimension. Figures cover all of the account's wallets unless `payee` pins
|
|
655
|
+
one of them (a 0x address the account owns — another address is refused with 403, a
|
|
656
|
+
malformed one with 400):
|
|
657
|
+
|
|
658
|
+
```ruby
|
|
659
|
+
client.analytics.summary(payee: "0xYourWallet…")
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
`from`/`to` filter the payment's CREATION date, which matters when reading
|
|
548
663
|
gas: a payment created in one period and captured in the next books its capture gas in the
|
|
549
664
|
first.
|
|
550
665
|
|
|
@@ -634,8 +749,7 @@ end
|
|
|
634
749
|
```
|
|
635
750
|
|
|
636
751
|
**`error` is the only field to branch on.** It is the specific condition, read from the
|
|
637
|
-
gateway's `code
|
|
638
|
-
wider family), so an older gateway still yields the most specific value it sent.
|
|
752
|
+
gateway's `code`.
|
|
639
753
|
|
|
640
754
|
`title` and `detail` come from the gateway's error catalogue, so the same condition always
|
|
641
755
|
reads the same way whichever endpoint surfaced it; `detail` is written to be shown to a
|
|
@@ -674,8 +788,8 @@ Rail0::Client.new(
|
|
|
674
788
|
### Rate limits
|
|
675
789
|
|
|
676
790
|
The gateway throttles two surfaces independently: the public, unauthenticated one **per
|
|
677
|
-
IP** (100 requests / 60s by default — SIWE nonce + verify, `/
|
|
678
|
-
catalog reads, `/health`) and everything authenticated **per session**, keyed on the
|
|
791
|
+
IP** (100 requests / 60s by default — SIWE nonce + verify, `/auth/revoke_all`,
|
|
792
|
+
`/payment_methods`, the catalog reads, `/health`) and everything authenticated **per session**, keyed on the
|
|
679
793
|
JWT's subject (300 / 60s). Over budget it answers **429** with `code: "rate_limited"` and
|
|
680
794
|
a `Retry-After`.
|
|
681
795
|
|
|
@@ -691,8 +805,8 @@ rescue Rail0::ApiError => e
|
|
|
691
805
|
end
|
|
692
806
|
```
|
|
693
807
|
|
|
694
|
-
Note what the number means: the gateway sends **the
|
|
695
|
-
|
|
808
|
+
Note what the number means: the gateway sends **the time left in the current window**
|
|
809
|
+
(the same value as `RateLimit-Reset`), not the window's length, so it is the wait itself.
|
|
696
810
|
|
|
697
811
|
`retry_on_429: true` makes the SDK do that waiting for you — Retry-After, clamped to
|
|
698
812
|
`retry_after_cap`, plus a little jitter (see `Rail0::Backoff`; callers sharing one session
|
|
@@ -737,7 +851,7 @@ lib/rail0/
|
|
|
737
851
|
wallets.rb account-scoped wallet management (JWT)
|
|
738
852
|
payments.rb payment lifecycle + disputes
|
|
739
853
|
disputes.rb account-level dispute listing (JWT)
|
|
740
|
-
accounts.rb the caller's own account profile (JWT)
|
|
854
|
+
accounts.rb the caller's own account profile, read and update (JWT)
|
|
741
855
|
webhooks.rb webhook subscription management (JWT)
|
|
742
856
|
analytics.rb account-scoped payment analytics (JWT)
|
|
743
857
|
query.rb shared query-string helper
|
|
@@ -765,6 +879,26 @@ uses them everywhere, table-aligned hashes because some literals are tables, and
|
|
|
765
879
|
(`retry_on_429`, `eip712`, `secp256k1`) that each spell themselves their own way. A linter
|
|
766
880
|
that argues with the codebase teaches people to ignore it.
|
|
767
881
|
|
|
882
|
+
## Publishing
|
|
883
|
+
|
|
884
|
+
Releases publish from GitHub Actions (`.github/workflows/release.yml`) through RubyGems
|
|
885
|
+
**Trusted Publishing**: RubyGems.org trusts that workflow in this repo, so there is no API
|
|
886
|
+
key to store or rotate and no OTP prompt.
|
|
887
|
+
|
|
888
|
+
1. Merge a PR that bumps `Rail0::VERSION` in `lib/rail0/version.rb`.
|
|
889
|
+
2. Publish a GitHub release on `main` tagged `v<version>` (e.g. `v1.2.0`).
|
|
890
|
+
|
|
891
|
+
The workflow checks the tag against `Rail0::VERSION`, runs `rake build` (rubocop and the
|
|
892
|
+
specs first, then the gem) and pushes it. A version already on RubyGems.org is skipped, so
|
|
893
|
+
a re-run (Actions → release → Run workflow, from `main`, with the tag as input) is always
|
|
894
|
+
safe — that is also how a release cut before the workflow existed gets published.
|
|
895
|
+
|
|
896
|
+
One-time setup, by an owner of the gem on rubygems.org: rail0-sdk → Trusted publishers →
|
|
897
|
+
GitHub Actions, repository `commercelayer/rail0-ruby`, workflow `release.yml`.
|
|
898
|
+
|
|
899
|
+
Publishing by hand still works as a fallback (`gem build rail0-sdk.gemspec && gem push
|
|
900
|
+
rail0-sdk-<version>.gem`, with the OTP the gem's `rubygems_mfa_required` asks for).
|
|
901
|
+
|
|
768
902
|
## License
|
|
769
903
|
|
|
770
904
|
[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
|
|
@@ -20,7 +20,15 @@ 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), and settlement — how long
|
|
26
|
+
# an operation on the chain has taken to confirm ON THIS GATEWAY, as
|
|
27
|
+
# `{ p50_seconds:, p90_seconds:, sample_size:, window_days: }`: broadcast-to-confirmation
|
|
28
|
+
# percentiles (whole seconds, rounded up) over the trailing `window_days`. Always
|
|
29
|
+
# present; the percentiles are nil below the gateway's minimum sample (20), so check
|
|
30
|
+
# `sample_size` and keep a fixed fallback. A wait-deadline hint (e.g. 3 × p90_seconds,
|
|
31
|
+
# floored at your fallback), not a guarantee.
|
|
24
32
|
def list(network_type: nil, symbol: nil)
|
|
25
33
|
http.get("/blockchains#{build_query(network_type: network_type, symbol: symbol)}")
|
|
26
34
|
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)
|
|
@@ -27,7 +32,9 @@ module Rail0
|
|
|
27
32
|
|
|
28
33
|
# List payments for the authenticated wallet (requires JWT). Returns
|
|
29
34
|
# payments where the caller is the payer or payee.
|
|
30
|
-
# @param status [String, nil] Filter by payment status
|
|
35
|
+
# @param status [String, Array<String>, nil] Filter by payment status: one state, or
|
|
36
|
+
# an Array matching ANY of them (sent comma-separated, `status=authorized,expired`).
|
|
37
|
+
# An unknown value is 400 `validation_failed` naming it.
|
|
31
38
|
# @param mode [String, nil] Filter by mode ("authorize" or "charge").
|
|
32
39
|
# @param payer [String, nil] Filter by payer address.
|
|
33
40
|
# @param payee [String, nil] Filter by payee address.
|
|
@@ -35,6 +42,8 @@ module Rail0
|
|
|
35
42
|
# @param rail0_id [String, nil] Filter by the logical on-chain payment id (0x…).
|
|
36
43
|
# @param chain_id [Integer, nil] Filter by the payment's chain.
|
|
37
44
|
# @param disputed [Boolean, nil] Filter by whether an open dispute exists.
|
|
45
|
+
# @param operation [String, nil] Only payments carrying at least one transaction with
|
|
46
|
+
# this operation (see {TRANSACTION_OPERATIONS}).
|
|
38
47
|
# @param min_amount [String, nil] Minimum amount in token BASE UNITS (inclusive).
|
|
39
48
|
# @param max_amount [String, nil] Maximum amount in token BASE UNITS (inclusive).
|
|
40
49
|
# These two really are base units — the filter runs against the stored column.
|
|
@@ -43,14 +52,20 @@ module Rail0
|
|
|
43
52
|
# @param created_from [String, nil] Only payments created at/after this ISO-8601 time.
|
|
44
53
|
# @param created_to [String, nil] Only payments created at/before this ISO-8601 time.
|
|
45
54
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
46
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
55
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
47
56
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
48
|
-
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
57
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }. Each row
|
|
58
|
+
# carries, besides the payment fields, `chain_id`, `decimals` (Integer, or nil when
|
|
59
|
+
# the gateway can't resolve the token — fall back to {Tokens#list} then) to render the
|
|
60
|
+
# base-unit amounts without a token lookup, and `in_flight` (Boolean: a transaction is
|
|
61
|
+
# `submitting`, `submitted`, or `pending` holding its signed transaction — disable
|
|
62
|
+
# further actions on the row while true).
|
|
49
63
|
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,
|
|
64
|
+
chain_id: nil, disputed: nil, operation: nil, min_amount: nil, max_amount: nil,
|
|
51
65
|
created_from: nil, created_to: nil, sort: nil, page: nil, per_page: nil)
|
|
52
66
|
query = build_query(status: status, mode: mode, payer: payer, payee: payee, token: token,
|
|
53
67
|
rail0_id: rail0_id, chain_id: chain_id, disputed: disputed,
|
|
68
|
+
operation: operation,
|
|
54
69
|
min_amount: min_amount, max_amount: max_amount,
|
|
55
70
|
created_from: created_from, created_to: created_to,
|
|
56
71
|
sort: sort, page: page, per_page: per_page)
|
|
@@ -80,17 +95,21 @@ module Rail0
|
|
|
80
95
|
|
|
81
96
|
# Fetch current payment state (DB status + live on-chain amounts + transactions).
|
|
82
97
|
# @param id [String] Payment UUID or rail0_id.
|
|
83
|
-
# @return [Hash]
|
|
98
|
+
# @return [Hash] The payment with its embedded transactions, including `decimals`
|
|
99
|
+
# (Integer or nil — divide `amount`, `capturable_amount` and `refundable_amount` by
|
|
100
|
+
# 10**decimals to render them) and `in_flight` (Boolean — see {list}).
|
|
84
101
|
def get(id)
|
|
85
102
|
http.get("/payments/#{id}")
|
|
86
103
|
end
|
|
87
104
|
|
|
88
105
|
# List on-chain transactions for a payment.
|
|
89
106
|
# @param id [String] Payment UUID or rail0_id.
|
|
90
|
-
# @param operation [String, nil] Filter by operation (see {
|
|
91
|
-
#
|
|
107
|
+
# @param operation [String, nil] Filter by operation (see {TRANSACTION_OPERATIONS} —
|
|
108
|
+
# dispute and close_dispute included).
|
|
109
|
+
# @param status [String, Array<String>, nil] Filter by transaction status: one, or an
|
|
110
|
+
# Array matching any of them (`%w[submitting submitted]` = on its way to the chain).
|
|
92
111
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
93
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
112
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
94
113
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
95
114
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
96
115
|
def transactions(id, operation: nil, status: nil, sort: nil, page: nil, per_page: nil)
|
|
@@ -149,7 +168,7 @@ module Rail0
|
|
|
149
168
|
# @param id [String] Payment UUID or rail0_id.
|
|
150
169
|
# @param status [String, nil] Filter by dispute status ("open" or "closed").
|
|
151
170
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
152
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
171
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
153
172
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
154
173
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
155
174
|
def disputes(id, status: nil, sort: nil, page: nil, per_page: nil)
|
|
@@ -239,9 +258,10 @@ module Rail0
|
|
|
239
258
|
submit(id, "void", params)
|
|
240
259
|
end
|
|
241
260
|
|
|
242
|
-
# Phase 1 — build the unsigned release() transaction
|
|
243
|
-
#
|
|
244
|
-
#
|
|
261
|
+
# Phase 1 — build the unsigned release() transaction for +from+, whose nonce it
|
|
262
|
+
# carries. +from+ must be the payment's payer or payee (the gateway answers 422
|
|
263
|
+
# release_submitter_not_a_party otherwise); omitted, it is the signed-in caller.
|
|
264
|
+
# Returns uncaptured escrow to the payer.
|
|
245
265
|
def release_prepare(id, from: nil, idempotency_key: nil)
|
|
246
266
|
prepare(id, "release", from ? { from: from } : {}, idempotency_key: idempotency_key)
|
|
247
267
|
end
|
|
@@ -10,10 +10,30 @@ module Rail0
|
|
|
10
10
|
module Query
|
|
11
11
|
private
|
|
12
12
|
|
|
13
|
+
# Builds "?k=v&…" from keyword filters, or "" when every value is nil.
|
|
14
|
+
#
|
|
15
|
+
# An Array value becomes ONE comma-separated pair (`status: %w[a b]` →
|
|
16
|
+
# `status=a,b`): the gateway's multi-value filters (e.g. `status` on
|
|
17
|
+
# GET /payments, #367) take the comma form. Each element is escaped on its
|
|
18
|
+
# own and the separating comma is left literal, so a comma can never leak
|
|
19
|
+
# in from an element. nil elements are dropped; an Array left empty drops
|
|
20
|
+
# the whole pair, the same as nil, rather than sending a blank `status=`
|
|
21
|
+
# the gateway would reject with 400.
|
|
13
22
|
def build_query(**params)
|
|
14
|
-
pairs = params.
|
|
23
|
+
pairs = params.filter_map do |k, v|
|
|
24
|
+
value = query_value(v)
|
|
25
|
+
"#{k}=#{value}" unless value.nil?
|
|
26
|
+
end
|
|
15
27
|
pairs.empty? ? "" : "?#{pairs.join('&')}"
|
|
16
28
|
end
|
|
29
|
+
|
|
30
|
+
def query_value(value)
|
|
31
|
+
return nil if value.nil?
|
|
32
|
+
return CGI.escape(value.to_s) unless value.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
items = value.compact
|
|
35
|
+
items.map { |item| CGI.escape(item.to_s) }.join(",") unless items.empty?
|
|
36
|
+
end
|
|
17
37
|
end
|
|
18
38
|
end
|
|
19
39
|
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
|
|
|
@@ -140,6 +143,11 @@ module Rail0
|
|
|
140
143
|
# not the token's contract address. The gateway looks the holding up by
|
|
141
144
|
# (wallet, token_id); the same applies to {enable_token} and {disable_token}.
|
|
142
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.
|
|
150
|
+
#
|
|
143
151
|
# @return [nil]
|
|
144
152
|
def remove_token(account_id, id_or_address, token_id)
|
|
145
153
|
http.delete("/accounts/#{account_id}/wallets/#{id_or_address}/tokens/#{token_id}")
|
|
@@ -153,6 +161,9 @@ module Rail0
|
|
|
153
161
|
|
|
154
162
|
# Disable an existing token holding without forgetting it — the holding (and
|
|
155
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.
|
|
156
167
|
# @return [Hash] the token holding
|
|
157
168
|
def disable_token(account_id, id_or_address, token_id)
|
|
158
169
|
http.patch("/accounts/#{account_id}/wallets/#{id_or_address}/tokens/#{token_id}/disable")
|
|
@@ -25,6 +25,7 @@ module Rail0
|
|
|
25
25
|
payments.voided
|
|
26
26
|
payments.released
|
|
27
27
|
payments.refunded
|
|
28
|
+
payments.authorization_expiring
|
|
28
29
|
payments.expired
|
|
29
30
|
payments.failed
|
|
30
31
|
payments.disputed
|
|
@@ -45,7 +46,7 @@ module Rail0
|
|
|
45
46
|
# @param active [Boolean, nil] Filter by active flag.
|
|
46
47
|
# @param circuit_state [String, nil] Filter by circuit state ("closed" or "open").
|
|
47
48
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
48
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
49
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
49
50
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
50
51
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
51
52
|
def list(topic: nil, active: nil, circuit_state: nil, sort: nil, page: nil, per_page: nil)
|
|
@@ -119,22 +120,50 @@ module Rail0
|
|
|
119
120
|
|
|
120
121
|
# List delivery attempts for a webhook.
|
|
121
122
|
# @param id [String] Webhook UUID.
|
|
122
|
-
# @param status [String, nil] Filter by delivery status
|
|
123
|
+
# @param status [String, nil] Filter by delivery status: "delivered" or "failed" only
|
|
124
|
+
# (anything else, "pending" included, is a 400).
|
|
123
125
|
# @param topic [String, nil] Filter by event topic.
|
|
124
|
-
# @param payment_id [String, nil] Filter by the payment the delivery is for
|
|
126
|
+
# @param payment_id [String, nil] Filter by the payment the delivery is for: its UUID
|
|
127
|
+
# or its rail0_id (0x…). One that names no payment matches nothing rather than erroring.
|
|
128
|
+
# @param response_code [String, Integer, nil] Filter by the subscriber's exact HTTP
|
|
129
|
+
# response code (e.g. 500) — `status: "failed"` finds the failures, this says which.
|
|
125
130
|
# @param since [String, nil] Only deliveries at/after this ISO-8601 time.
|
|
126
131
|
# @param until_time [String, nil] Only deliveries at/before this ISO-8601 time (query key: "until").
|
|
127
132
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
128
|
-
# @param page [Integer, nil] Page number (1-based).
|
|
133
|
+
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
129
134
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
130
135
|
# @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
|
-
|
|
136
|
+
def event_callbacks(id, status: nil, topic: nil, payment_id: nil, response_code: nil,
|
|
137
|
+
since: nil, until_time: nil, sort: nil, page: nil, per_page: nil)
|
|
138
|
+
query = build_query(status: status, topic: topic, payment_id: payment_id,
|
|
139
|
+
response_code: response_code, since: since, until: until_time,
|
|
140
|
+
sort: sort, page: page, per_page: per_page)
|
|
135
141
|
http.get_list("/webhooks/#{id}/event_callbacks#{query}")
|
|
136
142
|
end
|
|
137
143
|
|
|
144
|
+
# Re-deliver one recorded delivery's exact payload
|
|
145
|
+
# (POST /webhooks/:id/event_callbacks/:callback_id/redeliver).
|
|
146
|
+
#
|
|
147
|
+
# The recovery lever for events lost while the circuit breaker was open: the
|
|
148
|
+
# gateway stores each delivery's payload and replays THAT payload verbatim — the
|
|
149
|
+
# same embedded event id, so a receiver that already processed it deduplicates —
|
|
150
|
+
# under a fresh timestamped signature.
|
|
151
|
+
#
|
|
152
|
+
# Delivery is ASYNC (the standard dispatcher: same SSRF checks, retries and circuit
|
|
153
|
+
# accounting), and the dispatcher drops a delivery for a webhook that is not active.
|
|
154
|
+
# So re-activate the webhook FIRST — {enable} if it was disabled, {reset_circuit} if
|
|
155
|
+
# the breaker opened — or the 202 below is answered and the replay silently dropped.
|
|
156
|
+
#
|
|
157
|
+
# A callback id that is malformed, unknown, belongs to another webhook, or was
|
|
158
|
+
# recorded before payloads were stored all answer 404.
|
|
159
|
+
#
|
|
160
|
+
# @param id [String] Webhook UUID.
|
|
161
|
+
# @param callback_id [String] The event callback's UUID (from {event_callbacks}).
|
|
162
|
+
# @return [Hash] `{ status: "queued" }` (HTTP 202).
|
|
163
|
+
def redeliver(id, callback_id)
|
|
164
|
+
http.post("/webhooks/#{id}/event_callbacks/#{callback_id}/redeliver", {})
|
|
165
|
+
end
|
|
166
|
+
|
|
138
167
|
# Delete a webhook. Returns HTTP 204.
|
|
139
168
|
# @param id [String] Webhook UUID.
|
|
140
169
|
# @return [nil]
|
data/lib/rail0/types.rb
CHANGED
|
@@ -133,6 +133,22 @@ module Rail0
|
|
|
133
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
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
135
|
:contract, # ChainContract
|
|
136
|
+
:settlement, # BlockchainSettlement
|
|
137
|
+
keyword_init: true
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# How long an operation on this chain has taken to settle ON THIS GATEWAY: percentiles of broadcast
|
|
141
|
+
# (`submitted_at`) to confirmation (`confirmed_at`) over the confirmed transactions of the trailing
|
|
142
|
+
# `window_days`. End-to-end — the chain's finality lag plus the gateway/indexer pipeline — so it is
|
|
143
|
+
# what a client actually waits for, not the chain's finality. A measurement, not a guarantee: use it
|
|
144
|
+
# as a wait-deadline hint (e.g. a small multiple of `p90_seconds`) and keep a fixed fallback for
|
|
145
|
+
# when it is null. Deliberately a long-window capacity figure, not a live alarm (#289). Always
|
|
146
|
+
# present; the percentiles are null when `sample_size` is below the gateway's minimum (20).
|
|
147
|
+
BlockchainSettlement = Struct.new(
|
|
148
|
+
:p50_seconds, # Integer — Median settlement time, whole seconds rounded up. Null below the minimum sample.
|
|
149
|
+
:p90_seconds, # Integer — 90th-percentile settlement time, whole seconds rounded up — the figure to size a deadline from. Null below the minimum sample.
|
|
150
|
+
:sample_size, # Integer — Confirmed transactions measured in the window (0 when none).
|
|
151
|
+
:window_days, # Integer — The trailing window the figures cover, in days.
|
|
136
152
|
keyword_init: true
|
|
137
153
|
)
|
|
138
154
|
|
|
@@ -188,7 +204,8 @@ module Rail0
|
|
|
188
204
|
keyword_init: true
|
|
189
205
|
)
|
|
190
206
|
|
|
191
|
-
# Base persisted payment fields, plus the `chain_id` of the payment's deployment
|
|
207
|
+
# Base persisted payment fields, plus the `chain_id` of the payment's deployment, its token's
|
|
208
|
+
# `decimals`, and whether a transaction is `in_flight`.
|
|
192
209
|
Payment = Struct.new(
|
|
193
210
|
:id, # String
|
|
194
211
|
:contract_id, # String
|
|
@@ -203,11 +220,13 @@ module Rail0
|
|
|
203
220
|
:payer, # String
|
|
204
221
|
:payee, # String
|
|
205
222
|
:token, # String
|
|
223
|
+
:decimals, # Decimals of the payment's token: divide `amount`, `capturable_amount` and `refundable_amount` (base units) by 10^decimals to render them, with no GET /tokens join on `token` + `chain_id`. Resolved from the gateway's in-memory chain catalogue, retired tokens and archived contract versions included. Null only when the gateway cannot resolve the token (a contract it has not loaded, a token deleted outright) — fall back to GET /tokens then.
|
|
206
224
|
:authorization_expiry, # Integer
|
|
207
225
|
:refund_expiry, # Integer
|
|
208
226
|
: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
227
|
: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.
|
|
210
228
|
:disputed, # Boolean — True while an open dispute exists.
|
|
229
|
+
:in_flight, # Boolean — True while one of the payment's transactions is on its way to the chain and not yet settled: status `submitting`, `submitted`, or `pending` holding its signed transaction (the `redrivable` rows — signed and handed over, broadcast not yet sent). A `pending` row still awaiting its signature (an unfinished prepare) does not count, nor do `confirmed`/`failed`. While true the mirrored balances are about to move, so a client should disable further actions on the payment. Carried by list rows too, which embed no transactions; computed for a whole page in one query.
|
|
211
230
|
: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.
|
|
212
231
|
:last_error_message, # Human-readable form of last_error_code.
|
|
213
232
|
:description,
|
|
@@ -324,6 +343,7 @@ module Rail0
|
|
|
324
343
|
:operation, # String — Same vocabulary as `Transaction.operation` — dispute rows go stale too.
|
|
325
344
|
:payment_id, # String — Protocol-level rail0_id.
|
|
326
345
|
:chain_id, # Integer
|
|
346
|
+
: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.
|
|
327
347
|
keyword_init: true
|
|
328
348
|
)
|
|
329
349
|
|
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.3.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.3.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
|