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 +4 -4
- data/README.md +100 -17
- data/lib/rail0/dispute_reasons.rb +180 -0
- data/lib/rail0/error_hints.rb +5 -0
- data/lib/rail0/resources/accounts.rb +19 -9
- data/lib/rail0/resources/payments.rb +42 -6
- data/lib/rail0/resources/wallets.rb +16 -9
- data/lib/rail0/resources/webhooks.rb +9 -0
- data/lib/rail0/types.rb +125 -12
- data/lib/rail0/version.rb +1 -1
- data/lib/rail0/webhook_signature.rb +6 -0
- data/lib/rail0.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 06dc80b9fc301df5fb28259087c18ceae341e047418d6c26855a17fc2076c0e8
|
|
4
|
+
data.tar.gz: ee45ee4241b861c2443af42dc3453a824e1d5974c3accb872f0d03b9356959d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
370
|
-
with no preferred payment method — and the gateway answers
|
|
371
|
-
Make another token the default first
|
|
372
|
-
in the same transaction), then disable
|
|
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: "
|
|
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`
|
|
582
|
-
empty PATCH, which the gateway would answer with 400).
|
|
583
|
-
a value another account holds answers **409**. It is a write on
|
|
584
|
-
deactivated wallet (403 `wallet_deactivated`) or account (403
|
|
585
|
-
make it. The gateway's `active` field is operator-only and not
|
|
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
|
data/lib/rail0/error_hints.rb
CHANGED
|
@@ -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`.
|
|
34
|
-
# the response because only the account's owner, or an operator,
|
|
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.
|
|
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
|
|
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]
|
|
59
|
-
body[:email]
|
|
60
|
-
|
|
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,
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
147
|
-
# preferred payment method and nothing saying
|
|
148
|
-
# default_payment_method. Make another token the
|
|
149
|
-
# `default: true` demotes this one in the same
|
|
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
|
|
163
|
-
#
|
|
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
|
|
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,
|
|
233
|
-
:payment_id,
|
|
234
|
-
:status,
|
|
235
|
-
:reason,
|
|
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,
|
|
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
|
|
291
|
-
# so a standard account's profile carries no trace of that axis. An ADMIN reading
|
|
292
|
-
# the whole record plus `admin` instead, which is a different shape and not this
|
|
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,
|
|
295
|
-
:name,
|
|
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
|
-
:
|
|
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.
|
|
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
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rail0-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.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-
|
|
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
|