rail0-sdk 1.4.0 → 1.5.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: 5432df0776e08a77c56678e4e977d87c81ddf2be6176fef59a95051e287a6ce9
4
- data.tar.gz: 7747a265cc8d84f243a09a8b1ab66e3cba545a3817554bc321dc5aa9eaed34be
3
+ metadata.gz: 06dc80b9fc301df5fb28259087c18ceae341e047418d6c26855a17fc2076c0e8
4
+ data.tar.gz: ee45ee4241b861c2443af42dc3453a824e1d5974c3accb872f0d03b9356959d3
5
5
  SHA512:
6
- metadata.gz: 440edc898e25a83ad3c76615777b4074f7daf003904db75129df01c4fcb223aebad7e5a1e425212207842aa334cdab1bccfe79a0023fcf08578d5bc09da8462a
7
- data.tar.gz: 7482c21a09309b6dabe1f26c4b19b29890d8b8dfc679d4fc1b15d8b1de76a39b2f5998414e2e28ab3ee840edc2e31e7531ebe3b215c0a308f92068d0754ac46a
6
+ metadata.gz: 4a41a60e24ca27221b91e4c35021636cd8f5b134810369354f653b947f9578124c666c09005cc13f25b0f121262d06ff209f97bc637fcf20f2ea8fab5e616c0a
7
+ data.tar.gz: 3459c91f1e3ed2b9a2c3ede420e004d63c44d2fdf0def948b49e781d7b7f6b4bf00ac7e2c92c5d5b141c46704245c4aed6daed2f5c560199a39f1ed9b7137c24
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,12 @@ 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. Add or reactivate
331
+ another wallet first.
332
+
327
333
  **Registering a wallet needs a SIWE proof-of-ownership of the address being
328
334
  added**, not merely the session JWT. `auth.prove_address` runs that handshake and
329
335
  returns the `message` + `signature` to splat into `create`. Sign with **the added
@@ -366,10 +372,12 @@ re-enables it and answers 200 instead of creating a second row. The id passed to
366
372
  the other three is the **token's** UUID (`token_id` on the holding), not an id of the
367
373
  holding row and not the token address — the gateway looks the holding up by (wallet, token).
368
374
 
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.
375
+ While the wallet accepts other tokens, its active **default** holding cannot be disabled or
376
+ removed — that would leave it with no preferred payment method — and the gateway answers
377
+ 422 `default_payment_method`. Make another token the default first
378
+ (`add_token(..., default: true)` demotes the old one in the same transaction), then disable
379
+ or remove it. The **last** active holding can be disabled even as the default: the wallet
380
+ then accepts nothing, and the next holding enabled becomes the default.
373
381
 
374
382
  ## Payments
375
383
 
@@ -385,10 +393,16 @@ client.payments.update(id, description: nil) # nil (or "") clears it
385
393
  client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at")
386
394
  client.payments.list(status: %w[authorized expired]) # any of several states → ?status=authorized,expired
387
395
  client.payments.list(operation: "refund") # payments with at least one refund transaction
396
+ # The operator's cross-account list (admin only, 403 otherwise): every payment on the
397
+ # gateway, same filters, plus three that exist only there — the payee account, whether a
398
+ # last_error_code is set, and authorized payments whose hold lapses within N SECONDS.
399
+ client.payments.list(scope: "all", has_error: true)
400
+ client.payments.list(scope: "all", expiring_within: 24 * 3600)
401
+ client.payments.list(scope: "all", account_id: "019f…")
388
402
  client.payments.transactions(id, operation: "capture")
389
403
  client.payments.transactions(id, status: %w[submitting submitted]) # on its way to the chain
390
404
  # `operation` on both takes Payments::TRANSACTION_OPERATIONS — dispute and close_dispute included.
391
- client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast
405
+ client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast (payee, or an operator)
392
406
  # Offer `redrive` on the row's `redrivable` flag — the same predicate the gateway guards
393
407
  # the route with — and not on `status == "pending"`: a pending row holding no signed
394
408
  # transaction is not redrivable, and there the next step is submitting the signature, not
@@ -456,14 +470,56 @@ pattern. Like the rest of `/payments` they need a session, but the payer's
456
470
  account-less SIWE login is enough:
457
471
 
458
472
  ```ruby
459
- prep = client.payments.dispute_prepare(rail0_id, reason: "0x…") # reason optional
473
+ prep = client.payments.dispute_prepare(rail0_id, reason: "not_received") # reason optional
460
474
  raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
461
475
  client.payments.dispute(rail0_id, { signed_transaction: raw })
462
476
  # … later …
463
- client.payments.close_dispute_prepare(rail0_id)
477
+ prep = client.payments.close_dispute_prepare(rail0_id, reason: "withdrawn") # or omit reason:
478
+ raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
464
479
  client.payments.close_dispute(rail0_id, { signed_transaction: raw })
465
480
  ```
466
481
 
482
+ #### Dispute reasons
483
+
484
+ `reason:` is **optional** on both prepares. Omit it (or pass `nil` or a blank string) and
485
+ the SDK leaves `reason` out of the request: the dispute is opened/closed with no reason,
486
+ the calldata carries the zero bytes32 (the gateway also accepts the zero word explicitly),
487
+ and it reads back as `No reason given`. When given, it is a code from the gateway's
488
+ dispute-reason dictionary (a String or a Symbol) or exactly that code's bytes32,
489
+ `keccak256("rail0.dispute.<code>")`, which is what the contract stores. Anything else — any
490
+ other non-zero bytes32, a code from the other list, or the system-only `full_refund` — is
491
+ refused 422 `unknown_dispute_reason`.
492
+
493
+ | Kind | Codes |
494
+ |------|-------|
495
+ | Open (`dispute_prepare`) | `not_received`, `not_as_described`, `damaged_or_defective`, `duplicate`, `incorrect_amount`, `cancelled`, `refund_not_received`, `unauthorized`, `other` |
496
+ | Close (`close_dispute_prepare`) | `resolved_with_merchant`, `withdrawn`, `item_received`, `other` |
497
+ | System (never sent; read back only) | `full_refund` — a full refund auto-closed the dispute |
498
+
499
+ `Rail0::DisputeReasons` carries the dictionary as constants, generated from the gateway's
500
+ `DisputeOpenReason` / `DisputeCloseReason` / `DisputeSystemCloseReason` schemas:
501
+
502
+ ```ruby
503
+ Rail0::DisputeReasons::OPEN # [Reason(code:, description:, bytes32:), …]
504
+ Rail0::DisputeReasons.codes(:close) # ["resolved_with_merchant", "withdrawn", …]
505
+ Rail0::DisputeReasons.find(:open, "not_received") # Reason, or nil (also by bytes32)
506
+ Rail0::DisputeReasons.valid?(:close, "full_refund") # false: system-only
507
+ Rail0::DisputeReasons.valid?(:open, nil) # true: no reason is accepted
508
+ Rail0::DisputeReasons.none?(Rail0::DisputeReasons::ZERO_BYTES32) # true: nil, blank or zero
509
+ Rail0::DisputeReasons.description_for(:open, "0x00…00") # "No reason given" (the zero word)
510
+ Rail0::DisputeReasons.description_for(:open, "0x12…34") # "Unrecognised reason"
511
+ ```
512
+
513
+ Lookups take a kind because the lists overlap: `other` is both an open and a close reason.
514
+
515
+ A dispute read back (`payments.disputes`, `disputes.list`) carries the decoded reason next
516
+ to the raw bytes32: `reason_code` / `reason_description`, and `close_reason_code` /
517
+ `close_reason_description` once closed (`close_reason_code` may be `full_refund`). A
518
+ dispute with no reason (the zero bytes32) has `reason_code: nil` and
519
+ `reason_description: "No reason given"`; a non-zero bytes32 outside the dictionary — a
520
+ dispute opened on the contract directly — is kept verbatim in `reason`, with
521
+ `reason_code: nil` and `reason_description: "Unrecognised reason"`.
522
+
467
523
  ### Generic prepare/submit
468
524
 
469
525
  Every wrapper delegates to the generic form, useful for dynamic operations:
@@ -560,6 +616,25 @@ window is what bounds a replay of a captured delivery. Pass the body exactly as
560
616
  received: re-serialising a parsed hash changes key order and whitespace, and the
561
617
  digest with it. Comparison is constant-time.
562
618
 
619
+ **Rotating the secret without a gap.** After `rotate_secret` the gateway keeps the
620
+ replaced key for a grace window (`WEBHOOK_SECRET_GRACE`, one hour by default): inside it
621
+ every delivery carries `X-Rail0-Signature` under the new key **and**
622
+ `X-Rail0-Signature-Previous` under the old one, so an endpoint verifies with whichever key
623
+ it has deployed and nothing in flight at rotation becomes unverifiable. Deploy the new
624
+ secret at your pace within the window; an endpoint that still holds the old one verifies
625
+ the previous header instead — the old key signs only, and is never honoured past the
626
+ window. A second rotation inside it drops the oldest key.
627
+
628
+ ```ruby
629
+ verify = ->(signature) do
630
+ Rail0::WebhookSignature.verify(body: raw, signature: signature,
631
+ timestamp: request.headers["X-Rail0-Timestamp"],
632
+ secret: ENV.fetch("RAIL0_WEBHOOK_SECRET"))
633
+ end
634
+ ok = verify.call(request.headers["X-Rail0-Signature"]) ||
635
+ verify.call(request.headers["X-Rail0-Signature-Previous"])
636
+ ```
637
+
563
638
  ## Account (JWT)
564
639
 
565
640
  ```ruby
@@ -568,21 +643,28 @@ digest with it. Comparison is constant-time.
568
643
  # merchant's account — and an id that is not an account answers 404 exactly as
569
644
  # another account's id does, so the pair says nothing about existence.
570
645
  client.accounts.get(account_id)
571
- # => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test",
646
+ # => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test", priority_tip: true,
572
647
  # created_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-20T00:00:00Z" }
573
648
 
574
649
  # Update the same profile — only the fields passed are sent.
575
650
  client.accounts.update(account_id, email: "ops@merchant.example")
576
651
  client.accounts.update(account_id, name: "Merchant Ltd", email: "ops@merchant.example")
652
+ client.accounts.update(account_id, priority_tip: false) # minimum tip on the account's own transactions
577
653
  ```
578
654
 
579
655
  `email` is in the response because the holder is this endpoint's only possible caller.
580
656
 
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.
657
+ `update` needs at least one of `name`/`email`/`priority_tip` (it raises `ArgumentError`
658
+ before sending an empty PATCH, which the gateway would answer with 400). Name and email are
659
+ unique across accounts, so a value another account holds answers **409**. It is a write on
660
+ the account, so a deactivated wallet (403 `wallet_deactivated`) or account (403
661
+ `account_deactivated`) cannot make it. The gateway's `active` field is operator-only and not
662
+ exposed here.
663
+
664
+ **`priority_tip`** is the fee setting of the account's **own** transactions (authorize,
665
+ capture, void, release, refund): `true`, the default, tips the network's suggested priority
666
+ fee; `false` uses the chain's minimum tip (0 on most chains, 25 gwei on Polygon PoS/Amoy) —
667
+ cheaper, slower to confirm under load. The buyer's dispute/close-dispute never read it.
586
668
  The account's wallets are a collection under the same path (`client.wallets`), and
587
669
  buyer-facing discovery is `client.payment_methods`.
588
670
 
@@ -842,7 +924,7 @@ broadcast may already be in flight.
842
924
  ## Project structure
843
925
 
844
926
  ```text
845
- gen/generate.rb regenerates lib/rail0/types.rb from the gateway OpenAPI schema
927
+ gen/generate.rb regenerates lib/rail0/types.rb and lib/rail0/dispute_reasons.rb from the gateway OpenAPI schema
846
928
 
847
929
  lib/rail0/
848
930
  client.rb Rail0::Client — entry point
@@ -855,6 +937,7 @@ lib/rail0/
855
937
  webhook_signature.rb Rail0::WebhookSignature — delivery verification
856
938
  signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
857
939
  stablecoins.rb stablecoin address registry
940
+ dispute_reasons.rb generated Rail0::DisputeReasons — the dispute-reason dictionary
858
941
  types.rb generated Struct docs of the gateway schema (reference only)
859
942
  version.rb Rail0::VERSION
860
943
  resources/
@@ -882,7 +965,7 @@ bundle exec rubocop # style only
882
965
  bundle exec rubocop -a # and fix what is safely fixable
883
966
  bundle exec rspec # specs only
884
967
 
885
- # Regenerate lib/rail0/types.rb after a gateway schema change:
968
+ # Regenerate lib/rail0/types.rb and lib/rail0/dispute_reasons.rb after a gateway schema change:
886
969
  # defaults to ../rail0-gateway/docs/openapi.json, or set RAIL0_SCHEMA_PATH.
887
970
  ruby gen/generate.rb
888
971
  ```
@@ -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,10 @@ 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
+ # Login resolves the account FROM a wallet, so an account left with no active one
60
+ # could neither act nor add a wallet back.
61
+ "last_active_wallet" => "this is the account's only active wallet — add or reactivate another wallet first, then switch this one off",
62
+ "last_admin" => "the change would leave no active operator — grant the operator role to another active account first",
58
63
  "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
64
  "refund_expired" => "the refund window has closed (refundExpiry passed) — refund/dispute is no longer possible",
60
65
  "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
@@ -77,7 +77,9 @@ module Rail0
77
77
  http.post("/accounts/#{segment(account_id)}/wallets", body)
78
78
  end
79
79
 
80
- # Update a wallet's label and/or active status.
80
+ # Update a wallet's label and/or active status. +active: false+ on the account's
81
+ # only active wallet raises ApiError 422 +last_active_wallet+: login resolves the
82
+ # account from a wallet, so an account with none could neither act nor add one back.
81
83
  # @param account_id [String] Account UUID.
82
84
  # @param id_or_address [String] Wallet UUID or 0x address.
83
85
  # @param label [String, nil] New label.
@@ -90,7 +92,8 @@ module Rail0
90
92
  http.patch(wallet_path(account_id, id_or_address), body)
91
93
  end
92
94
 
93
- # Soft-delete (deactivate) a wallet. Returns HTTP 204.
95
+ # Soft-delete (deactivate) a wallet — the same as +update(active: false)+, so the
96
+ # account's only active wallet raises ApiError 422 +last_active_wallet+. Returns HTTP 204.
94
97
  # @param account_id [String] Account UUID.
95
98
  # @param id_or_address [String] Wallet UUID or 0x address.
96
99
  # @return [nil]
@@ -143,10 +146,12 @@ module Rail0
143
146
  # not the token's contract address. The gateway looks the holding up by
144
147
  # (wallet, token_id); the same applies to {enable_token} and {disable_token}.
145
148
  #
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.
149
+ # While the wallet accepts other tokens, its active DEFAULT holding cannot be
150
+ # removed: that would leave it with no preferred payment method and nothing saying
151
+ # so, and the gateway answers 422 default_payment_method. Make another token the
152
+ # default first ({add_token} with `default: true` demotes this one in the same
153
+ # transaction), then remove it. The LAST active holding can be removed even as the
154
+ # default: the wallet then accepts nothing, and the gateway clears the flag with it.
150
155
  #
151
156
  # @return [nil]
152
157
  def remove_token(account_id, id_or_address, token_id)
@@ -159,11 +164,13 @@ module Rail0
159
164
  http.patch("#{wallet_path(account_id, id_or_address)}/tokens/#{segment(token_id)}/enable")
160
165
  end
161
166
 
162
- # Disable an existing token holding without forgetting it — the holding (and
163
- # its default flag) survives, so re-enabling restores the previous setup.
167
+ # Disable an existing token holding without forgetting it — the holding survives,
168
+ # so re-enabling restores it; the first holding enabled on a wallet that accepts
169
+ # nothing becomes its default again.
164
170
  #
165
171
  # Like {remove_token}, refused with 422 default_payment_method for the wallet's
166
- # active default holding — promote another token to default first.
172
+ # active default holding while other holdings are active — promote another token to
173
+ # default first. The last active holding can be disabled, default flag and all.
167
174
  # @return [Hash] the token holding
168
175
  def disable_token(account_id, id_or_address, token_id)
169
176
  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 — Scheme, host, port and path only.
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,81 @@ 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
+ # An operator grant with the people named.
352
+ AdministratorFull = Struct.new(
353
+ :id, # String
354
+ :account_id, # String
355
+ :granted_by, # String
356
+ :note, # String
357
+ :expires_at, # String
297
358
  :created_at, # String
298
- :updated_at, # String
359
+ :account, # Hash — The account's public shape (id, name, email, priority_tip, timestamps).
360
+ :grantor, # Hash — The account's public shape (id, name, email, priority_tip, timestamps).
361
+ keyword_init: true
362
+ )
363
+
364
+ # One row of the operator audit log.
365
+ AdminAction = Struct.new(
366
+ :id, # String
367
+ :actor_account_id, # String
368
+ :actor_address, # String
369
+ :action, # String
370
+ :resource_type, # String
371
+ :resource_id, # String
372
+ :target_account_id, # String
373
+ :detail, # Hash
374
+ :created_at, # String
375
+ :actor, # Hash — The account's public shape (id, name, email, priority_tip, timestamps).
376
+ :target_account, # Hash — The account's public shape (id, name, email, priority_tip, timestamps).
377
+ keyword_init: true
378
+ )
379
+
380
+ # One reported policy value.
381
+ PolicySetting = Struct.new(
382
+ :value, # The effective value (number, boolean, string, list or map).
383
+ :env, # String — The environment variable that sets it; null for a constant.
384
+ :overridden, # Boolean — Whether that variable is set in this process.
385
+ keyword_init: true
386
+ )
387
+
388
+ # GET /admin/policy: the effective policy, grouped. Every leaf is a PolicySetting.
389
+ PolicyReport = Struct.new(
390
+ :payments, # Hash
391
+ :recovery, # Hash
392
+ :retention, # Hash
393
+ :webhooks, # Hash
394
+ :settlement, # Hash
395
+ :rate_limit, # Hash
396
+ :siwe, # Hash
397
+ :fees, # Hash
398
+ :rpc, # Hash
399
+ :generated_at, # String
400
+ keyword_init: true
401
+ )
402
+
403
+ # GET /admin/catalog: the snapshot against the database, per table.
404
+ CatalogDiff = Struct.new(
405
+ :loaded_at, # String
406
+ :checked_at, # String
407
+ :stale, # Boolean
408
+ :pending, # Integer
409
+ :blockchains, # Hash
410
+ :tokens, # Hash
411
+ :contracts, # Hash
299
412
  keyword_init: true
300
413
  )
301
414
 
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.0"
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.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-10-07 00:00:00.000000000 Z
11
+ date: 2026-10-12 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