rail0-sdk 1.4.0 → 1.5.1

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: 5432df0776e08a77c56678e4e977d87c81ddf2be6176fef59a95051e287a6ce9
4
- data.tar.gz: 7747a265cc8d84f243a09a8b1ab66e3cba545a3817554bc321dc5aa9eaed34be
3
+ metadata.gz: 106a0758b1da0b740e9c6e78fb83ffa9cdbfb53d562c2784b67d544aba2d58c3
4
+ data.tar.gz: '09690a456b1bb50bd573944a3ef9b7f980748bb32eb76ce7c17312a70914cf7a'
5
5
  SHA512:
6
- metadata.gz: 440edc898e25a83ad3c76615777b4074f7daf003904db75129df01c4fcb223aebad7e5a1e425212207842aa334cdab1bccfe79a0023fcf08578d5bc09da8462a
7
- data.tar.gz: 7482c21a09309b6dabe1f26c4b19b29890d8b8dfc679d4fc1b15d8b1de76a39b2f5998414e2e28ab3ee840edc2e31e7531ebe3b215c0a308f92068d0754ac46a
6
+ metadata.gz: b0e50c24f6276cb4ba23c812dcc8bc7f3b58cf92715855518bb0a76725c3393959487eb29e59794312bd0ccaab49148b436d5c1873cdeb5029db32f5f5a15ef1
7
+ data.tar.gz: 59463fad22c007aea05412ade646a21c051db20a85f886bc62897442c810f3c1d3d0f295f03c252c691661be958e8d3f729e7aacbdc0af4a7613a5d13e626483
data/README.md CHANGED
@@ -129,8 +129,8 @@ client.payments.submit_by_hash(rail0_id, "capture", { transaction_hash: "0x…"
129
129
  | `void_prepare` + `void` | payee | Cancel the hold, return funds to the payer (only before any capture) |
130
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
- | `dispute_prepare` + `dispute` | payer | Open a dispute (signal-only) |
133
- | `close_dispute_prepare` + `close_dispute` | payer | Close an open dispute |
132
+ | `dispute_prepare(id, reason: nil)` + `dispute` | payer | Open a dispute (signal-only; reason optional) |
133
+ | `close_dispute_prepare(id, reason: nil)` + `close_dispute` | payer | Close an open dispute (reason optional) |
134
134
  | `dispute_submit_by_hash` / `close_dispute_submit_by_hash` | payer | Report a dispute tx the wallet already broadcast |
135
135
 
136
136
  `release` is the one merchant-side operation either participant may send: the contract
@@ -324,6 +324,21 @@ client.wallets.delete(account_id, id_or_address) # 204
324
324
  client.wallets.balances(account_id, id_or_address, chain_id: 84532) # live on-chain balances
325
325
  ```
326
326
 
327
+ `delete` only deactivates (the same as `update(active: false)`; payments name the
328
+ address, so the row stays). Either way the account's **only active wallet** is
329
+ refused with 422 `last_active_wallet` — login resolves the account from a wallet,
330
+ so an account with none could neither act nor add one back. Another wallet must
331
+ be active first.
332
+
333
+ **A session is bound to the wallet it signed in with.** Reads (`list`, `get`,
334
+ `balances`) are account-wide, but `update`, `delete` and the token-holding methods
335
+ act only on the session's own wallet: another wallet of the same account is
336
+ refused with 403 `not_your_wallet`. An operator session is exempt.
337
+
338
+ > **Adding a wallet is currently unavailable.** The gateway answers every
339
+ > `wallets.create` with 403 `wallet_add_unavailable`. It returns with the merchant
340
+ > wallet-management section, which keeps the request shape and the proof below.
341
+
327
342
  **Registering a wallet needs a SIWE proof-of-ownership of the address being
328
343
  added**, not merely the session JWT. `auth.prove_address` runs that handshake and
329
344
  returns the `message` + `signature` to splat into `create`. Sign with **the added
@@ -366,10 +381,12 @@ re-enables it and answers 200 instead of creating a second row. The id passed to
366
381
  the other three is the **token's** UUID (`token_id` on the holding), not an id of the
367
382
  holding row and not the token address — the gateway looks the holding up by (wallet, token).
368
383
 
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.
384
+ While the wallet accepts other tokens, its active **default** holding cannot be disabled or
385
+ removed — that would leave it with no preferred payment method — and the gateway answers
386
+ 422 `default_payment_method`. Make another token the default first
387
+ (`add_token(..., default: true)` demotes the old one in the same transaction), then disable
388
+ or remove it. The **last** active holding can be disabled even as the default: the wallet
389
+ then accepts nothing, and the next holding enabled becomes the default.
373
390
 
374
391
  ## Payments
375
392
 
@@ -385,10 +402,16 @@ client.payments.update(id, description: nil) # nil (or "") clears it
385
402
  client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at")
386
403
  client.payments.list(status: %w[authorized expired]) # any of several states → ?status=authorized,expired
387
404
  client.payments.list(operation: "refund") # payments with at least one refund transaction
405
+ # The operator's cross-account list (admin only, 403 otherwise): every payment on the
406
+ # gateway, same filters, plus three that exist only there — the payee account, whether a
407
+ # last_error_code is set, and authorized payments whose hold lapses within N SECONDS.
408
+ client.payments.list(scope: "all", has_error: true)
409
+ client.payments.list(scope: "all", expiring_within: 24 * 3600)
410
+ client.payments.list(scope: "all", account_id: "019f…")
388
411
  client.payments.transactions(id, operation: "capture")
389
412
  client.payments.transactions(id, status: %w[submitting submitted]) # on its way to the chain
390
413
  # `operation` on both takes Payments::TRANSACTION_OPERATIONS — dispute and close_dispute included.
391
- client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast
414
+ client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast (payee, or an operator)
392
415
  # Offer `redrive` on the row's `redrivable` flag — the same predicate the gateway guards
393
416
  # the route with — and not on `status == "pending"`: a pending row holding no signed
394
417
  # transaction is not redrivable, and there the next step is submitting the signature, not
@@ -456,14 +479,56 @@ pattern. Like the rest of `/payments` they need a session, but the payer's
456
479
  account-less SIWE login is enough:
457
480
 
458
481
  ```ruby
459
- prep = client.payments.dispute_prepare(rail0_id, reason: "0x…") # reason optional
482
+ prep = client.payments.dispute_prepare(rail0_id, reason: "not_received") # reason optional
460
483
  raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
461
484
  client.payments.dispute(rail0_id, { signed_transaction: raw })
462
485
  # … later …
463
- client.payments.close_dispute_prepare(rail0_id)
486
+ prep = client.payments.close_dispute_prepare(rail0_id, reason: "withdrawn") # or omit reason:
487
+ raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
464
488
  client.payments.close_dispute(rail0_id, { signed_transaction: raw })
465
489
  ```
466
490
 
491
+ #### Dispute reasons
492
+
493
+ `reason:` is **optional** on both prepares. Omit it (or pass `nil` or a blank string) and
494
+ the SDK leaves `reason` out of the request: the dispute is opened/closed with no reason,
495
+ the calldata carries the zero bytes32 (the gateway also accepts the zero word explicitly),
496
+ and it reads back as `No reason given`. When given, it is a code from the gateway's
497
+ dispute-reason dictionary (a String or a Symbol) or exactly that code's bytes32,
498
+ `keccak256("rail0.dispute.<code>")`, which is what the contract stores. Anything else — any
499
+ other non-zero bytes32, a code from the other list, or the system-only `full_refund` — is
500
+ refused 422 `unknown_dispute_reason`.
501
+
502
+ | Kind | Codes |
503
+ |------|-------|
504
+ | Open (`dispute_prepare`) | `not_received`, `not_as_described`, `damaged_or_defective`, `duplicate`, `incorrect_amount`, `cancelled`, `refund_not_received`, `unauthorized`, `other` |
505
+ | Close (`close_dispute_prepare`) | `resolved_with_merchant`, `withdrawn`, `item_received`, `other` |
506
+ | System (never sent; read back only) | `full_refund` — a full refund auto-closed the dispute |
507
+
508
+ `Rail0::DisputeReasons` carries the dictionary as constants, generated from the gateway's
509
+ `DisputeOpenReason` / `DisputeCloseReason` / `DisputeSystemCloseReason` schemas:
510
+
511
+ ```ruby
512
+ Rail0::DisputeReasons::OPEN # [Reason(code:, description:, bytes32:), …]
513
+ Rail0::DisputeReasons.codes(:close) # ["resolved_with_merchant", "withdrawn", …]
514
+ Rail0::DisputeReasons.find(:open, "not_received") # Reason, or nil (also by bytes32)
515
+ Rail0::DisputeReasons.valid?(:close, "full_refund") # false: system-only
516
+ Rail0::DisputeReasons.valid?(:open, nil) # true: no reason is accepted
517
+ Rail0::DisputeReasons.none?(Rail0::DisputeReasons::ZERO_BYTES32) # true: nil, blank or zero
518
+ Rail0::DisputeReasons.description_for(:open, "0x00…00") # "No reason given" (the zero word)
519
+ Rail0::DisputeReasons.description_for(:open, "0x12…34") # "Unrecognised reason"
520
+ ```
521
+
522
+ Lookups take a kind because the lists overlap: `other` is both an open and a close reason.
523
+
524
+ A dispute read back (`payments.disputes`, `disputes.list`) carries the decoded reason next
525
+ to the raw bytes32: `reason_code` / `reason_description`, and `close_reason_code` /
526
+ `close_reason_description` once closed (`close_reason_code` may be `full_refund`). A
527
+ dispute with no reason (the zero bytes32) has `reason_code: nil` and
528
+ `reason_description: "No reason given"`; a non-zero bytes32 outside the dictionary — a
529
+ dispute opened on the contract directly — is kept verbatim in `reason`, with
530
+ `reason_code: nil` and `reason_description: "Unrecognised reason"`.
531
+
467
532
  ### Generic prepare/submit
468
533
 
469
534
  Every wrapper delegates to the generic form, useful for dynamic operations:
@@ -560,6 +625,25 @@ window is what bounds a replay of a captured delivery. Pass the body exactly as
560
625
  received: re-serialising a parsed hash changes key order and whitespace, and the
561
626
  digest with it. Comparison is constant-time.
562
627
 
628
+ **Rotating the secret without a gap.** After `rotate_secret` the gateway keeps the
629
+ replaced key for a grace window (`WEBHOOK_SECRET_GRACE`, one hour by default): inside it
630
+ every delivery carries `X-Rail0-Signature` under the new key **and**
631
+ `X-Rail0-Signature-Previous` under the old one, so an endpoint verifies with whichever key
632
+ it has deployed and nothing in flight at rotation becomes unverifiable. Deploy the new
633
+ secret at your pace within the window; an endpoint that still holds the old one verifies
634
+ the previous header instead — the old key signs only, and is never honoured past the
635
+ window. A second rotation inside it drops the oldest key.
636
+
637
+ ```ruby
638
+ verify = ->(signature) do
639
+ Rail0::WebhookSignature.verify(body: raw, signature: signature,
640
+ timestamp: request.headers["X-Rail0-Timestamp"],
641
+ secret: ENV.fetch("RAIL0_WEBHOOK_SECRET"))
642
+ end
643
+ ok = verify.call(request.headers["X-Rail0-Signature"]) ||
644
+ verify.call(request.headers["X-Rail0-Signature-Previous"])
645
+ ```
646
+
563
647
  ## Account (JWT)
564
648
 
565
649
  ```ruby
@@ -568,21 +652,28 @@ digest with it. Comparison is constant-time.
568
652
  # merchant's account — and an id that is not an account answers 404 exactly as
569
653
  # another account's id does, so the pair says nothing about existence.
570
654
  client.accounts.get(account_id)
571
- # => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test",
655
+ # => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test", priority_tip: true,
572
656
  # created_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-20T00:00:00Z" }
573
657
 
574
658
  # Update the same profile — only the fields passed are sent.
575
659
  client.accounts.update(account_id, email: "ops@merchant.example")
576
660
  client.accounts.update(account_id, name: "Merchant Ltd", email: "ops@merchant.example")
661
+ client.accounts.update(account_id, priority_tip: false) # minimum tip on the account's own transactions
577
662
  ```
578
663
 
579
664
  `email` is in the response because the holder is this endpoint's only possible caller.
580
665
 
581
- `update` needs at least one of `name`/`email` (it raises `ArgumentError` before sending an
582
- empty PATCH, which the gateway would answer with 400). Both are unique across accounts, so
583
- a value another account holds answers **409**. It is a write on the account, so a
584
- deactivated wallet (403 `wallet_deactivated`) or account (403 `account_deactivated`) cannot
585
- make it. The gateway's `active` field is operator-only and not exposed here.
666
+ `update` needs at least one of `name`/`email`/`priority_tip` (it raises `ArgumentError`
667
+ before sending an empty PATCH, which the gateway would answer with 400). Name and email are
668
+ unique across accounts, so a value another account holds answers **409**. It is a write on
669
+ the account, so a deactivated wallet (403 `wallet_deactivated`) or account (403
670
+ `account_deactivated`) cannot make it. The gateway's `active` field is operator-only and not
671
+ exposed here.
672
+
673
+ **`priority_tip`** is the fee setting of the account's **own** transactions (authorize,
674
+ capture, void, release, refund): `true`, the default, tips the network's suggested priority
675
+ fee; `false` uses the chain's minimum tip (0 on most chains, 25 gwei on Polygon PoS/Amoy) —
676
+ cheaper, slower to confirm under load. The buyer's dispute/close-dispute never read it.
586
677
  The account's wallets are a collection under the same path (`client.wallets`), and
587
678
  buyer-facing discovery is `client.payment_methods`.
588
679
 
@@ -842,7 +933,7 @@ broadcast may already be in flight.
842
933
  ## Project structure
843
934
 
844
935
  ```text
845
- gen/generate.rb regenerates lib/rail0/types.rb from the gateway OpenAPI schema
936
+ gen/generate.rb regenerates lib/rail0/types.rb and lib/rail0/dispute_reasons.rb from the gateway OpenAPI schema
846
937
 
847
938
  lib/rail0/
848
939
  client.rb Rail0::Client — entry point
@@ -855,6 +946,7 @@ lib/rail0/
855
946
  webhook_signature.rb Rail0::WebhookSignature — delivery verification
856
947
  signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
857
948
  stablecoins.rb stablecoin address registry
949
+ dispute_reasons.rb generated Rail0::DisputeReasons — the dispute-reason dictionary
858
950
  types.rb generated Struct docs of the gateway schema (reference only)
859
951
  version.rb Rail0::VERSION
860
952
  resources/
@@ -882,7 +974,7 @@ bundle exec rubocop # style only
882
974
  bundle exec rubocop -a # and fix what is safely fixable
883
975
  bundle exec rspec # specs only
884
976
 
885
- # Regenerate lib/rail0/types.rb after a gateway schema change:
977
+ # Regenerate lib/rail0/types.rb and lib/rail0/dispute_reasons.rb after a gateway schema change:
886
978
  # defaults to ../rail0-gateway/docs/openapi.json, or set RAIL0_SCHEMA_PATH.
887
979
  ruby gen/generate.rb
888
980
  ```
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Code generated by gen/generate.rb — DO NOT EDIT.
4
+ # Re-run `ruby gen/generate.rb` after updating the gateway OpenAPI schema.
5
+ #
6
+ # Source: rail0-gateway/docs/openapi.json — components.schemas.DisputeOpenReason,
7
+ # DisputeCloseReason and DisputeSystemCloseReason (enum, x-enum-descriptions and
8
+ # x-enum-bytes32, aligned by index).
9
+
10
+ module Rail0
11
+ # The gateway's dispute-reason dictionary. On-chain a dispute reason is a bytes32,
12
+ # keccak256("rail0.dispute.<code>"); the dispute prepares take either the code or
13
+ # exactly that bytes32, and refuse anything else 422 unknown_dispute_reason. A
14
+ # dispute read back carries `reason_code`/`reason_description` (and the close_
15
+ # counterparts) already decoded by the gateway; these constants are for building a
16
+ # picker, validating input before a round trip, or decoding a raw bytes32.
17
+ module DisputeReasons
18
+ # One dictionary entry: the code the API speaks, its English description and the
19
+ # bytes32 the contract stores.
20
+ Reason = Struct.new(:code, :description, :bytes32, keyword_init: true)
21
+
22
+ # What the gateway reports as the description of a bytes32 outside the dictionary.
23
+ UNRECOGNISED_DESCRIPTION = "Unrecognised reason"
24
+
25
+ # What the gateway reports as the description of a dispute opened or closed with no
26
+ # reason — the zero bytes32 on-chain (rail0-gateway#382).
27
+ NO_REASON_DESCRIPTION = "No reason given"
28
+
29
+ # The all-zero bytes32: what the contract stores when no reason is given.
30
+ ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000"
31
+
32
+ # Reasons a payer may open a dispute with (Payments#dispute_prepare).
33
+ OPEN = [
34
+ Reason.new(
35
+ code: "not_received",
36
+ description: "Goods or service not received",
37
+ bytes32: "0x8b8e446d6f906b670526e73bf5502f1db64b2150c42360febf9dec0d075a28f9"
38
+ ).freeze,
39
+ Reason.new(
40
+ code: "not_as_described",
41
+ description: "Not as described, or not what was ordered",
42
+ bytes32: "0xd7ac93eb8a6f78f2398d7131c7d34ac3cc667872ccd86f9acb2acf0b1c1ce68d"
43
+ ).freeze,
44
+ Reason.new(
45
+ code: "damaged_or_defective",
46
+ description: "Arrived damaged or defective",
47
+ bytes32: "0xedaf2417cca94f10d7611552c2c3a9a9fcf7aeecb58bc213c9290ea0c7e58f3a"
48
+ ).freeze,
49
+ Reason.new(
50
+ code: "duplicate",
51
+ description: "Charged twice for the same purchase",
52
+ bytes32: "0x21d2ae9aec01aa72dfe968be0b24b311d8f9b26491ad3f5122ee6bce9c0da16f"
53
+ ).freeze,
54
+ Reason.new(
55
+ code: "incorrect_amount",
56
+ description: "Amount differs from what was agreed",
57
+ bytes32: "0x668fab4989c1fca438fa96c57778b3a0f1a46766bfbce495d8704fd1b0aa6ab6"
58
+ ).freeze,
59
+ Reason.new(
60
+ code: "cancelled",
61
+ description: "Order or subscription cancelled but still charged",
62
+ bytes32: "0xf0e563be58fdee0107cd6907d4040a859af4753a93cafa838e374a1b6c6880d4"
63
+ ).freeze,
64
+ Reason.new(
65
+ code: "refund_not_received",
66
+ description: "A refund promised by the merchant never arrived",
67
+ bytes32: "0x654f13ad1cef4e3e1c74161e98f531e730883aa4d14855ee35546f5485de13ce"
68
+ ).freeze,
69
+ Reason.new(
70
+ code: "unauthorized",
71
+ description: "Payment not recognised (e.g. a compromised wallet)",
72
+ bytes32: "0x5a8e87ece389352e3ad16b9cc84ecb7348115546c041e079f18011d158934079"
73
+ ).freeze,
74
+ Reason.new(
75
+ code: "other",
76
+ description: "Other reason, detailed off-chain",
77
+ bytes32: "0x0c507c226b608fa1f2920608f9c8baf135357fe717c6cd9ac355ae6451b63667"
78
+ ).freeze
79
+ ].freeze
80
+
81
+ # Reasons a payer may close a dispute with (Payments#close_dispute_prepare).
82
+ CLOSE = [
83
+ Reason.new(
84
+ code: "resolved_with_merchant",
85
+ description: "Resolved with the merchant off-chain",
86
+ bytes32: "0x37b092a939a58d27e17848d569e5abb446933c05d90edb8f2ad69caae8b9416a"
87
+ ).freeze,
88
+ Reason.new(
89
+ code: "withdrawn",
90
+ description: "Withdrawn by the buyer (a mistake, or changed their mind)",
91
+ bytes32: "0x304c3ad3e1c95131e0e666568cb55d269a5382b83f18dcd07137626b07de4d91"
92
+ ).freeze,
93
+ Reason.new(
94
+ code: "item_received",
95
+ description: "The disputed goods or service arrived after all",
96
+ bytes32: "0xafa7760b6eaeb3756917ec71a4add26b6982f123779bbefca4af237ed2a9cbb1"
97
+ ).freeze,
98
+ Reason.new(
99
+ code: "other",
100
+ description: "Other reason",
101
+ bytes32: "0x0c507c226b608fa1f2920608f9c8baf135357fe717c6cd9ac355ae6451b63667"
102
+ ).freeze
103
+ ].freeze
104
+
105
+ # Close reasons the protocol records on its own — read back on a dispute, never accepted from a caller.
106
+ SYSTEM = [
107
+ Reason.new(
108
+ code: "full_refund",
109
+ description: "Closed automatically by a full refund",
110
+ bytes32: "0x8d72b2d99f736f70b77d5c4eb77867aa31f7d7be526741b1ba241c1c663b0f6c"
111
+ ).freeze
112
+ ].freeze
113
+
114
+ # The lists by kind, for the lookups below.
115
+ KINDS = { open: OPEN, close: CLOSE, system: SYSTEM }.freeze
116
+
117
+ # The entries of one kind (:open, :close or :system).
118
+ # @param kind [Symbol, String]
119
+ # @return [Array<Reason>]
120
+ # @raise [ArgumentError] for any other kind.
121
+ def self.all(kind)
122
+ KINDS.fetch(kind.to_sym) { raise ArgumentError, "unknown dispute reason kind: #{kind.inspect}" }
123
+ end
124
+
125
+ # The codes of one kind, in dictionary order.
126
+ # @param kind [Symbol, String]
127
+ # @return [Array<String>]
128
+ def self.codes(kind)
129
+ all(kind).map(&:code)
130
+ end
131
+
132
+ # The entry of +kind+ that +value+ names — a code ("not_received" or :not_received)
133
+ # or exactly its bytes32 (0x-hex, either case) — or nil. Lookups are per kind
134
+ # because the lists overlap: "other" is both an open and a close reason, with one
135
+ # bytes32.
136
+ # @param kind [Symbol, String]
137
+ # @param value [String, Symbol, nil]
138
+ # @return [Reason, nil]
139
+ def self.find(kind, value)
140
+ return nil if value.nil?
141
+
142
+ needle = value.to_s
143
+ all(kind).find { |r| r.code == needle || r.bytes32.casecmp?(needle) }
144
+ end
145
+
146
+ # Whether +value+ means "no reason": nil, a blank string, or the zero bytes32. The
147
+ # reason is optional on both prepares, and the gateway records any of these as the
148
+ # zero bytes32.
149
+ # @param value [String, Symbol, nil]
150
+ # @return [Boolean]
151
+ def self.none?(value)
152
+ needle = value.to_s.strip
153
+ needle.empty? || needle.casecmp?(ZERO_BYTES32)
154
+ end
155
+
156
+ # Whether the dispute prepare of +kind+ (:open or :close) would accept +value+:
157
+ # no reason at all (see {.none?}), or a code / bytes32 from that list. :system is
158
+ # never accepted from a caller, so it always answers false.
159
+ # @param kind [Symbol, String]
160
+ # @param value [String, Symbol, nil]
161
+ # @return [Boolean]
162
+ def self.valid?(kind, value)
163
+ return false if kind.to_sym == :system
164
+
165
+ none?(value) || !find(kind, value).nil?
166
+ end
167
+
168
+ # The description the gateway would give +value+ as a +kind+ reason:
169
+ # {NO_REASON_DESCRIPTION} for no reason (see {.none?}), the entry's description
170
+ # when it is in that list, else {UNRECOGNISED_DESCRIPTION}.
171
+ # @param kind [Symbol, String]
172
+ # @param value [String, Symbol, nil]
173
+ # @return [String]
174
+ def self.description_for(kind, value)
175
+ return NO_REASON_DESCRIPTION if none?(value)
176
+
177
+ find(kind, value)&.description || UNRECOGNISED_DESCRIPTION
178
+ end
179
+ end
180
+ end
@@ -18,6 +18,7 @@ module Rail0
18
18
  "already_disputed" => "a dispute is already open — close it first",
19
19
  "not_disputed" => "there is no open dispute to close",
20
20
  "nothing_to_dispute" => "a dispute needs a merchant-held (refundable) balance",
21
+ "unknown_dispute_reason" => "reason must be a dispute-reason code (Rail0::DisputeReasons.codes(:open) / codes(:close)) or exactly its bytes32 — full_refund is system-only",
21
22
  "transaction_not_overwritable" => "a transaction for this operation is already in flight — wait for it to settle",
22
23
  "signer_mismatch" => "the signing key doesn't match the payment's payer/payee",
23
24
  # The SIWE BINDING failures, split out of signer_mismatch so a failed login says
@@ -55,6 +56,17 @@ module Rail0
55
56
  "payer_must_be_caller" => "the payer must be the signed-in wallet — sign in as the buyer to create its payment",
56
57
  "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
58
  "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",
59
+ # A session is bound to the one wallet it signed in with: the account's other wallets
60
+ # are readable, but not this session's to change (operators are exempt).
61
+ "not_your_wallet" => "a session can change only the wallet it signed in with — sign in with that wallet to update it, deactivate it or manage its tokens",
62
+ # POST /accounts/:id/wallets is suspended for every session until the merchant
63
+ # wallet-management section lands.
64
+ "wallet_add_unavailable" => "adding a wallet to an account is not available yet — it returns with the merchant wallet-management section",
65
+ # Login resolves the account FROM a wallet, so an account left with no active one
66
+ # could neither act nor add a wallet back. Adding a wallet is suspended and a session
67
+ # changes only its own wallet, so the other wallet has to be reactivated by an operator.
68
+ "last_active_wallet" => "this is the account's only active wallet — another wallet must be active before this one can be switched off (adding a wallet is not available yet; an operator can reactivate one)",
69
+ "last_admin" => "the change would leave no active operator — grant the operator role to another active account first",
58
70
  "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",
59
71
  "refund_expired" => "the refund window has closed (refundExpiry passed) — refund/dispute is no longer possible",
60
72
  "authorization_not_expired" => "release opens only after authorizationExpiry — wait until it passes",
@@ -30,8 +30,12 @@ module Rail0
30
30
 
31
31
  # The account's own profile.
32
32
  # @param account_id [String] The account UUID — must be the one this JWT belongs to.
33
- # @return [Hash] `id`, `name`, `email`, `created_at`, `updated_at`. `email` is part of
34
- # the response because only the account's owner, or an operator, can read it.
33
+ # @return [Hash] `id`, `name`, `email`, `priority_tip`, `created_at`, `updated_at`.
34
+ # `email` is part of the response because only the account's owner, or an operator,
35
+ # can read it. `priority_tip` is the fee setting of the account's OWN transactions
36
+ # (authorize, capture, void, release, refund): true, the default, tips the network's
37
+ # suggested priority fee; false uses the chain's minimum tip — 0 on most chains,
38
+ # 25 gwei on Polygon PoS/Amoy. The buyer's dispute/close_dispute never read it.
35
39
  def get(account_id)
36
40
  http.get("/accounts/#{segment(account_id)}")
37
41
  end
@@ -40,8 +44,10 @@ module Rail0
40
44
  #
41
45
  # Only the fields passed are sent, and at least one is required: a PATCH with
42
46
  # nothing to change is a caller bug, which the gateway answers with 400 — so this
43
- # raises ArgumentError first rather than spending a request on it. Both fields are
44
- # UNIQUE across accounts, so a value another account already holds answers 409.
47
+ # raises ArgumentError first rather than spending a request on it. Name and email
48
+ # are UNIQUE across accounts, so a value another account already holds answers 409.
49
+ # `priority_tip: false` is a value, not an absence: it is sent as false and switches
50
+ # the account's own transactions to the chain's minimum tip; nil leaves it alone.
45
51
  #
46
52
  # This is a write on the account itself, so a deactivated wallet (403
47
53
  # wallet_deactivated) or a deactivated account (403 account_deactivated) cannot
@@ -51,13 +57,17 @@ module Rail0
51
57
  # @param account_id [String] The account UUID — must be the one this JWT belongs to.
52
58
  # @param name [String, nil] New display name (non-blank; unique across accounts).
53
59
  # @param email [String, nil] New contact email (non-blank; unique across accounts).
60
+ # @param priority_tip [Boolean, nil] Tip the network's suggested priority fee on the
61
+ # account's own transactions (true, the gateway default) or the chain's minimum tip
62
+ # (false). Owner or admin.
54
63
  # @return [Hash] the updated profile, same shape as {get}.
55
- # @raise [ArgumentError] when neither name nor email is given.
56
- def update(account_id, name: nil, email: nil)
64
+ # @raise [ArgumentError] when none of name, email or priority_tip is given.
65
+ def update(account_id, name: nil, email: nil, priority_tip: nil)
57
66
  body = {}
58
- body[:name] = name unless name.nil?
59
- body[:email] = email unless email.nil?
60
- raise ArgumentError, "at least one of name or email is required" if body.empty?
67
+ body[:name] = name unless name.nil?
68
+ body[:email] = email unless email.nil?
69
+ body[:priority_tip] = priority_tip unless priority_tip.nil?
70
+ raise ArgumentError, "at least one of name, email or priority_tip is required" if body.empty?
61
71
 
62
72
  http.patch("/accounts/#{segment(account_id)}", body)
63
73
  end
@@ -31,7 +31,8 @@ module Rail0
31
31
  end
32
32
 
33
33
  # List payments for the authenticated wallet (requires JWT). Returns
34
- # payments where the caller is the payer or payee.
34
+ # payments where the caller is the payer or payee — or, with `scope: "all"`
35
+ # (admin only), every payment on the gateway.
35
36
  # @param status [String, Array<String>, nil] Filter by payment status: one state, or
36
37
  # an Array matching ANY of them (sent comma-separated, `status=authorized,expired`).
37
38
  # An unknown value is 400 `validation_failed` naming it.
@@ -51,6 +52,16 @@ module Rail0
51
52
  # the gateway scales by the token's decimals. The two units coexist in the API.
52
53
  # @param created_from [String, nil] Only payments created at/after this ISO-8601 time.
53
54
  # @param created_to [String, nil] Only payments created at/before this ISO-8601 time.
55
+ # @param scope [String, nil] "mine" (the default): the authenticated wallet's payments.
56
+ # "all": every payment on the gateway — the operator's cross-account list on the
57
+ # same call, admin session required (403 otherwise, never silently narrowed to your
58
+ # own). The three filters below exist only there and are ignored without it.
59
+ # @param account_id [String, nil] scope "all" only: payments whose payee is one of
60
+ # this account's wallets.
61
+ # @param has_error [Boolean, nil] scope "all" only: true = payments carrying a
62
+ # `last_error_code`, false = none.
63
+ # @param expiring_within [Integer, nil] scope "all" only: `authorized` payments whose
64
+ # `authorization_expiry` falls within this many SECONDS from now (1..90 days).
54
65
  # @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
55
66
  # @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
56
67
  # @param per_page [Integer, nil] Items per page (max 100).
@@ -62,12 +73,15 @@ module Rail0
62
73
  # further actions on the row while true).
63
74
  def list(status: nil, mode: nil, payer: nil, payee: nil, token: nil, rail0_id: nil,
64
75
  chain_id: nil, disputed: nil, operation: nil, min_amount: nil, max_amount: nil,
65
- created_from: nil, created_to: nil, sort: nil, page: nil, per_page: nil)
76
+ created_from: nil, created_to: nil, scope: nil, account_id: nil, has_error: nil,
77
+ expiring_within: nil, sort: nil, page: nil, per_page: nil)
66
78
  query = build_query(status: status, mode: mode, payer: payer, payee: payee, token: token,
67
79
  rail0_id: rail0_id, chain_id: chain_id, disputed: disputed,
68
80
  operation: operation,
69
81
  min_amount: min_amount, max_amount: max_amount,
70
82
  created_from: created_from, created_to: created_to,
83
+ scope: scope, account_id: account_id, has_error: has_error,
84
+ expiring_within: expiring_within,
71
85
  sort: sort, page: page, per_page: per_page)
72
86
  http.get_list("/payments#{query}")
73
87
  end
@@ -165,7 +179,9 @@ module Rail0
165
179
  # signature, not retrying a send that never happened.
166
180
  #
167
181
  # The transaction id is resolved THROUGH the payment, so one belonging to another
168
- # payment answers 404 rather than redriving someone else's row.
182
+ # payment answers 404 rather than redriving someone else's row. Payee-gated, like
183
+ # the other operation writes — or an operator session on any payment, which the
184
+ # gateway records in its audit log as transaction.redrive.
169
185
  #
170
186
  # @param id [String] Payment UUID or rail0_id.
171
187
  # @param transaction_id [String] The transaction row to redrive.
@@ -308,8 +324,18 @@ module Rail0
308
324
  end
309
325
 
310
326
  # Phase 1 — build the unsigned dispute() transaction (payer only).
327
+ #
328
+ # +reason+ is optional (rail0-gateway#382). Omitted (nil) or blank, it is left
329
+ # out of the request and the dispute is opened with no reason: the calldata
330
+ # carries the zero bytes32 (which the gateway also accepts explicitly), read back
331
+ # as "No reason given". When given it must be a code from
332
+ # {Rail0::DisputeReasons::OPEN} (e.g. "not_received" or :not_received) or exactly
333
+ # that code's bytes32; the gateway refuses anything else 422
334
+ # +unknown_dispute_reason+ — the close/system codes included — so it is the
335
+ # authority; the SDK sends the value as given rather than second-guessing a
336
+ # dictionary it may lag behind.
311
337
  # @param id [String] Payment UUID or rail0_id.
312
- # @param reason [String, nil] Optional bytes32 code (0x…); defaults to zero server-side.
338
+ # @param reason [String, Symbol, nil] A DisputeOpenReason code, or its bytes32 (0x…); nil for none.
313
339
  # @return [Hash]
314
340
  def dispute_prepare(id, reason: nil, idempotency_key: nil)
315
341
  prepare_dispute("dispute/prepare", id, reason, idempotency_key: idempotency_key)
@@ -324,8 +350,15 @@ module Rail0
324
350
  end
325
351
 
326
352
  # Phase 1 — build the unsigned closeDispute() transaction (payer only).
353
+ #
354
+ # +reason+ is optional, as for {#dispute_prepare}: omitted or blank closes with
355
+ # no reason (the zero bytes32, "No reason given"). When given it must be a code
356
+ # from {Rail0::DisputeReasons::CLOSE} (e.g. "withdrawn") or exactly that code's
357
+ # bytes32. +full_refund+ ({Rail0::DisputeReasons::SYSTEM}) is recorded by the
358
+ # protocol itself and, like any other value, is refused 422
359
+ # +unknown_dispute_reason+.
327
360
  # @param id [String] Payment UUID or rail0_id.
328
- # @param reason [String, nil] Optional bytes32 code (0x…).
361
+ # @param reason [String, Symbol, nil] A DisputeCloseReason code, or its bytes32 (0x…); nil for none.
329
362
  # @return [Hash]
330
363
  def close_dispute_prepare(id, reason: nil, idempotency_key: nil)
331
364
  prepare_dispute("dispute/close/prepare", id, reason, idempotency_key: idempotency_key)
@@ -370,8 +403,11 @@ module Rail0
370
403
  # +path+ is one of this class's own literals ("dispute/prepare",
371
404
  # "dispute/close/prepare"), never caller input, so it is interpolated as-is:
372
405
  # its `/` is meant to separate segments. Only +id+ goes through {#segment}.
406
+ # A nil or blank +reason+ is left out of the body (no reason); anything else is
407
+ # sent as given, stripped, for the gateway to resolve.
373
408
  def prepare_dispute(path, id, reason, idempotency_key: nil)
374
- body = reason ? { reason: reason } : {}
409
+ value = reason.to_s.strip
410
+ body = value.empty? ? {} : { reason: value }
375
411
  http.post("/payments/#{segment(id)}/#{path}", body,
376
412
  headers: idempotency_headers(idempotency_key))
377
413
  end
@@ -11,6 +11,12 @@ module Rail0
11
11
  # with its token holdings nested under +tokens+ (a wallet with no matching
12
12
  # tokens is still returned with an empty list). +id_or_address+ accepts either
13
13
  # the wallet UUID or its 0x address (unique per account).
14
+ #
15
+ # A session is bound to the wallet it signed in with. Reads (list, get, balances)
16
+ # are account-wide, but update, delete and the token-holding methods act only on
17
+ # the session's own wallet: another wallet of the same account raises ApiError 403
18
+ # +not_your_wallet+ (an operator session is exempt). +create+ is suspended for
19
+ # every session (403 +wallet_add_unavailable+).
14
20
  class Wallets
15
21
  include Query
16
22
 
@@ -51,6 +57,10 @@ module Rail0
51
57
 
52
58
  # Add a wallet to the account.
53
59
  #
60
+ # CURRENTLY UNAVAILABLE: the gateway answers every session ApiError 403
61
+ # +wallet_add_unavailable+ until the merchant wallet-management section lands.
62
+ # The method and its proof shape are kept because that section reuses them.
63
+ #
54
64
  # Requires a SIWE PROOF-OF-OWNERSHIP of the address being added, not merely
55
65
  # the session JWT: the gateway verifies that +signature+ recovers to
56
66
  # +address+ (422 otherwise), consumes the nonce carried in +message+, and
@@ -77,7 +87,11 @@ module Rail0
77
87
  http.post("/accounts/#{segment(account_id)}/wallets", body)
78
88
  end
79
89
 
80
- # Update a wallet's label and/or active status.
90
+ # Update a wallet's label and/or active status. Only the wallet the session signed
91
+ # in with: another wallet of the account raises ApiError 403 +not_your_wallet+ (an
92
+ # operator may change any). +active: false+ on the account's only active wallet
93
+ # raises ApiError 422 +last_active_wallet+: login resolves the account from a
94
+ # wallet, so an account with none could neither act nor add one back.
81
95
  # @param account_id [String] Account UUID.
82
96
  # @param id_or_address [String] Wallet UUID or 0x address.
83
97
  # @param label [String, nil] New label.
@@ -90,7 +104,9 @@ module Rail0
90
104
  http.patch(wallet_path(account_id, id_or_address), body)
91
105
  end
92
106
 
93
- # Soft-delete (deactivate) a wallet. Returns HTTP 204.
107
+ # Soft-delete (deactivate) a wallet — the same as +update(active: false)+, so the
108
+ # account's only active wallet raises ApiError 422 +last_active_wallet+, and another
109
+ # wallet than the session's own 403 +not_your_wallet+. Returns HTTP 204.
94
110
  # @param account_id [String] Account UUID.
95
111
  # @param id_or_address [String] Wallet UUID or 0x address.
96
112
  # @return [nil]
@@ -116,6 +132,9 @@ module Rail0
116
132
  # Which tokens a wallet accepts, on which chains. This is what GET
117
133
  # /payment_methods then exposes to buyers, so a merchant onboarding flow that
118
134
  # can create a wallet but not configure its tokens cannot finish. (#12)
135
+ #
136
+ # Owner only, and only on the wallet the session signed in with: another wallet
137
+ # of the same account raises ApiError 403 +not_your_wallet+.
119
138
 
120
139
  # Accept a token (on a chain) on this wallet.
121
140
  #
@@ -143,10 +162,12 @@ module Rail0
143
162
  # not the token's contract address. The gateway looks the holding up by
144
163
  # (wallet, token_id); the same applies to {enable_token} and {disable_token}.
145
164
  #
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.
165
+ # While the wallet accepts other tokens, its active DEFAULT holding cannot be
166
+ # removed: that would leave it with no preferred payment method and nothing saying
167
+ # so, and the gateway answers 422 default_payment_method. Make another token the
168
+ # default first ({add_token} with `default: true` demotes this one in the same
169
+ # transaction), then remove it. The LAST active holding can be removed even as the
170
+ # default: the wallet then accepts nothing, and the gateway clears the flag with it.
150
171
  #
151
172
  # @return [nil]
152
173
  def remove_token(account_id, id_or_address, token_id)
@@ -159,11 +180,13 @@ module Rail0
159
180
  http.patch("#{wallet_path(account_id, id_or_address)}/tokens/#{segment(token_id)}/enable")
160
181
  end
161
182
 
162
- # Disable an existing token holding without forgetting it — the holding (and
163
- # its default flag) survives, so re-enabling restores the previous setup.
183
+ # Disable an existing token holding without forgetting it — the holding survives,
184
+ # so re-enabling restores it; the first holding enabled on a wallet that accepts
185
+ # nothing becomes its default again.
164
186
  #
165
187
  # Like {remove_token}, refused with 422 default_payment_method for the wallet's
166
- # active default holding — promote another token to default first.
188
+ # active default holding while other holdings are active — promote another token to
189
+ # default first. The last active holding can be disabled, default flag and all.
167
190
  # @return [Hash] the token holding
168
191
  def disable_token(account_id, id_or_address, token_id)
169
192
  http.patch("#{wallet_path(account_id, id_or_address)}/tokens/#{segment(token_id)}/disable")
@@ -105,6 +105,15 @@ module Rail0
105
105
  end
106
106
 
107
107
  # Generate a new shared secret, returned once on the response.
108
+ #
109
+ # The replaced secret is kept for a grace window (the gateway's
110
+ # WEBHOOK_SECRET_GRACE, one hour by default): inside it every delivery carries
111
+ # X-Rail0-Signature under the new key AND X-Rail0-Signature-Previous under the
112
+ # old one, so an endpoint verifies with whichever key it has deployed and nothing
113
+ # in flight at rotation becomes unverifiable — see {Rail0::WebhookSignature}. The
114
+ # old key signs only, is never honoured past the window, and a second rotation
115
+ # inside it drops the oldest key.
116
+ #
108
117
  # @param id [String] Webhook UUID.
109
118
  # @return [Hash] webhook record including the new shared_secret
110
119
  def rotate_secret(id)
data/lib/rail0/types.rb CHANGED
@@ -10,6 +10,17 @@
10
10
 
11
11
  module Rail0
12
12
  module Types
13
+ # What still names a wallet's address: the payments and transactions an admin is shown before
14
+ # switching the wallet off.
15
+ WalletUsage = Struct.new(
16
+ :wallet_id, # String
17
+ :address, # String
18
+ :payments_as_payee, # Integer
19
+ :payments_as_payer, # Integer
20
+ :transactions_as_sender, # Integer
21
+ keyword_init: true
22
+ )
23
+
13
24
  # A wallet's on-chain balances, one entry per chain.
14
25
  WalletBalances = Struct.new(
15
26
  :wallet_id, # String
@@ -75,6 +86,38 @@ module Rail0
75
86
  keyword_init: true
76
87
  )
77
88
 
89
+ # GET /admin/rpc_health: per-chain RPC reachability as seen from the gateway's process.
90
+ AdminRpcHealth = Struct.new(
91
+ :status, # String — The worst chain status.
92
+ :checked_at, # String — When the probe ran; a cached answer keeps its original time.
93
+ :chains, # Array
94
+ keyword_init: true
95
+ )
96
+
97
+ AdminRpcHealthChain = Struct.new(
98
+ :chain_id, # Integer
99
+ :name, # String
100
+ :finality_tag, # String
101
+ :ok, # Boolean — At least one endpoint serves the chain.
102
+ :primary_status, # String — The first endpoint's status; null when none is configured.
103
+ :status, # String — ok: every endpoint up; degraded: some; error: none, or no endpoint configured.
104
+ :endpoints, # Array
105
+ keyword_init: true
106
+ )
107
+
108
+ AdminRpcHealthEndpoint = Struct.new(
109
+ :url, # String — The endpoint, as configured.
110
+ :ok, # Boolean
111
+ :status, # String
112
+ :error, # String — Short `Class: first line`; never an upstream body.
113
+ :chain_id, # Integer — What the node reported.
114
+ :latency_ms, # Integer — First round trip, handshake included.
115
+ :head, # Integer
116
+ :tag_block, # Integer — The finality-tag block; null on depth chains or when the node refuses the tag.
117
+ :tag_error, # String
118
+ keyword_init: true
119
+ )
120
+
78
121
  # Operator diagnostics for GET /admin/health: one verdict plus a named check per standing failure
79
122
  # condition, always 200 (a degraded system is the content, not an error — this is not a liveness
80
123
  # probe). Every figure is computed on request from state the gateway already keeps; nothing here is
@@ -229,14 +272,18 @@ module Rail0
229
272
 
230
273
  # Buyer-driven, signal-only dispute lifecycle (no fund effect).
231
274
  Dispute = Struct.new(
232
- :id, # String
233
- :payment_id, # String
234
- :status, # String
235
- :reason, # String — On-chain bytes32 reason code (hex).
275
+ :id, # String
276
+ :payment_id, # String
277
+ :status, # String
278
+ :reason, # String — On-chain bytes32 reason the dispute was opened with (hex); the all-zero word when no reason was given; kept verbatim even when it is outside the dispute-reason dictionary.
279
+ :reason_code, # DisputeOpenReason | nil — The DisputeOpenReason code `reason` reads as; null when no reason was given (the zero bytes32) or when the bytes32 is not in the dictionary (a direct contract call).
280
+ :reason_description, # String — English description of `reason_code`; "No reason given" when `reason` is the zero bytes32; "Unrecognised reason" when it is a non-zero bytes32 outside the dictionary.
236
281
  :opened_block,
237
- :opened_at, # String
282
+ :opened_at, # String
238
283
  :closed_by,
239
- :close_reason,
284
+ :close_reason, # On-chain bytes32 close reason (hex); the all-zero word when closed with no reason; null while the dispute is open.
285
+ :close_reason_code, # DisputeCloseReason | DisputeSystemCloseReason | nil — The code `close_reason` reads as: a DisputeCloseReason, or the system DisputeSystemCloseReason `full_refund` when a full refund auto-closed the dispute. Null while open, when closed with no reason (the zero bytes32), or when the bytes32 is not in the dictionary.
286
+ :close_reason_description, # English description of `close_reason_code`; "No reason given" when `close_reason` is the zero bytes32; "Unrecognised reason" when it is a non-zero bytes32 outside the dictionary; null while open.
240
287
  :closed_block,
241
288
  :closed_at,
242
289
  keyword_init: true
@@ -287,15 +334,99 @@ module Rail0
287
334
  )
288
335
 
289
336
  # The account's own profile as the holder reads it (GET) and as a PATCH returns it — id, name,
290
- # email, timestamps. Deliberately no admin/role field: the operator grant lives in a separate table,
291
- # so a standard account's profile carries no trace of that axis. An ADMIN reading any account gets
292
- # the whole record plus `admin` instead, which is a different shape and not this one.
337
+ # email, priority_tip, timestamps. Deliberately no admin/role field: the operator grant lives in a
338
+ # separate table, so a standard account's profile carries no trace of that axis. An ADMIN reading
339
+ # any account gets the whole record plus `admin` instead, which is a different shape and not this
340
+ # one.
293
341
  Account = Struct.new(
294
- :id, # String
295
- :name, # String
342
+ :id, # String
343
+ :name, # String
296
344
  :email,
345
+ :priority_tip, # Boolean — Whether the merchant's own transactions tip the network's suggested priority fee (true, the default) or the chain's minimum tip (false: 0 on most chains, 25 gwei on Polygon PoS/Amoy). The buyer's dispute/close_dispute never read it.
346
+ :created_at, # String
347
+ :updated_at, # String
348
+ keyword_init: true
349
+ )
350
+
351
+ # The whole account record, as an operator reads it (Full entity: GET /accounts, POST /accounts, and
352
+ # GET/PATCH /accounts/{account_id} for an admin session): every column plus `admin` and the derived
353
+ # `status`.
354
+ AccountFull = Struct.new(
355
+ :id, # String
356
+ :name, # String
357
+ :email, # String — Stored lowercase.
358
+ :active, # Boolean — The operator's switch. false = deactivated.
359
+ :priority_tip, # Boolean — The merchant's fee setting (see Account.priority_tip).
360
+ :claimed_at, # When the merchant claimed the account at POST /claim; null while it is pending its claim.
361
+ :created_at, # String
362
+ :updated_at, # String
363
+ :admin, # Boolean — Whether the account holds the operator grant (a row in administrators).
364
+ :status, # String — One-word reading of active and claimed_at together: pending_claim (not yet claimed, not operational), active (claimed and active) or deactivated (switched off by an operator, claimed or not).
365
+ keyword_init: true
366
+ )
367
+
368
+ # An operator grant with the people named.
369
+ AdministratorFull = Struct.new(
370
+ :id, # String
371
+ :account_id, # String
372
+ :granted_by, # String
373
+ :note, # String
374
+ :expires_at, # String
297
375
  :created_at, # String
298
- :updated_at, # String
376
+ :account, # Hash — The account's public shape (id, name, email, priority_tip, timestamps).
377
+ :grantor, # Hash — The account's public shape (id, name, email, priority_tip, timestamps).
378
+ keyword_init: true
379
+ )
380
+
381
+ # One row of the operator audit log.
382
+ AdminAction = Struct.new(
383
+ :id, # String
384
+ :actor_account_id, # String
385
+ :actor_address, # String
386
+ :action, # String
387
+ :resource_type, # String
388
+ :resource_id, # String
389
+ :target_account_id, # String
390
+ :detail, # Hash
391
+ :created_at, # String
392
+ :actor, # Hash — The account's public shape (id, name, email, priority_tip, timestamps).
393
+ :target_account, # Hash — The account's public shape (id, name, email, priority_tip, timestamps).
394
+ keyword_init: true
395
+ )
396
+
397
+ # One reported policy value.
398
+ PolicySetting = Struct.new(
399
+ :value, # The effective value (number, boolean, string, list or map).
400
+ :env, # String — The environment variable that sets it; null for a constant.
401
+ :overridden, # Boolean — Whether that variable is set in this process.
402
+ :description, # String — Plain-text explanation of the entry: what it controls, which part of the gateway reads it and the effect of raising or lowering it, and its unit (from config/policy_descriptions.yml). Every entry has one; older gateways omit the field.
403
+ keyword_init: true
404
+ )
405
+
406
+ # GET /admin/policy: the effective policy, grouped. Every leaf is a PolicySetting.
407
+ PolicyReport = Struct.new(
408
+ :payments, # Hash
409
+ :recovery, # Hash
410
+ :retention, # Hash
411
+ :webhooks, # Hash
412
+ :settlement, # Hash
413
+ :rate_limit, # Hash
414
+ :siwe, # Hash
415
+ :fees, # Hash
416
+ :rpc, # Hash
417
+ :generated_at, # String
418
+ keyword_init: true
419
+ )
420
+
421
+ # GET /admin/catalog: the snapshot against the database, per table.
422
+ CatalogDiff = Struct.new(
423
+ :loaded_at, # String
424
+ :checked_at, # String
425
+ :stale, # Boolean
426
+ :pending, # Integer
427
+ :blockchains, # Hash
428
+ :tokens, # Hash
429
+ :contracts, # Hash
299
430
  keyword_init: true
300
431
  )
301
432
 
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.4.0"
8
+ VERSION = "1.5.1"
9
9
  end
@@ -16,6 +16,12 @@ module Rail0
16
16
  # X-Rail0-Timestamp unix seconds, as a string
17
17
  # X-Rail0-Signature hex HMAC-SHA256 over "{timestamp}.{body}", keyed by the secret
18
18
  #
19
+ # and, for a grace window after a secret rotation (the gateway's
20
+ # WEBHOOK_SECRET_GRACE, one hour by default), X-Rail0-Signature-Previous: the same
21
+ # digest under the REPLACED secret, so an endpoint still holding the old key passes
22
+ # that header to {verify} instead and no delivery in flight at rotation is rejected.
23
+ # The old key signs only, and is never honoured past the window.
24
+ #
19
25
  # The timestamp is inside the signed string on purpose: without it a captured
20
26
  # delivery is replayable forever, because the body alone stays valid indefinitely.
21
27
  # That is why {verify} rejects a stale timestamp even when the digest matches —
data/lib/rail0.rb CHANGED
@@ -18,3 +18,4 @@ require_relative "rail0/http_client"
18
18
  require_relative "rail0/client"
19
19
  require_relative "rail0/webhook_signature"
20
20
  require_relative "rail0/stablecoins"
21
+ require_relative "rail0/dispute_reasons"
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.0
4
+ version: 1.5.1
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-10-07 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -41,6 +41,7 @@ files:
41
41
  - lib/rail0/backoff.rb
42
42
  - lib/rail0/client.rb
43
43
  - lib/rail0/default_logger.rb
44
+ - lib/rail0/dispute_reasons.rb
44
45
  - lib/rail0/error_hints.rb
45
46
  - lib/rail0/http_client.rb
46
47
  - lib/rail0/request.rb