rail0-sdk 1.8.1 → 1.9.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 +152 -3
- data/lib/rail0/client.rb +10 -1
- data/lib/rail0/error_hints.rb +20 -0
- data/lib/rail0/http_client.rb +2 -1
- data/lib/rail0/payout_verify.rb +211 -0
- data/lib/rail0/request.rb +19 -1
- data/lib/rail0/resources/chains.rb +51 -1
- data/lib/rail0/resources/payments.rb +8 -3
- data/lib/rail0/resources/payout_destinations.rb +149 -0
- data/lib/rail0/resources/payouts.rb +173 -0
- data/lib/rail0/resources/webhooks.rb +7 -0
- data/lib/rail0/signing.rb +46 -0
- data/lib/rail0/tx_verify.rb +3 -2
- data/lib/rail0/types.rb +144 -56
- data/lib/rail0/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e9c4a0434db81266c43b414c1f3f2f4b6f2466ef7aa9f1f4f1e1406542fabdf
|
|
4
|
+
data.tar.gz: a01332eba373c295821bd20edaa16076f4ef20b9fdb204064a0c9c378a5b8f1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: abc0ca11b0940752160e3503087fd89d62c1e8ba04b5c6412cdef41751b598b25acd988fb52e4b04aa5a2c2e64c6fafdf0a72625eceb98238e0e2abd6a955e55
|
|
7
|
+
data.tar.gz: 9267bc67421f3aa0867c5c9b24a959679d1c648ea5e9598df149743a06efa76db088ea9f1b21a39a46c0f2974376ceadf783e34a65b59f1d846a5ae603c08464
|
data/README.md
CHANGED
|
@@ -301,11 +301,17 @@ logging — it says exactly which sessions died, which `revoked_all: true` canno
|
|
|
301
301
|
```ruby
|
|
302
302
|
client.chains.list # GET /blockchains (each chain's :contract is the active RAIL0 deployment, :settlement its measured confirm time)
|
|
303
303
|
client.chains.list(network_type: "testnet", symbol: "ETH")
|
|
304
|
+
Rail0::Resources::Chains.resolve(client.chains.list(network_type: "testnet"), "arc") # by slug (case-insensitive) or chain id; raises on no match / an ambiguous slug
|
|
304
305
|
client.tokens.list # GET /tokens — every token, retired ones included
|
|
305
306
|
client.tokens.list(chain_id: 84532, symbol: "USDC")
|
|
306
307
|
client.tokens.list(active: true) # what a NEW payment may use
|
|
307
308
|
```
|
|
308
309
|
|
|
310
|
+
Each chain carries a `slug` (e.g. `"arc"`, shared by its testnet and mainnet, unique per
|
|
311
|
+
`network_type`) and `public_rpc_urls` (keyless RPC endpoints the gateway publishes for
|
|
312
|
+
clients). `Chains.resolve(chains, ref)` picks one by chain id or slug without a request; a
|
|
313
|
+
slug matching both networks in the list raises, so filter with `network_type:` or pass the id.
|
|
314
|
+
|
|
309
315
|
Each chain's `settlement` is how long an operation on it has taken to confirm **on this
|
|
310
316
|
gateway**, end to end (the chain's finality lag plus the gateway/indexer pipeline):
|
|
311
317
|
`{ p50_seconds:, p90_seconds:, sample_size:, window_days: }`, percentiles of broadcast →
|
|
@@ -335,7 +341,7 @@ client.health.get # GET /health → { status:, api_version:, contract_version:
|
|
|
335
341
|
|
|
336
342
|
## Pagination
|
|
337
343
|
|
|
338
|
-
Paginated calls return `{ data:, meta: }`. `meta` is `{ page:, per_page:, total:, total_pages:, links: }`.
|
|
344
|
+
Paginated calls return `{ data:, meta: }`. `meta` is `{ page:, per_page:, total:, total_pages:, links:, next_cursor: }`.
|
|
339
345
|
|
|
340
346
|
`page` is 1-based and bounded to 1..1,000,000 — a value outside is a 400, not an empty
|
|
341
347
|
page. `per_page` defaults to 25 and is capped at 100.
|
|
@@ -349,6 +355,22 @@ while page[:meta][:links][:next]
|
|
|
349
355
|
end
|
|
350
356
|
```
|
|
351
357
|
|
|
358
|
+
`payments.list` and `payouts.list` also page by **cursor**: pass `after:` (omit it on the
|
|
359
|
+
first page) and follow `meta[:next_cursor]` until it is `nil`. It walks newest first at a
|
|
360
|
+
constant cost per page and carries no total (`total` reads 0); `page:` or a sort other than
|
|
361
|
+
`-created_at` beside `after:` is 422 `invalid_cursor`. `next_cursor` comes from the
|
|
362
|
+
`x-next-cursor` header, or the Link `next`'s `after` when the header is missing, and is
|
|
363
|
+
`nil` on offset pages.
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
cursor = nil
|
|
367
|
+
loop do
|
|
368
|
+
page = client.payouts.list(per_page: 100, after: cursor)
|
|
369
|
+
page[:data].each { |payout| puts payout[:id] }
|
|
370
|
+
break unless (cursor = page[:meta][:next_cursor])
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
352
374
|
### Idempotent prepares
|
|
353
375
|
|
|
354
376
|
Every `*_prepare` takes an optional `idempotency_key:`. Without one, a retry that arrives after the first transaction was signed and broadcast opens a **second** one — right for a genuine sequential partial capture, wrong for a retry, and only the caller can tell those apart. Replaying a key returns the first transaction; the same key with different terms is refused `422 idempotency_key_reused`.
|
|
@@ -625,12 +647,133 @@ client.payments.submit(id, "capture", { signed_transaction: raw })
|
|
|
625
647
|
client.payments.submit_by_hash(id, "capture", { transaction_hash: "0x…" })
|
|
626
648
|
```
|
|
627
649
|
|
|
650
|
+
## Payouts (JWT)
|
|
651
|
+
|
|
652
|
+
A payout moves the merchant wallet's balance to a **payout destination** — a deposit
|
|
653
|
+
address at an exchange, Circle Mint, an off-ramp provider or your own custody. It is a
|
|
654
|
+
plain ERC-20 `transfer(address,uint256)` from the wallet, signed by the destination's own
|
|
655
|
+
wallet; rail0 never holds the key. Destination amounts (`reserve_amount`, `min_amount`)
|
|
656
|
+
are **base-unit** strings; the `*_formatted` fields beside them carry the human decimals.
|
|
657
|
+
|
|
658
|
+
### 1. Register a destination (with the confirmation)
|
|
659
|
+
|
|
660
|
+
Generate the deposit address at the venue **with the network set to the payout's chain**,
|
|
661
|
+
note the venue's minimum deposit, then register it. `confirm_details:` is required and has
|
|
662
|
+
no default: `true` is the merchant's acceptance of the gateway's `details_v1` text
|
|
663
|
+
(`Rail0::Resources::PayoutDestinations::CONFIRM_DETAILS_TEXT`), recorded with who and when:
|
|
664
|
+
|
|
665
|
+
> I have checked the address, network, token and minimum deposit on the venue's deposit
|
|
666
|
+
> page and confirm they are correct. Funds sent to a wrong address or network, or below the
|
|
667
|
+
> venue's minimum, may be permanently lost and cannot be recovered by rail0.
|
|
668
|
+
|
|
669
|
+
Show that text and pass the merchant's answer; anything but `true` is 422
|
|
670
|
+
`confirmation_required` and nothing is saved.
|
|
671
|
+
|
|
672
|
+
```ruby
|
|
673
|
+
dest = client.payout_destinations.create(
|
|
674
|
+
wallet_id: WALLET_ID, # the session's own wallet (uuid or address)
|
|
675
|
+
chain_id: 5042002, token: USDC, address: VENUE_DEPOSIT_ADDRESS,
|
|
676
|
+
label: "Kraken", kind: "exchange", # exchange | circle_mint | self_custody | provider
|
|
677
|
+
min_amount: "5000000", # the venue's minimum, base units (5 USDC); omit for none
|
|
678
|
+
confirm_details: merchant_accepted # true only once the merchant accepted the text above
|
|
679
|
+
)
|
|
680
|
+
dest[:status] # => "unverified"
|
|
681
|
+
|
|
682
|
+
client.payout_destinations.list(status: "verified") # paged like payments
|
|
683
|
+
client.payout_destinations.get(dest[:id])
|
|
684
|
+
client.payout_destinations.events(dest[:id]) # audit trail, newest first
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
### 2. Prove it: proof → confirm
|
|
688
|
+
|
|
689
|
+
An unverified destination can receive the **proof** amount at most: a small test transfer
|
|
690
|
+
(the gateway's proof amount, raised to `min_amount` when that is higher). Send it, wait for
|
|
691
|
+
it to confirm on-chain, check that the venue **credited** it, then confirm:
|
|
692
|
+
|
|
693
|
+
```ruby
|
|
694
|
+
proof = client.payouts.prepare(destination_id: dest[:id], purpose: "proof")
|
|
695
|
+
client.payouts.sign_and_submit(proof, MERCHANT_PRIVATE_KEY, destination: dest)
|
|
696
|
+
# … poll client.payouts.get(proof[:id]) until status == "confirmed", check the venue …
|
|
697
|
+
client.payout_destinations.confirm(dest[:id]) # => status "verified"
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
### 3. Pay out: prepare → verify → sign → submit
|
|
701
|
+
|
|
702
|
+
`amount` is a **human decimal** or `"all"` (the balance minus the reserve, below). A payout
|
|
703
|
+
below the destination's `min_amount` is refused 422 `payout_below_minimum`.
|
|
704
|
+
|
|
705
|
+
```ruby
|
|
706
|
+
payout = client.payouts.prepare(destination_id: dest[:id], amount: "250.00")
|
|
707
|
+
client.payouts.sign_and_submit(payout, MERCHANT_PRIVATE_KEY,
|
|
708
|
+
destination: dest, amount: "250.00", decimals: 6)
|
|
709
|
+
# 202, now "submitted"; poll client.payouts.get(payout[:id]) (or the payouts.confirmed /
|
|
710
|
+
# payouts.failed webhooks) until it settles
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
`sign_and_submit` is `Rail0::Signing.sign_payout` + `payouts.submit`. Before the key touches
|
|
714
|
+
the prepared `unsigned_transaction`, `Rail0::PayoutVerify.verify` holds it to exactly:
|
|
715
|
+
`to` = the payout's token, `value` 0, `chain_id` = the payout's chain, and calldata
|
|
716
|
+
`0xa9059cbb` + abi(the payout's address, the payout's base-unit amount) with nothing
|
|
717
|
+
trailing. With `destination:` the payout must also be for that registered destination
|
|
718
|
+
(chain, token, address); with `amount:` + `decimals:` its amount must be exactly the one you
|
|
719
|
+
asked for. Anything else raises `Rail0::TxVerify::RefusedSignature` and nothing is signed or
|
|
720
|
+
sent. There is no blind form.
|
|
721
|
+
|
|
722
|
+
The two steps apart, or the other way to send:
|
|
723
|
+
|
|
724
|
+
```ruby
|
|
725
|
+
raw = Rail0::Signing.sign_payout(payout, MERCHANT_PRIVATE_KEY, destination: dest,
|
|
726
|
+
amount: "250.00", decimals: 6)
|
|
727
|
+
client.payouts.submit(payout[:id], { signed_transaction: raw }) # the gateway broadcasts
|
|
728
|
+
|
|
729
|
+
# A wallet that signs and broadcasts itself: verify, send, then report the hash.
|
|
730
|
+
Rail0::PayoutVerify.verify(payout[:unsigned_transaction], payout: payout, destination: dest,
|
|
731
|
+
amount: "250.00", decimals: 6)
|
|
732
|
+
client.payouts.submit_by_hash(payout[:id], { transaction_hash: "0x…" })
|
|
733
|
+
|
|
734
|
+
client.payouts.resend(payout[:id]) # re-broadcast the stored signed bytes (after a 502)
|
|
735
|
+
client.payouts.cancel(payout[:id]) # an UNSIGNED pending payout only
|
|
736
|
+
client.payouts.list(status: %w[pending submitted], purpose: "payout")
|
|
737
|
+
client.payouts.events(payout[:id])
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
One pending payout per destination: preparing again replaces it while it is unsigned. Once
|
|
741
|
+
a signed transaction is stored, it may still be mined, so it cannot be re-prepared or
|
|
742
|
+
cancelled (409 `payout_already_signed`): `resend` it and let it settle.
|
|
743
|
+
|
|
744
|
+
A runnable end-to-end flow (register → proof → confirm → payout → poll) is in
|
|
745
|
+
[`examples/06_payouts.rb`](examples/06_payouts.rb).
|
|
746
|
+
|
|
747
|
+
### Minimum and reserve
|
|
748
|
+
|
|
749
|
+
- **Minimum** (`min_amount`): the venue's minimum deposit. Below it the venue may never
|
|
750
|
+
credit a transfer, so the gateway refuses it. Changing it needs the same confirmation, and
|
|
751
|
+
raising it above the latest proof takes the verification away (prove it again):
|
|
752
|
+
|
|
753
|
+
```ruby
|
|
754
|
+
client.payout_destinations.update(dest[:id], min_amount: "10000000", confirm_details: merchant_accepted)
|
|
755
|
+
client.payout_destinations.update(dest[:id], min_amount: "", confirm_details: merchant_accepted) # none
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
- **Reserve** (`reserve_amount`): what an `"all"` payout leaves in the wallet so refunds can
|
|
759
|
+
still be paid. Unset, the gateway's suggestion applies (`suggested_reserve`: the refundable
|
|
760
|
+
balance of payments still inside their refund window).
|
|
761
|
+
|
|
762
|
+
```ruby
|
|
763
|
+
client.payout_destinations.update(dest[:id], reserve_amount: "100000000")
|
|
764
|
+
client.payout_destinations.update(dest[:id], reserve_amount: "") # back to the suggestion
|
|
765
|
+
client.payout_destinations.update(dest[:id], active: false) # disable (true re-enables)
|
|
766
|
+
```
|
|
767
|
+
|
|
628
768
|
## Webhooks (JWT)
|
|
629
769
|
|
|
630
770
|
One subscription carries a **set** of topics (see `Rail0::Resources::Webhooks::TOPICS`):
|
|
631
771
|
one shared secret and one circuit breaker for all of them, with each delivery naming the
|
|
632
772
|
event that fired in `X-Rail0-Topic`. Two subscriptions for the same `callback_url` must not
|
|
633
|
-
overlap — the gateway answers 409 and names the topic that collided.
|
|
773
|
+
overlap — the gateway answers 409 and names the topic that collided. The payout topics —
|
|
774
|
+
`payouts.confirmed`, `payouts.failed` (the reason is the payout's `error_code`) and
|
|
775
|
+
`payout_destinations.verified` — carry the same envelope (`id`, `emitted_at`, `topic`) with
|
|
776
|
+
the subject under `payout` or `payout_destination` instead of `payment`/`transaction`.
|
|
634
777
|
|
|
635
778
|
```ruby
|
|
636
779
|
hook = client.webhooks.create(
|
|
@@ -867,6 +1010,8 @@ Requires the `eth` gem. No private key ever leaves your process.
|
|
|
867
1010
|
| `Rail0::PayloadVerify.verify(signing_payload, payment:, signer:, amount: nil)` | The payload pre-sign check on its own — run it before handing the payload to a browser wallet |
|
|
868
1011
|
| `sign_transaction(unsigned_transaction, key, payment:, operation:, amount: nil)` | Verify a prepare step's unsigned EIP-1559 transaction against the payment, then sign it; returns the 0x raw tx. Without `payment:` it signs blindly (**deprecated**) |
|
|
869
1012
|
| `Rail0::TxVerify.verify(unsigned_transaction, payment:, operation:, amount: nil, signer: nil)` | The pre-sign check on its own — for a signer that is not a local key |
|
|
1013
|
+
| `sign_payout(payout, key, destination: nil, amount: nil, decimals: nil)` | Verify a prepared payout's ERC-20 transfer (see [Payouts](#payouts-jwt)), then sign it; returns the 0x raw tx. No blind form |
|
|
1014
|
+
| `Rail0::PayoutVerify.verify(unsigned_transaction, payout:, destination: nil, amount: nil, decimals: nil, signer: nil)` | The payout pre-sign check on its own — for a wallet that broadcasts itself |
|
|
870
1015
|
| `sign_transfer_with_authorization(signer, domain, params)` | Raw EIP-3009 `TransferWithAuthorization` signer, for talking to a token directly |
|
|
871
1016
|
|
|
872
1017
|
```ruby
|
|
@@ -1058,6 +1203,7 @@ lib/rail0/
|
|
|
1058
1203
|
signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
|
|
1059
1204
|
tx_verify.rb Rail0::TxVerify — pre-sign check of an unsigned transaction
|
|
1060
1205
|
payload_verify.rb Rail0::PayloadVerify — pre-sign check of an EIP-3009 signing payload
|
|
1206
|
+
payout_verify.rb Rail0::PayoutVerify — pre-sign check of a payout's ERC-20 transfer
|
|
1061
1207
|
stablecoins.rb stablecoin address registry
|
|
1062
1208
|
dispute_reasons.rb generated Rail0::DisputeReasons — the dispute-reason dictionary
|
|
1063
1209
|
types.rb generated Struct docs of the gateway schema (reference only)
|
|
@@ -1074,6 +1220,8 @@ lib/rail0/
|
|
|
1074
1220
|
accounts.rb the caller's own account profile, read and update (JWT)
|
|
1075
1221
|
webhooks.rb webhook subscription management (JWT)
|
|
1076
1222
|
analytics.rb account-scoped payment analytics (JWT)
|
|
1223
|
+
payout_destinations.rb payout destinations: register, confirm, update (JWT)
|
|
1224
|
+
payouts.rb merchant payouts: prepare, sign, submit (JWT)
|
|
1077
1225
|
query.rb shared query-string and path-segment helpers
|
|
1078
1226
|
```
|
|
1079
1227
|
|
|
@@ -1115,7 +1263,8 @@ A release is an approved pull request: merging it is what publishes.
|
|
|
1115
1263
|
**Or on a feature branch**, to release a change in the same review: run it on a clean
|
|
1116
1264
|
branch that is pushed, contains `origin/main` and has an open pull request into `main`.
|
|
1117
1265
|
The bump is committed and pushed on that branch (`chore(release): vX.Y.Z`), the pull
|
|
1118
|
-
request's own title counts toward the version
|
|
1266
|
+
request's own title counts toward the version, gets a line in the notes and gains a
|
|
1267
|
+
` (release vX.Y.Z)` suffix, and the
|
|
1119
1268
|
notes go into its description between `<!-- release:start -->` markers — the text above
|
|
1120
1269
|
them is kept. Re-run it after new commits or a merge of `main`: it refreshes the version
|
|
1121
1270
|
and the notes instead of bumping twice. One approval then covers the change and its
|
data/lib/rail0/client.rb
CHANGED
|
@@ -12,6 +12,8 @@ require_relative "resources/payments"
|
|
|
12
12
|
require_relative "resources/disputes"
|
|
13
13
|
require_relative "resources/webhooks"
|
|
14
14
|
require_relative "resources/analytics"
|
|
15
|
+
require_relative "resources/payout_destinations"
|
|
16
|
+
require_relative "resources/payouts"
|
|
15
17
|
|
|
16
18
|
module Rail0
|
|
17
19
|
# Entry point for the RAIL0 SDK.
|
|
@@ -48,8 +50,13 @@ module Rail0
|
|
|
48
50
|
# @return [Resources::Webhooks] Webhook subscription management (JWT).
|
|
49
51
|
# @!attribute [r] analytics
|
|
50
52
|
# @return [Resources::Analytics] Account-scoped payment analytics (JWT).
|
|
53
|
+
# @!attribute [r] payout_destinations
|
|
54
|
+
# @return [Resources::PayoutDestinations] Where the merchant's payouts go (JWT).
|
|
55
|
+
# @!attribute [r] payouts
|
|
56
|
+
# @return [Resources::Payouts] Merchant payouts: prepare, sign, submit (JWT).
|
|
51
57
|
attr_reader :auth, :chains, :tokens, :health, :payment_methods,
|
|
52
|
-
:accounts, :wallets, :payments, :disputes, :webhooks, :analytics
|
|
58
|
+
:accounts, :wallets, :payments, :disputes, :webhooks, :analytics,
|
|
59
|
+
:payout_destinations, :payouts
|
|
53
60
|
|
|
54
61
|
# @param base_url [String] Base URL of the RAIL0 API, e.g. "https://api.rail0.xyz".
|
|
55
62
|
# @param headers [Hash] Default headers merged into every request (e.g. Authorization).
|
|
@@ -83,6 +90,8 @@ module Rail0
|
|
|
83
90
|
@disputes = Resources::Disputes.new(http)
|
|
84
91
|
@webhooks = Resources::Webhooks.new(http)
|
|
85
92
|
@analytics = Resources::Analytics.new(http)
|
|
93
|
+
@payout_destinations = Resources::PayoutDestinations.new(http)
|
|
94
|
+
@payouts = Resources::Payouts.new(http)
|
|
86
95
|
freeze
|
|
87
96
|
end
|
|
88
97
|
end
|
data/lib/rail0/error_hints.rb
CHANGED
|
@@ -113,6 +113,26 @@ module Rail0
|
|
|
113
113
|
"onboarding_link_invalid" => "the link was altered or its request no longer exists — use the link in the most recent email, or ask for a new one",
|
|
114
114
|
"onboarding_link_expired" => "the link has expired — sign up again, or ask your RAIL0 contact for a new link",
|
|
115
115
|
"onboarding_link_superseded" => "a newer link was sent for this request and this one no longer works — use the link in the most recent email",
|
|
116
|
+
# Payouts (gateway#455): the merchant's transfer of its balance to a payout destination.
|
|
117
|
+
"confirmation_required" => "registering a payout destination, or changing its min_amount, needs confirm_details: true — the merchant's confirmation that they checked the address, network, token and minimum deposit at the venue (PayoutDestinations::CONFIRM_DETAILS_TEXT)",
|
|
118
|
+
"payout_destination_exists" => "this wallet already has a destination with this address for this token and chain — use it (payout_destinations.list)",
|
|
119
|
+
"payout_destination_zero_address" => "the zero address cannot receive a payout — register the venue's deposit address",
|
|
120
|
+
"payout_destination_own_wallet" => "the destination is the paying wallet itself — register the deposit address of the exchange, Circle Mint account or another wallet you control",
|
|
121
|
+
"payout_destination_is_contract" => "the address is a RAIL0 contract or a token contract on this chain, where funds cannot be recovered — register the venue's deposit address",
|
|
122
|
+
"payout_destination_disabled" => "the destination is disabled — re-enable it (payout_destinations.update(id, active: true)) or choose another one",
|
|
123
|
+
"payout_destination_unverified" => "the destination is not verified: send the proof (payouts.prepare(purpose: \"proof\")), wait for it to confirm, check the venue credited it, then payout_destinations.confirm — until then only the proof amount can be sent",
|
|
124
|
+
"payout_destination_proof_missing" => "the proof transfer has not confirmed on-chain yet — prepare, sign and submit it (purpose: \"proof\"), wait for payouts.get to say confirmed, then confirm the destination",
|
|
125
|
+
"payout_below_minimum" => "the amount is below the destination's min_amount (the venue may never credit it) — send at least the minimum; for \"all\", the balance after the reserve is below it",
|
|
126
|
+
"insufficient_balance" => "the wallet does not hold enough of the token for this amount after the refund reserve — the detail names what is available",
|
|
127
|
+
"payout_limit_reached" => "this wallet reached its hourly payout limit — retry later",
|
|
128
|
+
"payout_already_signed" => "the payout already holds a signed transaction that may still be mined — resend it (payouts.resend) and let it settle; it cannot be re-prepared or cancelled",
|
|
129
|
+
"payout_not_pending" => "the payout is already submitted or settled — prepare a new one",
|
|
130
|
+
"payout_not_signed" => "the payout holds no signed transaction to resend — submit the signed transfer first",
|
|
131
|
+
"payout_hash_in_use" => "another payout already records this transaction hash — one transaction settles one payout",
|
|
132
|
+
"foreign_transfer_hash" => "the reported transaction does not move this payout's amount of its token to its destination — report the hash of the transfer you broadcast for this payout",
|
|
133
|
+
"invalid_amount" => "the amount is not valid: base units (digits only, below 2^120) for reserve_amount/min_amount, a positive decimal within the token's decimals elsewhere",
|
|
134
|
+
"invalid_transaction" => "the signed transaction could not be decoded, or its sender is not allowed here — sign the prepare's unsigned_transaction unmodified with the right wallet",
|
|
135
|
+
"unknown_chain" => "that chain id is not configured on this gateway — see chains.list",
|
|
116
136
|
# A BARE forbidden is not a party mismatch: the gateway split those into codes of
|
|
117
137
|
# their own (not_the_payee, not_the_payer, wallet_deactivated, account_deactivated,
|
|
118
138
|
# not_your_account) because they need different fixes. This entry kept describing
|
data/lib/rail0/http_client.rb
CHANGED
|
@@ -64,7 +64,8 @@ module Rail0
|
|
|
64
64
|
# headers (not a {data, meta} envelope), so this reads the meta back from the
|
|
65
65
|
# headers and wraps the array. Non-paginated array endpoints (blockchains,
|
|
66
66
|
# tokens, payment_methods) use plain #get instead.
|
|
67
|
-
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total
|
|
67
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total:, total_pages:, links:,
|
|
68
|
+
# next_cursor: } } — next_cursor is set only on a cursor-paged list with a next page.
|
|
68
69
|
def get_list(path)
|
|
69
70
|
request(:get, path, nil, paginated: true)
|
|
70
71
|
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tx_verify"
|
|
4
|
+
|
|
5
|
+
module Rail0
|
|
6
|
+
# Pre-sign verification of a payout's +unsigned_transaction+ — {Rail0::TxVerify}'s
|
|
7
|
+
# counterpart for the merchant payout (POST /payouts/prepare).
|
|
8
|
+
#
|
|
9
|
+
# A payout is not a RAIL0 call: it is a plain ERC-20 +transfer(address,uint256)+ from the
|
|
10
|
+
# merchant's wallet, addressed to the TOKEN contract. For a plain transfer the calldata IS
|
|
11
|
+
# the money, so nothing in it may be skipped: a gateway endpoint that answered with
|
|
12
|
+
# +approve(attacker, MAX)+, a transfer to another address, a larger amount, another token
|
|
13
|
+
# or another chain would otherwise have the merchant's key sign it. Every check below runs
|
|
14
|
+
# on data the caller already holds, before the key touches the transaction. In order:
|
|
15
|
+
#
|
|
16
|
+
# 1. the JSON parses;
|
|
17
|
+
# 2. +value+ is zero — an ERC-20 transfer attaches no native currency;
|
|
18
|
+
# 3. +chain_id+ is the payout's +chain_id+;
|
|
19
|
+
# 4. +from+, when present, is the signing key's address;
|
|
20
|
+
# 5. +to+ is an address and is the payout's +token+ (case-insensitive) — an empty +to+
|
|
21
|
+
# would be a contract creation and always refuses;
|
|
22
|
+
# 6. +data+ is EXACTLY +0xa9059cbb+ ++ abi(address = the payout's +address+, uint256 = the
|
|
23
|
+
# payout's base-unit +amount+), nothing trailing.
|
|
24
|
+
#
|
|
25
|
+
# With +destination:+ (the record the merchant registered, from
|
|
26
|
+
# {Resources::PayoutDestinations#get}) the payout itself is first held to it: same
|
|
27
|
+
# destination id, chain, token and recipient address — so a payout "for" this destination
|
|
28
|
+
# that pays elsewhere refuses. With +amount:+ (the human decimal the caller asked
|
|
29
|
+
# {Resources::Payouts#prepare} for, not "all") the payout's base-unit amount must be exactly
|
|
30
|
+
# that amount scaled by +decimals:+.
|
|
31
|
+
#
|
|
32
|
+
# FAIL-CLOSED: a payout record missing its chain_id, token, address or a base-unit amount,
|
|
33
|
+
# or an explicit +amount:+ without +decimals:+, refuses rather than skips. A refusal raises
|
|
34
|
+
# {Rail0::TxVerify::RefusedSignature} ("refusing to sign: …", naming the field).
|
|
35
|
+
#
|
|
36
|
+
# Mirrors rail0-admin's lib/onchain-guard.ts (+assertTxTarget+ with a transfer, and
|
|
37
|
+
# +assertTransferCall+). Loaded by +rail0/signing+, never by +require "rail0"+ alone.
|
|
38
|
+
module PayoutVerify
|
|
39
|
+
# The 4-byte selector of ERC-20 +transfer(address,uint256)+ — keccak256 of that
|
|
40
|
+
# signature, pinned in the spec.
|
|
41
|
+
TRANSFER_SELECTOR = "a9059cbb"
|
|
42
|
+
|
|
43
|
+
# The one function a payout may call.
|
|
44
|
+
TRANSFER_SIGNATURE = "transfer(address,uint256)"
|
|
45
|
+
|
|
46
|
+
WORD_HEX = 64
|
|
47
|
+
private_constant :WORD_HEX
|
|
48
|
+
|
|
49
|
+
# Verifies +unsigned_transaction+ against +payout+ (and +destination+ / +amount+ when
|
|
50
|
+
# given), BEFORE any key touches it. Raises {Rail0::TxVerify::RefusedSignature} on the
|
|
51
|
+
# first mismatch; returns the parsed, string-keyed transaction otherwise.
|
|
52
|
+
#
|
|
53
|
+
# @param unsigned_transaction [String, Hash] the payout's +unsigned_transaction+
|
|
54
|
+
# @param payout [Hash] the prepared payout (+chain_id+, +token+, +address+, +amount+)
|
|
55
|
+
# @param destination [Hash, nil] the registered destination the payout must be for
|
|
56
|
+
# @param amount [String, nil] the human decimal passed to prepare; nil or "all" skips
|
|
57
|
+
# only this comparison (the gateway chose the amount: a proof, or the balance)
|
|
58
|
+
# @param decimals [Integer, nil] the token's decimals, required with an explicit +amount+
|
|
59
|
+
# @param signer [String, nil] the address of the key about to sign, or nil to skip
|
|
60
|
+
# @return [Hash] the transaction, string-keyed
|
|
61
|
+
def self.verify(unsigned_transaction, payout:, destination: nil, amount: nil, decimals: nil, signer: nil)
|
|
62
|
+
tx = parse(unsigned_transaction)
|
|
63
|
+
pay = payout_fields(payout)
|
|
64
|
+
check_destination(pay, destination) unless destination.nil?
|
|
65
|
+
check_amount(pay[:amount], amount, decimals)
|
|
66
|
+
check_header(tx, pay, signer)
|
|
67
|
+
check_call(tx["data"], pay[:address], pay[:amount])
|
|
68
|
+
tx
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.parse(unsigned_transaction)
|
|
72
|
+
refuse("the payout carries no unsigned transaction") if blank?(unsigned_transaction)
|
|
73
|
+
tx = unsigned_transaction.is_a?(String) ? JSON.parse(unsigned_transaction) : unsigned_transaction
|
|
74
|
+
refuse("the unsigned transaction is not an object") unless tx.is_a?(Hash)
|
|
75
|
+
tx.transform_keys(&:to_s)
|
|
76
|
+
rescue JSON::ParserError => e
|
|
77
|
+
refuse("the gateway's unsigned transaction is not valid JSON: #{e.message}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The payout fields the transaction is held to. Every one is required: for a plain
|
|
81
|
+
# transfer there is nothing else to compare against.
|
|
82
|
+
def self.payout_fields(payout)
|
|
83
|
+
refuse("cannot check the transaction without the payout it is for") unless payout.is_a?(Hash)
|
|
84
|
+
p = symbolize(payout)
|
|
85
|
+
refuse("the payout's chain_id is unknown, so the chain cannot be checked") unless decimal?(p[:chain_id])
|
|
86
|
+
refuse("the payout's token is not an address, so the transfer cannot be checked") unless address?(p[:token])
|
|
87
|
+
refuse("the payout's address is not an address, so the transfer cannot be checked") unless address?(p[:address])
|
|
88
|
+
refuse("the payout's amount is not base units, so the transfer cannot be checked") unless decimal?(p[:amount])
|
|
89
|
+
|
|
90
|
+
{ id: p[:id], destination_id: p[:destination_id], chain_id: Integer(p[:chain_id].to_s, 10),
|
|
91
|
+
token: p[:token], address: p[:address], amount: Integer(p[:amount].to_s, 10) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private_class_method :parse, :payout_fields
|
|
95
|
+
|
|
96
|
+
# The payout must be FOR the destination the merchant registered: the gateway answers
|
|
97
|
+
# both, but the destination is the record the merchant typed and confirmed, so a payout
|
|
98
|
+
# that disagrees with it is one stitched from somewhere else.
|
|
99
|
+
def self.check_destination(pay, destination)
|
|
100
|
+
refuse("the destination is not a record") unless destination.is_a?(Hash)
|
|
101
|
+
d = symbolize(destination)
|
|
102
|
+
check_destination_id(pay[:destination_id], d[:id])
|
|
103
|
+
unless decimal?(d[:chain_id]) && Integer(d[:chain_id].to_s, 10) == pay[:chain_id]
|
|
104
|
+
refuse("the payout is on chain #{pay[:chain_id]} but the destination is on chain #{d[:chain_id].inspect}")
|
|
105
|
+
end
|
|
106
|
+
unless same_address?(d[:token], pay[:token])
|
|
107
|
+
refuse("the payout moves token #{pay[:token]} but the destination is for #{d[:token].inspect}")
|
|
108
|
+
end
|
|
109
|
+
return if same_address?(d[:address], pay[:address])
|
|
110
|
+
|
|
111
|
+
refuse("the payout pays #{pay[:address]} but the destination's address is #{d[:address].inspect}")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.check_destination_id(got, want)
|
|
115
|
+
return if blank?(got) || blank?(want) || got.to_s == want.to_s
|
|
116
|
+
|
|
117
|
+
refuse("the payout is for destination #{got} but you asked for #{want}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The caller's own amount, scaled locally, against the payout's base-unit amount. "all"
|
|
121
|
+
# and nil leave the amount to the gateway (the balance after the reserve, or the proof).
|
|
122
|
+
def self.check_amount(payout_units, amount, decimals)
|
|
123
|
+
return if blank?(amount) || amount.to_s.strip.casecmp("all").zero?
|
|
124
|
+
|
|
125
|
+
if decimals.nil?
|
|
126
|
+
refuse("the token's decimals are unknown, so the payout amount #{amount.to_s.inspect} cannot be checked")
|
|
127
|
+
end
|
|
128
|
+
want = Integer(TxVerify.to_base_units(amount.to_s, Integer(decimals)), 10)
|
|
129
|
+
return if want == payout_units
|
|
130
|
+
|
|
131
|
+
refuse("the payout moves #{payout_units} base units but you asked for #{want}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private_class_method :check_destination, :check_destination_id, :check_amount
|
|
135
|
+
|
|
136
|
+
# Checks 2-5: the fields outside the calldata.
|
|
137
|
+
def self.check_header(fields, pay, signer)
|
|
138
|
+
unless TxVerify.zero_native_value?(fields["value"])
|
|
139
|
+
refuse("transaction attaches native currency (value=#{fields['value'].inspect}); an ERC-20 transfer is value-0")
|
|
140
|
+
end
|
|
141
|
+
unless decimal?(fields["chain_id"]) && Integer(fields["chain_id"].to_s, 10) == pay[:chain_id]
|
|
142
|
+
refuse("transaction targets chain #{fields['chain_id'].inspect} but this payout is on chain #{pay[:chain_id]}")
|
|
143
|
+
end
|
|
144
|
+
from = fields["from"].to_s
|
|
145
|
+
if signer && !from.empty? && !same_address?(from, signer)
|
|
146
|
+
refuse("transaction is prepared from #{from} but the signing key is #{signer}")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
to = fields["to"].to_s.strip
|
|
150
|
+
refuse("the transaction has no `to` address; signing it would deploy a contract") if to.empty?
|
|
151
|
+
refuse("the transaction's `to` #{to.inspect} is not an address") unless address?(to)
|
|
152
|
+
return if same_address?(to, pay[:token])
|
|
153
|
+
|
|
154
|
+
refuse("transaction is addressed to #{to} but this payout's token is #{pay[:token]}")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Check 6: the calldata, decoded word by word so the refusal names what differs, then
|
|
158
|
+
# its length — a transfer is exactly selector + two words, and anything appended is
|
|
159
|
+
# refused rather than ignored (the token would ignore it; the signature would not).
|
|
160
|
+
def self.check_call(data, address, units)
|
|
161
|
+
raw = data.to_s.strip.downcase.delete_prefix("0x")
|
|
162
|
+
refuse("calldata is not hex") unless raw.match?(/\A\h*\z/)
|
|
163
|
+
refuse("calldata is #{raw.length} hex chars, too short to carry a function selector") if raw.length < 8
|
|
164
|
+
selector = raw[0, 8]
|
|
165
|
+
unless selector == TRANSFER_SELECTOR
|
|
166
|
+
refuse("the call is selector 0x#{selector}, not #{TRANSFER_SIGNATURE} (0x#{TRANSFER_SELECTOR})")
|
|
167
|
+
end
|
|
168
|
+
want_len = 8 + (2 * WORD_HEX)
|
|
169
|
+
refuse("calldata is #{raw.length} hex chars, too short for #{TRANSFER_SIGNATURE}") if raw.length < want_len
|
|
170
|
+
|
|
171
|
+
recipient = raw[8, WORD_HEX]
|
|
172
|
+
want_recipient = address.downcase.delete_prefix("0x").rjust(WORD_HEX, "0")
|
|
173
|
+
unless recipient == want_recipient
|
|
174
|
+
refuse("the transfer pays 0x#{recipient[-40..]} but the payout destination is #{address}")
|
|
175
|
+
end
|
|
176
|
+
got_units = raw[8 + WORD_HEX, WORD_HEX].to_i(16)
|
|
177
|
+
refuse("the transfer moves #{got_units} base units but the payout is for #{units}") unless got_units == units
|
|
178
|
+
return if raw.length == want_len
|
|
179
|
+
|
|
180
|
+
refuse("calldata carries #{(raw.length - want_len) / 2} trailing bytes after #{TRANSFER_SIGNATURE}")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private_class_method :check_header, :check_call
|
|
184
|
+
|
|
185
|
+
def self.refuse(message)
|
|
186
|
+
raise TxVerify::RefusedSignature, "refusing to sign: #{message}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def self.symbolize(hash)
|
|
190
|
+
hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.blank?(value)
|
|
194
|
+
value.nil? || value.to_s.strip.empty?
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.decimal?(value)
|
|
198
|
+
value.is_a?(Integer) ? value >= 0 : value.is_a?(String) && value.match?(/\A\d+\z/)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def self.address?(value)
|
|
202
|
+
TxVerify.address?(value)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def self.same_address?(left, right)
|
|
206
|
+
address?(left) && address?(right) && left.casecmp(right).zero?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private_class_method :refuse, :symbolize, :blank?, :decimal?, :address?, :same_address?
|
|
210
|
+
end
|
|
211
|
+
end
|
data/lib/rail0/request.rb
CHANGED
|
@@ -143,6 +143,7 @@ module Rail0
|
|
|
143
143
|
end
|
|
144
144
|
|
|
145
145
|
def page_meta(response)
|
|
146
|
+
links = page_links(response["link"])
|
|
146
147
|
{
|
|
147
148
|
page: response["x-page"].to_i,
|
|
148
149
|
per_page: response["x-per-page"].to_i,
|
|
@@ -150,10 +151,27 @@ module Rail0
|
|
|
150
151
|
# Zero for an empty collection: "no pages" is what there are, so a pager
|
|
151
152
|
# rendered off this renders none. (rail0-gateway#242)
|
|
152
153
|
total_pages: response["x-total-pages"].to_i,
|
|
153
|
-
links:
|
|
154
|
+
links: links,
|
|
155
|
+
next_cursor: next_cursor(response["x-next-cursor"], links[:next])
|
|
154
156
|
}.freeze
|
|
155
157
|
end
|
|
156
158
|
|
|
159
|
+
# The cursor of the next page on a cursor-paged list (the +after:+ of payments.list
|
|
160
|
+
# and payouts.list): the x-next-cursor header, or — should a proxy have dropped it —
|
|
161
|
+
# the +after+ query parameter of the Link +next+. nil on the last page, and on an
|
|
162
|
+
# offset-paged response (whose +next+ carries +page=+, not +after=+).
|
|
163
|
+
def next_cursor(header, next_link)
|
|
164
|
+
cursor = header.to_s.strip
|
|
165
|
+
return cursor unless cursor.empty?
|
|
166
|
+
return nil if next_link.nil?
|
|
167
|
+
|
|
168
|
+
query = next_link.split("?", 2)[1]
|
|
169
|
+
return nil if query.nil?
|
|
170
|
+
|
|
171
|
+
value = URI.decode_www_form(query).assoc("after")&.last
|
|
172
|
+
value.nil? || value.empty? ? nil : value
|
|
173
|
+
end
|
|
174
|
+
|
|
157
175
|
# RFC 8288 Link into { first:, prev:, next:, last: }. `first`/`last` are always
|
|
158
176
|
# sent, `prev`/`next` only where they exist, and the header is ABSENT entirely on
|
|
159
177
|
# an empty collection — so an empty hash here means "no pages", not "unparsed".
|
|
@@ -18,7 +18,9 @@ module Rail0
|
|
|
18
18
|
# List active blockchains supported by RAIL0.
|
|
19
19
|
# @param network_type [String, nil] Filter by "testnet" or "mainnet".
|
|
20
20
|
# @param symbol [String, nil] Filter by native symbol (case-insensitive, e.g. "ETH").
|
|
21
|
-
# @return [Array<Hash>] chain_id,
|
|
21
|
+
# @return [Array<Hash>] chain_id, slug (shared by a chain's testnet and mainnet, e.g.
|
|
22
|
+
# "arc"; unique per network_type), name, native_symbol, network_type, explorer_url,
|
|
23
|
+
# public_rpc_urls (keyless RPC endpoints published for clients),
|
|
22
24
|
# required_confirmations, finality_tag (the settlement rule: the tag where the
|
|
23
25
|
# chain serves one, the count only where it does not), and contract — the chain's
|
|
24
26
|
# active RAIL0 deployment as `{ address:, version:, deployed_at: }` (what holds
|
|
@@ -32,6 +34,54 @@ module Rail0
|
|
|
32
34
|
def list(network_type: nil, symbol: nil)
|
|
33
35
|
http.get("/blockchains#{build_query(network_type: network_type, symbol: symbol)}")
|
|
34
36
|
end
|
|
37
|
+
|
|
38
|
+
# Picks one chain out of a {list} result by numeric chain id OR slug. No request.
|
|
39
|
+
#
|
|
40
|
+
# +ref+ is an Integer or an all-digit String (a chain id: "5042002", 5042002) or a
|
|
41
|
+
# slug, compared case-insensitively ("Arc"). A slug is shared by a chain's testnet
|
|
42
|
+
# and mainnet, so when the list holds both it is AMBIGUOUS and raises rather than
|
|
43
|
+
# guessing which network the caller meant: pass the chain id, or a list filtered
|
|
44
|
+
# with +network_type:+. Nothing matching raises too, naming the slugs available.
|
|
45
|
+
#
|
|
46
|
+
# chains = client.chains.list(network_type: "testnet")
|
|
47
|
+
# Rail0::Resources::Chains.resolve(chains, "arc") # => { chain_id: 5042002, slug: "arc", ... }
|
|
48
|
+
# Rail0::Resources::Chains.resolve(chains, 5042002) # the same chain
|
|
49
|
+
#
|
|
50
|
+
# @param chains [Array<Hash>] blockchains, as {list} returns them (symbol or string keys)
|
|
51
|
+
# @param ref [Integer, String] chain id or slug
|
|
52
|
+
# @return [Hash] the matching chain
|
|
53
|
+
# @raise [ArgumentError] when nothing matches, or the slug matches several chains
|
|
54
|
+
def self.resolve(chains, ref)
|
|
55
|
+
key = ref.to_s.strip
|
|
56
|
+
raise ArgumentError, "a chain id or slug is required" if key.empty?
|
|
57
|
+
|
|
58
|
+
rows = Array(chains).map { |c| c.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k } }
|
|
59
|
+
return resolve_id(rows, key) if key.match?(/\A\d+\z/)
|
|
60
|
+
|
|
61
|
+
matches = rows.select { |c| c[:slug].to_s.casecmp(key).zero? }
|
|
62
|
+
return matches.first if matches.size == 1
|
|
63
|
+
|
|
64
|
+
if matches.empty?
|
|
65
|
+
raise ArgumentError, "no chain with slug #{key.inspect} (available slugs: #{describe_available(rows)})"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
ids = matches.map { |c| "#{c[:chain_id]} (#{c[:network_type] || 'unknown network'})" }.join(", ")
|
|
69
|
+
raise ArgumentError, "slug #{key.inspect} is ambiguous: it matches chains #{ids} — pass the chain id, " \
|
|
70
|
+
"or list with network_type:"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.resolve_id(rows, key)
|
|
74
|
+
found = rows.find { |c| c[:chain_id].to_s == key.sub(/\A0+(?=\d)/, "") }
|
|
75
|
+
return found if found
|
|
76
|
+
|
|
77
|
+
raise ArgumentError, "no chain with id #{key} (available: #{describe_available(rows)})"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.describe_available(rows)
|
|
81
|
+
slugs = rows.map { |c| c[:slug].to_s.downcase }.reject(&:empty?).uniq.sort
|
|
82
|
+
slugs.empty? ? "none" : slugs.join(", ")
|
|
83
|
+
end
|
|
84
|
+
private_class_method :resolve_id, :describe_available
|
|
35
85
|
end
|
|
36
86
|
end
|
|
37
87
|
end
|
|
@@ -74,7 +74,12 @@ module Rail0
|
|
|
74
74
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
75
75
|
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
76
76
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
77
|
-
# @
|
|
77
|
+
# @param after [String, nil] Cursor paging: the previous page's `meta[:next_cursor]`.
|
|
78
|
+
# Walks newest first at a constant cost per page, with no total (`total` reads 0);
|
|
79
|
+
# `page:` and any `sort:` other than "-created_at" beside it are 422 `invalid_cursor`.
|
|
80
|
+
# Omit it on the first page (pass `per_page:` alone) and follow `next_cursor` until nil.
|
|
81
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total:, total_pages:, links:,
|
|
82
|
+
# next_cursor: } }. Each row
|
|
78
83
|
# carries, besides the payment fields, `chain_id`, `decimals` (Integer, or nil when
|
|
79
84
|
# the gateway can't resolve the token — fall back to {Tokens#list} then) to render the
|
|
80
85
|
# base-unit amounts without a token lookup, and `in_flight` (Boolean: a transaction is
|
|
@@ -84,7 +89,7 @@ module Rail0
|
|
|
84
89
|
chain_id: nil, disputed: nil, operation: nil, min_amount: nil, max_amount: nil,
|
|
85
90
|
created_from: nil, created_to: nil, scope: nil, account_id: nil, has_error: nil,
|
|
86
91
|
expiring_within: nil, reference: nil, reference_origin: nil, sort: nil, page: nil,
|
|
87
|
-
per_page: nil)
|
|
92
|
+
per_page: nil, after: nil)
|
|
88
93
|
query = build_query(status: status, mode: mode, payer: payer, payee: payee, token: token,
|
|
89
94
|
rail0_id: rail0_id, reference: reference, reference_origin: reference_origin,
|
|
90
95
|
chain_id: chain_id, disputed: disputed,
|
|
@@ -93,7 +98,7 @@ module Rail0
|
|
|
93
98
|
created_from: created_from, created_to: created_to,
|
|
94
99
|
scope: scope, account_id: account_id, has_error: has_error,
|
|
95
100
|
expiring_within: expiring_within,
|
|
96
|
-
sort: sort, page: page, per_page: per_page)
|
|
101
|
+
sort: sort, page: page, per_page: per_page, after: after)
|
|
97
102
|
http.get_list("/payments#{query}")
|
|
98
103
|
end
|
|
99
104
|
|