rail0-sdk 1.3.1 → 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 +104 -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 +60 -6
- data/lib/rail0/resources/wallets.rb +16 -9
- data/lib/rail0/resources/webhooks.rb +9 -0
- data/lib/rail0/types.rb +125 -22
- 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
|
|
|
@@ -378,13 +386,23 @@ client.payments.create(params, idempotency_key: nil) # or keyword fields
|
|
|
378
386
|
# Reusing a key for DIFFERENT terms raises Rail0::ApiError with code
|
|
379
387
|
# "idempotency_key_reused" (422) instead of returning the first payment.
|
|
380
388
|
client.payments.get(id)
|
|
389
|
+
client.payments.update(id, description: "Order #42") # → the payment, same shape as get
|
|
390
|
+
client.payments.update(id, description: nil) # nil (or "") clears it
|
|
391
|
+
# The description is the only editable field: off-chain, so any status, settled included.
|
|
392
|
+
# Payer or payee only (a non-participant gets 404); over 255 characters is a 422.
|
|
381
393
|
client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at")
|
|
382
394
|
client.payments.list(status: %w[authorized expired]) # any of several states → ?status=authorized,expired
|
|
383
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…")
|
|
384
402
|
client.payments.transactions(id, operation: "capture")
|
|
385
403
|
client.payments.transactions(id, status: %w[submitting submitted]) # on its way to the chain
|
|
386
404
|
# `operation` on both takes Payments::TRANSACTION_OPERATIONS — dispute and close_dispute included.
|
|
387
|
-
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)
|
|
388
406
|
# Offer `redrive` on the row's `redrivable` flag — the same predicate the gateway guards
|
|
389
407
|
# the route with — and not on `status == "pending"`: a pending row holding no signed
|
|
390
408
|
# transaction is not redrivable, and there the next step is submitting the signature, not
|
|
@@ -452,14 +470,56 @@ pattern. Like the rest of `/payments` they need a session, but the payer's
|
|
|
452
470
|
account-less SIWE login is enough:
|
|
453
471
|
|
|
454
472
|
```ruby
|
|
455
|
-
prep = client.payments.dispute_prepare(rail0_id, reason: "
|
|
473
|
+
prep = client.payments.dispute_prepare(rail0_id, reason: "not_received") # reason optional
|
|
456
474
|
raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
|
|
457
475
|
client.payments.dispute(rail0_id, { signed_transaction: raw })
|
|
458
476
|
# … later …
|
|
459
|
-
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)
|
|
460
479
|
client.payments.close_dispute(rail0_id, { signed_transaction: raw })
|
|
461
480
|
```
|
|
462
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
|
+
|
|
463
523
|
### Generic prepare/submit
|
|
464
524
|
|
|
465
525
|
Every wrapper delegates to the generic form, useful for dynamic operations:
|
|
@@ -556,6 +616,25 @@ window is what bounds a replay of a captured delivery. Pass the body exactly as
|
|
|
556
616
|
received: re-serialising a parsed hash changes key order and whitespace, and the
|
|
557
617
|
digest with it. Comparison is constant-time.
|
|
558
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
|
+
|
|
559
638
|
## Account (JWT)
|
|
560
639
|
|
|
561
640
|
```ruby
|
|
@@ -564,21 +643,28 @@ digest with it. Comparison is constant-time.
|
|
|
564
643
|
# merchant's account — and an id that is not an account answers 404 exactly as
|
|
565
644
|
# another account's id does, so the pair says nothing about existence.
|
|
566
645
|
client.accounts.get(account_id)
|
|
567
|
-
# => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test",
|
|
646
|
+
# => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test", priority_tip: true,
|
|
568
647
|
# created_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-20T00:00:00Z" }
|
|
569
648
|
|
|
570
649
|
# Update the same profile — only the fields passed are sent.
|
|
571
650
|
client.accounts.update(account_id, email: "ops@merchant.example")
|
|
572
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
|
|
573
653
|
```
|
|
574
654
|
|
|
575
655
|
`email` is in the response because the holder is this endpoint's only possible caller.
|
|
576
656
|
|
|
577
|
-
`update` needs at least one of `name`/`email` (it raises `ArgumentError`
|
|
578
|
-
empty PATCH, which the gateway would answer with 400).
|
|
579
|
-
a value another account holds answers **409**. It is a write on
|
|
580
|
-
deactivated wallet (403 `wallet_deactivated`) or account (403
|
|
581
|
-
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.
|
|
582
668
|
The account's wallets are a collection under the same path (`client.wallets`), and
|
|
583
669
|
buyer-facing discovery is `client.payment_methods`.
|
|
584
670
|
|
|
@@ -838,7 +924,7 @@ broadcast may already be in flight.
|
|
|
838
924
|
## Project structure
|
|
839
925
|
|
|
840
926
|
```text
|
|
841
|
-
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
|
|
842
928
|
|
|
843
929
|
lib/rail0/
|
|
844
930
|
client.rb Rail0::Client — entry point
|
|
@@ -851,6 +937,7 @@ lib/rail0/
|
|
|
851
937
|
webhook_signature.rb Rail0::WebhookSignature — delivery verification
|
|
852
938
|
signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
|
|
853
939
|
stablecoins.rb stablecoin address registry
|
|
940
|
+
dispute_reasons.rb generated Rail0::DisputeReasons — the dispute-reason dictionary
|
|
854
941
|
types.rb generated Struct docs of the gateway schema (reference only)
|
|
855
942
|
version.rb Rail0::VERSION
|
|
856
943
|
resources/
|
|
@@ -878,7 +965,7 @@ bundle exec rubocop # style only
|
|
|
878
965
|
bundle exec rubocop -a # and fix what is safely fixable
|
|
879
966
|
bundle exec rspec # specs only
|
|
880
967
|
|
|
881
|
-
# 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:
|
|
882
969
|
# defaults to ../rail0-gateway/docs/openapi.json, or set RAIL0_SCHEMA_PATH.
|
|
883
970
|
ruby gen/generate.rb
|
|
884
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
|
|
@@ -102,6 +116,24 @@ module Rail0
|
|
|
102
116
|
http.get("/payments/#{segment(id)}")
|
|
103
117
|
end
|
|
104
118
|
|
|
119
|
+
# Set or clear a payment's description (PATCH /payments/{id}).
|
|
120
|
+
#
|
|
121
|
+
# The description is the only field this route edits: it is off-chain metadata, so
|
|
122
|
+
# it can change at any status, settled payments included. Only a participant (the
|
|
123
|
+
# payer or the payee) may edit it — a non-participant gets the same 404 as an unknown
|
|
124
|
+
# id, and a deactivated caller wallet a 403 `wallet_deactivated`.
|
|
125
|
+
#
|
|
126
|
+
# +description:+ is required, and is always sent: passing +nil+ sends JSON +null+,
|
|
127
|
+
# which clears it (as does +""+). Over 255 characters is a 422.
|
|
128
|
+
# (rail0-gateway#373)
|
|
129
|
+
#
|
|
130
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
131
|
+
# @param description [String, nil] The new description; +nil+ or +""+ clears it.
|
|
132
|
+
# @return [Hash] The updated payment, the same shape {get} returns.
|
|
133
|
+
def update(id, description:)
|
|
134
|
+
http.patch("/payments/#{segment(id)}", { description: description })
|
|
135
|
+
end
|
|
136
|
+
|
|
105
137
|
# List on-chain transactions for a payment.
|
|
106
138
|
# @param id [String] Payment UUID or rail0_id.
|
|
107
139
|
# @param operation [String, nil] Filter by operation (see {TRANSACTION_OPERATIONS} —
|
|
@@ -147,7 +179,9 @@ module Rail0
|
|
|
147
179
|
# signature, not retrying a send that never happened.
|
|
148
180
|
#
|
|
149
181
|
# The transaction id is resolved THROUGH the payment, so one belonging to another
|
|
150
|
-
# 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.
|
|
151
185
|
#
|
|
152
186
|
# @param id [String] Payment UUID or rail0_id.
|
|
153
187
|
# @param transaction_id [String] The transaction row to redrive.
|
|
@@ -290,8 +324,18 @@ module Rail0
|
|
|
290
324
|
end
|
|
291
325
|
|
|
292
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.
|
|
293
337
|
# @param id [String] Payment UUID or rail0_id.
|
|
294
|
-
# @param reason [String, nil]
|
|
338
|
+
# @param reason [String, Symbol, nil] A DisputeOpenReason code, or its bytes32 (0x…); nil for none.
|
|
295
339
|
# @return [Hash]
|
|
296
340
|
def dispute_prepare(id, reason: nil, idempotency_key: nil)
|
|
297
341
|
prepare_dispute("dispute/prepare", id, reason, idempotency_key: idempotency_key)
|
|
@@ -306,8 +350,15 @@ module Rail0
|
|
|
306
350
|
end
|
|
307
351
|
|
|
308
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+.
|
|
309
360
|
# @param id [String] Payment UUID or rail0_id.
|
|
310
|
-
# @param reason [String, nil]
|
|
361
|
+
# @param reason [String, Symbol, nil] A DisputeCloseReason code, or its bytes32 (0x…); nil for none.
|
|
311
362
|
# @return [Hash]
|
|
312
363
|
def close_dispute_prepare(id, reason: nil, idempotency_key: nil)
|
|
313
364
|
prepare_dispute("dispute/close/prepare", id, reason, idempotency_key: idempotency_key)
|
|
@@ -352,8 +403,11 @@ module Rail0
|
|
|
352
403
|
# +path+ is one of this class's own literals ("dispute/prepare",
|
|
353
404
|
# "dispute/close/prepare"), never caller input, so it is interpolated as-is:
|
|
354
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.
|
|
355
408
|
def prepare_dispute(path, id, reason, idempotency_key: nil)
|
|
356
|
-
|
|
409
|
+
value = reason.to_s.strip
|
|
410
|
+
body = value.empty? ? {} : { reason: value }
|
|
357
411
|
http.post("/payments/#{segment(id)}/#{path}", body,
|
|
358
412
|
headers: idempotency_headers(idempotency_key))
|
|
359
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
|
|
@@ -173,16 +216,6 @@ module Rail0
|
|
|
173
216
|
keyword_init: true
|
|
174
217
|
)
|
|
175
218
|
|
|
176
|
-
# A wallet's token holding, with the wallet, token, and blockchain nested via their own schemas.
|
|
177
|
-
WalletToken = Struct.new(
|
|
178
|
-
:default, # Boolean
|
|
179
|
-
:active, # Boolean
|
|
180
|
-
:wallet, # Wallet
|
|
181
|
-
:token, # Token
|
|
182
|
-
:blockchain, # Blockchain
|
|
183
|
-
keyword_init: true
|
|
184
|
-
)
|
|
185
|
-
|
|
186
219
|
# A wallet's token holding as nested under its wallet (GET /accounts/:id/wallets): the token plus
|
|
187
220
|
# this wallet's per-token flags, without re-nesting the wallet.
|
|
188
221
|
WalletTokenHolding = Struct.new(
|
|
@@ -239,14 +272,18 @@ module Rail0
|
|
|
239
272
|
|
|
240
273
|
# Buyer-driven, signal-only dispute lifecycle (no fund effect).
|
|
241
274
|
Dispute = Struct.new(
|
|
242
|
-
:id,
|
|
243
|
-
:payment_id,
|
|
244
|
-
:status,
|
|
245
|
-
: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.
|
|
246
281
|
:opened_block,
|
|
247
|
-
:opened_at,
|
|
282
|
+
:opened_at, # String
|
|
248
283
|
:closed_by,
|
|
249
|
-
: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.
|
|
250
287
|
:closed_block,
|
|
251
288
|
:closed_at,
|
|
252
289
|
keyword_init: true
|
|
@@ -297,15 +334,81 @@ module Rail0
|
|
|
297
334
|
)
|
|
298
335
|
|
|
299
336
|
# The account's own profile as the holder reads it (GET) and as a PATCH returns it — id, name,
|
|
300
|
-
# email, timestamps. Deliberately no admin/role field: the operator grant lives in a
|
|
301
|
-
# so a standard account's profile carries no trace of that axis. An ADMIN reading
|
|
302
|
-
# 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.
|
|
303
341
|
Account = Struct.new(
|
|
304
|
-
:id,
|
|
305
|
-
:name,
|
|
342
|
+
:id, # String
|
|
343
|
+
:name, # String
|
|
306
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
|
|
307
358
|
:created_at, # String
|
|
308
|
-
:
|
|
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
|
|
309
412
|
keyword_init: true
|
|
310
413
|
)
|
|
311
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-
|
|
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
|