rail0-sdk 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f10297affa2fe993f9f4777ba2c6994610de8a6e2abe54d19b790cabfb09681
4
- data.tar.gz: 75ea2a7fdcc4b18cd8b4d62c689bb9a2b0bcb2030793e24a12224ee205275490
3
+ metadata.gz: 60c25c2843d57ee49df3b17267e56a612f56701d61e48430786b1c6435987eb9
4
+ data.tar.gz: d8ef2d01d59b1a775f116753608bba6ab90d0d868a360040c0bc8fdc64f64de0
5
5
  SHA512:
6
- metadata.gz: 211b69279ade20a322f29061a5723ce477e42d6ff50f9f2b2b192569117ffcbc25f35ba32dbe2c56c88ac04501669856cc3f9613ad12a8a2433ba76cf15e4a62
7
- data.tar.gz: 1e856d8c347d5793915bdbbceec990cc0bcc12f9e85977e293bc268fee9c4985d19cf114559199a9c88f58850b5e6f83e4e94d5d7e82f69f38036592d284be1e
6
+ metadata.gz: e7e6441337f7daf05f4e55ba22d94a380755f5775fd203b0699e8f16029fc493c88e9863a81e2d1559e71c383db7b79330503cd724c2219dd0a1b169badfac81
7
+ data.tar.gz: 943a28ce8740139e30e4966ce1021b9a85271789418a2274d505a6457733a09e8cc84c3c8f435ff34962dec3ed7e5beded3fc0f7cec131de626d8e5d6535fc5d
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` | anyone | Return uncaptured escrow to the payer |
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) are
160
- **payee-only**, while `release` and the prepare steps accept either participant,
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
@@ -227,10 +239,15 @@ logging — it says exactly which sessions died, which `revoked_all: true` canno
227
239
  ```ruby
228
240
  client.chains.list # GET /blockchains (each chain's :contract is the active RAIL0 deployment)
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
+ `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
+
234
251
  ## Health
235
252
 
236
253
  ```ruby
@@ -241,6 +258,9 @@ client.health.get # GET /health → { status:, api_version:, contract_version:
241
258
 
242
259
  Paginated calls return `{ data:, meta: }`. `meta` is `{ page:, per_page:, total:, total_pages:, links: }`.
243
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
+
244
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.
245
265
 
246
266
  ```ruby
@@ -278,6 +298,8 @@ account id first. `id_or_address` accepts the wallet UUID or its 0x address.
278
298
  ```ruby
279
299
  client.wallets.list(account_id, chain_id: 84532, active: true)
280
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)
281
303
 
282
304
  client.wallets.get(account_id, id_or_address)
283
305
  client.wallets.update(account_id, id_or_address, label: "Renamed", active: false)
@@ -299,13 +321,14 @@ client.wallets.create(account_id, address: "0x…", **proof, label: "Treasury")
299
321
  ```
300
322
 
301
323
  The proof is **purpose-bound**, and a login proof will not do — the gateway pins
302
- each endpoint to one statement and refuses the other with 422
324
+ each endpoint to one statement and refuses the others with 422
303
325
  `siwe_purpose_mismatch`:
304
326
 
305
327
  | Endpoint | Statement | Constant |
306
328
  |---|---|---|
307
329
  | `POST /auth` | `Sign in to RAIL0` | `Rail0::Resources::Auth::LOGIN_STATEMENT` |
308
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` |
309
332
 
310
333
  That is a security boundary rather than a label: a login signature is handed out
311
334
  on every sign-in, so a wallet-link endpoint that accepted one would let anyone
@@ -326,6 +349,11 @@ re-enables it and answers 200 instead of creating a second row. The id passed to
326
349
  the other three is the **token's** UUID (`token_id` on the holding), not an id of the
327
350
  holding row and not the token address — the gateway looks the holding up by (wallet, token).
328
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.
356
+
329
357
  ## Payments
330
358
 
331
359
  ```ruby
@@ -334,7 +362,9 @@ client.payments.create(params, idempotency_key: nil) # or keyword fields
334
362
  # "idempotency_key_reused" (422) instead of returning the first payment.
335
363
  client.payments.get(id)
336
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
337
366
  client.payments.transactions(id, operation: "capture")
367
+ # `operation` on both takes Payments::TRANSACTION_OPERATIONS — dispute and close_dispute included.
338
368
  client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast
339
369
  # Offer `redrive` on the row's `redrivable` flag — the same predicate the gateway guards
340
370
  # the route with — and not on `status == "pending"`: a pending row holding no signed
@@ -377,6 +407,8 @@ client.payments.refund(rail0_id, { signed_transaction: raw })
377
407
 
378
408
  ### Disputes (payer-driven)
379
409
 
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
+
380
412
  Disputes are signed on-chain by the payer and follow the same prepare → submit
381
413
  pattern. Like the rest of `/payments` they need a session, but the payer's
382
414
  account-less SIWE login is enough:
@@ -424,10 +456,18 @@ client.webhooks.enable(id)
424
456
  client.webhooks.disable(id)
425
457
  client.webhooks.rotate_secret(id) # returns a fresh shared_secret
426
458
  client.webhooks.reset_circuit(id)
427
- client.webhooks.event_callbacks(id, status: "failed")
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
428
462
  client.webhooks.delete(id) # 204
429
463
  ```
430
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
+
431
471
  ### Verifying a delivery
432
472
 
433
473
  Every delivery carries `X-Rail0-Topic`, `X-Rail0-Timestamp` and
@@ -469,9 +509,19 @@ digest with it. Comparison is constant-time.
469
509
  client.accounts.get(account_id)
470
510
  # => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test",
471
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")
472
516
  ```
473
517
 
474
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.
475
525
  The account's wallets are a collection under the same path (`client.wallets`), and
476
526
  buyer-facing discovery is `client.payment_methods`.
477
527
 
@@ -542,9 +592,17 @@ client.analytics.breakdown(by: "operation")
542
592
  # => [{ key: "capture", orders: 1, transactions: 2 }, …]
543
593
  ```
544
594
 
545
- All three take the same filters — `mode`, `status`, `token`, `chain_id`, `from`, `to` — so
546
- the same question can be asked at three resolutions: one total, a series over time, a split
547
- by dimension. `from`/`to` filter the payment's CREATION date, which matters when reading
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
548
606
  gas: a payment created in one period and captured in the next books its capture gas in the
549
607
  first.
550
608
 
@@ -634,8 +692,7 @@ end
634
692
  ```
635
693
 
636
694
  **`error` is the only field to branch on.** It is the specific condition, read from the
637
- gateway's `code` and falling back to the older `error` sub-code and then to `status` (the
638
- wider family), so an older gateway still yields the most specific value it sent.
695
+ gateway's `code`.
639
696
 
640
697
  `title` and `detail` come from the gateway's error catalogue, so the same condition always
641
698
  reads the same way whichever endpoint surfaced it; `detail` is written to be shown to a
@@ -674,8 +731,8 @@ Rail0::Client.new(
674
731
  ### Rate limits
675
732
 
676
733
  The gateway throttles two surfaces independently: the public, unauthenticated one **per
677
- IP** (100 requests / 60s by default — SIWE nonce + verify, `/payment_methods`, the
678
- 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
679
736
  JWT's subject (300 / 60s). Over budget it answers **429** with `code: "rate_limited"` and
680
737
  a `Retry-After`.
681
738
 
@@ -691,8 +748,8 @@ rescue Rail0::ApiError => e
691
748
  end
692
749
  ```
693
750
 
694
- Note what the number means: the gateway sends **the whole throttle period**, not the time
695
- left in the current window, so it is an upper bound on the wait rather than a measurement.
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.
696
753
 
697
754
  `retry_on_429: true` makes the SDK do that waiting for you — Retry-After, clamped to
698
755
  `retry_after_cap`, plus a little jitter (see `Rail0::Backoff`; callers sharing one session
@@ -737,7 +794,7 @@ lib/rail0/
737
794
  wallets.rb account-scoped wallet management (JWT)
738
795
  payments.rb payment lifecycle + disputes
739
796
  disputes.rb account-level dispute listing (JWT)
740
- accounts.rb the caller's own account profile (JWT)
797
+ accounts.rb the caller's own account profile, read and update (JWT)
741
798
  webhooks.rb webhook subscription management (JWT)
742
799
  analytics.rb account-scoped payment analytics (JWT)
743
800
  query.rb shared query-string helper
@@ -765,6 +822,26 @@ uses them everywhere, table-aligned hashes because some literals are tables, and
765
822
  (`retry_on_429`, `eip712`, `secp256k1`) that each spell themselves their own way. A linter
766
823
  that argues with the codebase teaches people to ignore it.
767
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
+
768
845
  ## License
769
846
 
770
847
  [MIT](LICENSE)
@@ -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
- # Note it is the WHOLE window the gateway throttles over, not the time left in it
25
- # — the limiter sends its period verbatim — so it is an upper bound on the wait,
26
- # not a measurement. Rail0::Backoff clamps it for that reason.
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 WHOLE period as Retry-After
28
- # (rack_attack.rb: `headers["retry-after"] = match_data[:period].to_s`), not the time
29
- # remaining in the window — so hitting the limit one second in is told to wait the
30
- # full 60. Capping bounds both that over-wait and a hostile or misconfigured value
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
 
@@ -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, not_your_account)
67
- # because they need different fixes. This entry kept describing one of them long
68
- # after the split — and the rule it named no longer exists in the gateway at all.
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
 
@@ -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 whole throttle period as Retry-After rather than the time left in it,
21
- # so this bounds both the over-wait and any hostile value from in between.
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
- # One method, and that is the whole surface by design: the gateway guards
8
- # `/accounts/:account_id` with an ownership check — a JWT whose account matches the
9
- # path — so a caller can only ever read its OWN account. There is no endpoint for
10
- # reading another merchant's, and an id that is not an account answers 404 exactly as
11
- # another account's id does, so the pair cannot be used to learn whether an account
12
- # exists.
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 holder is this endpoint's only possible caller: it is a
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 status)" if by.nil? || by.to_s.empty?
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,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 {OPERATIONS}).
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. +from+ overrides the
243
- # submitter address (defaults to the payer). Returns uncaptured escrow to the
244
- # payer.
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 active tokens, optionally filtered by chain and/or symbol.
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
- # @return [Array<Hash>] chain_id, symbol, address, decimals
22
- def list(chain_id: nil, symbol: nil)
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, sort: sort, page: page, per_page: per_page)
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")
@@ -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 ("pending", "delivered", "failed").
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, since: nil,
132
- until_time: nil, sort: nil, page: nil, per_page: nil)
133
- query = build_query(status: status, topic: topic, payment_id: payment_id, since: since,
134
- until: until_time, sort: sort, page: page, per_page: per_page)
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/types.rb CHANGED
@@ -324,6 +324,7 @@ module Rail0
324
324
  :operation, # String — Same vocabulary as `Transaction.operation` — dispute rows go stale too.
325
325
  :payment_id, # String — Protocol-level rail0_id.
326
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.
327
328
  keyword_init: true
328
329
  )
329
330
 
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.1.0"
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.1.0
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-24 00:00:00.000000000 Z
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.11
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