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