rail0-sdk 1.8.1 → 1.10.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 +203 -3
- data/lib/rail0/client.rb +10 -1
- data/lib/rail0/error_hints.rb +24 -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 +234 -0
- data/lib/rail0/resources/payouts.rb +186 -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 +185 -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: ef5875508f67b6ca5ba08aa397f5afa33ad2fb1d3c53d956c1ea5c1f6f0c066c
|
|
4
|
+
data.tar.gz: 251404c40cce8af924a7b0ffded8052adb3a0fdea8c0f91345a3a9f7dceae3ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3bcef6de15169f95b428d03e16336373f1e234b3c4a7e3aa987ddd72a9e34cdeddb7bb0359e0b03534c60209e89dc50e7a0014d45ff2b4e0c676b2e34df98d4
|
|
7
|
+
data.tar.gz: 8d2d98affb4443432e676d9008aa0a73486946884fc5905e2a44b99fdbad75eec123217b10d4818de8df29d2aad98e410c621552ce435697a5bb0988fcb70bd9
|
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,184 @@ 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 (`min_amount`) are **base-unit**
|
|
656
|
+
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. The minimum is required, as exactly one
|
|
662
|
+
of `min_amount:` (the venue's figure in base units, from its deposit page) or
|
|
663
|
+
`no_minimum: true` (the merchant states the venue has none). Neither, a blank figure, or both
|
|
664
|
+
raise `ArgumentError` before anything is sent, and nothing is defaulted: a minimum nobody
|
|
665
|
+
looked up is how a deposit gets lost. `confirm_details:` is required and has
|
|
666
|
+
no default: `true` is the merchant's acceptance of the gateway's `details_v1` text
|
|
667
|
+
(`Rail0::Resources::PayoutDestinations::CONFIRM_DETAILS_TEXT`), recorded with who and when:
|
|
668
|
+
|
|
669
|
+
> I have checked the address, network, token and minimum deposit on the venue's deposit
|
|
670
|
+
> page and confirm they are correct. Funds sent to a wrong address or network, or below the
|
|
671
|
+
> venue's minimum, may be permanently lost and cannot be recovered by rail0.
|
|
672
|
+
|
|
673
|
+
Show that text and pass the merchant's answer; anything but `true` is 422
|
|
674
|
+
`confirmation_required` and nothing is saved.
|
|
675
|
+
|
|
676
|
+
`label:` is required too: the name the merchant knows the destination by. The gateway trims
|
|
677
|
+
it, and it must be unique among the account's live destinations, compared without regard to
|
|
678
|
+
case (409 `payout_destination_label_taken`; deleting a destination frees its label). A blank
|
|
679
|
+
or whitespace-only label raises `ArgumentError` before anything is sent (the gateway's 422
|
|
680
|
+
`label_required`).
|
|
681
|
+
|
|
682
|
+
```ruby
|
|
683
|
+
dest = client.payout_destinations.create(
|
|
684
|
+
wallet_id: WALLET_ID, # the session's own wallet (uuid or address)
|
|
685
|
+
chain_id: 5042002, token: USDC, address: VENUE_DEPOSIT_ADDRESS,
|
|
686
|
+
label: "Kraken", # required, unique per account (case-insensitive)
|
|
687
|
+
min_amount: "5000000", # the venue's minimum, base units (5 USDC) — or no_minimum: true
|
|
688
|
+
confirm_details: merchant_accepted # true only once the merchant accepted the text above
|
|
689
|
+
)
|
|
690
|
+
dest[:status] # => "unverified"
|
|
691
|
+
|
|
692
|
+
client.payout_destinations.list(status: "verified") # paged like payments
|
|
693
|
+
client.payout_destinations.list(include_deleted: true) # deleted ones too (deleted_at set)
|
|
694
|
+
client.payout_destinations.get(dest[:id])
|
|
695
|
+
client.payout_destinations.events(dest[:id]) # audit trail, newest first
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
### 2. Prove it: proof → confirm
|
|
699
|
+
|
|
700
|
+
An unverified destination can receive the **proof** amount at most: a small test transfer
|
|
701
|
+
(the gateway's proof amount, raised to `min_amount` when that is higher). Send it, wait for
|
|
702
|
+
it to confirm on-chain, check that the venue **credited** it, then confirm:
|
|
703
|
+
|
|
704
|
+
```ruby
|
|
705
|
+
proof = client.payouts.prepare(destination_id: dest[:id], purpose: "proof")
|
|
706
|
+
client.payouts.sign_and_submit(proof, MERCHANT_PRIVATE_KEY, destination: dest)
|
|
707
|
+
# … poll client.payouts.get(proof[:id]) until status == "confirmed", check the venue …
|
|
708
|
+
client.payout_destinations.confirm(dest[:id]) # => status "verified"
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
### 3. Pay out: prepare → verify → sign → submit
|
|
712
|
+
|
|
713
|
+
`amount` is a **human decimal** or `"all"` (the balance minus what is kept back, below). A payout
|
|
714
|
+
below the destination's `min_amount` is refused 422 `payout_below_minimum`.
|
|
715
|
+
|
|
716
|
+
```ruby
|
|
717
|
+
payout = client.payouts.prepare(destination_id: dest[:id], amount: "250.00")
|
|
718
|
+
client.payouts.sign_and_submit(payout, MERCHANT_PRIVATE_KEY,
|
|
719
|
+
destination: dest, amount: "250.00", decimals: 6)
|
|
720
|
+
# 202, now "submitted"; poll client.payouts.get(payout[:id]) (or the payouts.confirmed /
|
|
721
|
+
# payouts.failed webhooks) until it settles
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
`sign_and_submit` is `Rail0::Signing.sign_payout` + `payouts.submit`. Before the key touches
|
|
725
|
+
the prepared `unsigned_transaction`, `Rail0::PayoutVerify.verify` holds it to exactly:
|
|
726
|
+
`to` = the payout's token, `value` 0, `chain_id` = the payout's chain, and calldata
|
|
727
|
+
`0xa9059cbb` + abi(the payout's address, the payout's base-unit amount) with nothing
|
|
728
|
+
trailing. With `destination:` the payout must also be for that registered destination
|
|
729
|
+
(chain, token, address); with `amount:` + `decimals:` its amount must be exactly the one you
|
|
730
|
+
asked for. Anything else raises `Rail0::TxVerify::RefusedSignature` and nothing is signed or
|
|
731
|
+
sent. There is no blind form.
|
|
732
|
+
|
|
733
|
+
The two steps apart, or the other way to send:
|
|
734
|
+
|
|
735
|
+
```ruby
|
|
736
|
+
raw = Rail0::Signing.sign_payout(payout, MERCHANT_PRIVATE_KEY, destination: dest,
|
|
737
|
+
amount: "250.00", decimals: 6)
|
|
738
|
+
client.payouts.submit(payout[:id], { signed_transaction: raw }) # the gateway broadcasts
|
|
739
|
+
|
|
740
|
+
# A wallet that signs and broadcasts itself: verify, send, then report the hash.
|
|
741
|
+
Rail0::PayoutVerify.verify(payout[:unsigned_transaction], payout: payout, destination: dest,
|
|
742
|
+
amount: "250.00", decimals: 6)
|
|
743
|
+
client.payouts.submit_by_hash(payout[:id], { transaction_hash: "0x…" })
|
|
744
|
+
|
|
745
|
+
client.payouts.resend(payout[:id]) # re-broadcast the stored signed bytes (after a 502)
|
|
746
|
+
client.payouts.cancel(payout[:id]) # an UNSIGNED pending payout only
|
|
747
|
+
client.payouts.list(status: %w[pending submitted], purpose: "payout")
|
|
748
|
+
client.payouts.events(payout[:id])
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
One pending payout per destination: preparing again replaces it while it is unsigned. Once
|
|
752
|
+
a signed transaction is stored, it may still be mined, so it cannot be re-prepared or
|
|
753
|
+
cancelled (409 `payout_already_signed`): `resend` it and let it settle.
|
|
754
|
+
|
|
755
|
+
A runnable end-to-end flow (register → proof → confirm → payout → poll) is in
|
|
756
|
+
[`examples/06_payouts.rb`](examples/06_payouts.rb).
|
|
757
|
+
|
|
758
|
+
### Minimum, reserves, disable and delete
|
|
759
|
+
|
|
760
|
+
- **Minimum** (`min_amount`): the venue's minimum deposit. Below it the venue may never
|
|
761
|
+
credit a transfer, so the gateway refuses it. It is required and explicit: a figure
|
|
762
|
+
(`"0"` included), or `no_minimum: true` when the venue has none — the destination then
|
|
763
|
+
reads `no_minimum: true` with `min_amount` nil (`no_minimum: false` with a nil `min_amount`
|
|
764
|
+
is a destination registered before the minimum was asked: never stated). A blank figure,
|
|
765
|
+
or a figure beside `no_minimum: true`, raises `ArgumentError` (the gateway's
|
|
766
|
+
`minimum_required` / `minimum_conflict`). Changing either needs the same confirmation, and
|
|
767
|
+
raising the minimum above the latest proof takes the verification away (prove it again):
|
|
768
|
+
|
|
769
|
+
```ruby
|
|
770
|
+
client.payout_destinations.update(dest[:id], min_amount: "10000000", confirm_details: merchant_accepted)
|
|
771
|
+
client.payout_destinations.update(dest[:id], no_minimum: true, confirm_details: merchant_accepted) # none
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
- **Reserves**: what a payout keeps back in the wallet is decided at payout time, not stored
|
|
775
|
+
on the destination. The **refund reserve** is the refundable balance of the wallet's
|
|
776
|
+
payments in this token still inside their refund window (a refund is pulled from the payee
|
|
777
|
+
wallet, so an emptied wallet fails it). The **gas reserve**, only on a chain whose gas is
|
|
778
|
+
paid in this very token (Arc's USDC, `native_gas`), is the gas for those refunds and for
|
|
779
|
+
capturing the open authorizations plus a base of new captures; `"0"` elsewhere. On such a
|
|
780
|
+
chain the payout's own fee (`gas_allowance`) is always kept back too. `reserves` shows the
|
|
781
|
+
figures right now, with `available`: what `"all"` moves with both reserves kept:
|
|
782
|
+
|
|
783
|
+
```ruby
|
|
784
|
+
r = client.payout_destinations.reserves(dest[:id])
|
|
785
|
+
r[:refund_reserve_formatted] # => "40.00" (r[:refundable_payments] payments)
|
|
786
|
+
r[:gas_reserve_formatted] # => "0.12"
|
|
787
|
+
r[:available_formatted] # => "960.00" what amount: "all" moves
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
Both reserves are kept by default. A payout can opt out of either (a refund or capture after
|
|
791
|
+
it may then fail for lack of funds); the payout records what it kept back in
|
|
792
|
+
`refund_reserve` / `gas_reserve` (base units, nil for a proof):
|
|
793
|
+
|
|
794
|
+
```ruby
|
|
795
|
+
client.payouts.prepare(destination_id: dest[:id], amount: "all", keep_refund_reserve: false)
|
|
796
|
+
client.payouts.prepare(destination_id: dest[:id], amount: "all", keep_gas_reserve: false)
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
- **Rename**: `label:` renames it, under the same rules as on create: never blank
|
|
800
|
+
(`ArgumentError` here, 422 `label_required` at the gateway) and unique per account,
|
|
801
|
+
case-insensitive (409 `payout_destination_label_taken`):
|
|
802
|
+
|
|
803
|
+
```ruby
|
|
804
|
+
client.payout_destinations.update(dest[:id], label: "Kraken main")
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
- **Disable and delete**: `active: false` disables a destination (`true` re-enables it with
|
|
808
|
+
the verification it had). `delete` removes it from the lists and frees its payment method
|
|
809
|
+
(one destination per token and chain, so another address can be registered there) and its
|
|
810
|
+
label; the row is kept (`get` and `events` still read it, with `deleted_at` set
|
|
811
|
+
and `status` `"disabled"`). Either settles a pending payout first: an unsigned one is
|
|
812
|
+
cancelled, a signed one refuses with 409 `payout_already_signed`.
|
|
813
|
+
|
|
814
|
+
```ruby
|
|
815
|
+
client.payout_destinations.update(dest[:id], active: false) # disable (true re-enables)
|
|
816
|
+
client.payout_destinations.delete(dest[:id]) # soft delete, own wallet only
|
|
817
|
+
```
|
|
818
|
+
|
|
628
819
|
## Webhooks (JWT)
|
|
629
820
|
|
|
630
821
|
One subscription carries a **set** of topics (see `Rail0::Resources::Webhooks::TOPICS`):
|
|
631
822
|
one shared secret and one circuit breaker for all of them, with each delivery naming the
|
|
632
823
|
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.
|
|
824
|
+
overlap — the gateway answers 409 and names the topic that collided. The payout topics —
|
|
825
|
+
`payouts.confirmed`, `payouts.failed` (the reason is the payout's `error_code`) and
|
|
826
|
+
`payout_destinations.verified` — carry the same envelope (`id`, `emitted_at`, `topic`) with
|
|
827
|
+
the subject under `payout` or `payout_destination` instead of `payment`/`transaction`.
|
|
634
828
|
|
|
635
829
|
```ruby
|
|
636
830
|
hook = client.webhooks.create(
|
|
@@ -867,6 +1061,8 @@ Requires the `eth` gem. No private key ever leaves your process.
|
|
|
867
1061
|
| `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
1062
|
| `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
1063
|
| `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 |
|
|
1064
|
+
| `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 |
|
|
1065
|
+
| `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
1066
|
| `sign_transfer_with_authorization(signer, domain, params)` | Raw EIP-3009 `TransferWithAuthorization` signer, for talking to a token directly |
|
|
871
1067
|
|
|
872
1068
|
```ruby
|
|
@@ -1058,6 +1254,7 @@ lib/rail0/
|
|
|
1058
1254
|
signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
|
|
1059
1255
|
tx_verify.rb Rail0::TxVerify — pre-sign check of an unsigned transaction
|
|
1060
1256
|
payload_verify.rb Rail0::PayloadVerify — pre-sign check of an EIP-3009 signing payload
|
|
1257
|
+
payout_verify.rb Rail0::PayoutVerify — pre-sign check of a payout's ERC-20 transfer
|
|
1061
1258
|
stablecoins.rb stablecoin address registry
|
|
1062
1259
|
dispute_reasons.rb generated Rail0::DisputeReasons — the dispute-reason dictionary
|
|
1063
1260
|
types.rb generated Struct docs of the gateway schema (reference only)
|
|
@@ -1074,6 +1271,8 @@ lib/rail0/
|
|
|
1074
1271
|
accounts.rb the caller's own account profile, read and update (JWT)
|
|
1075
1272
|
webhooks.rb webhook subscription management (JWT)
|
|
1076
1273
|
analytics.rb account-scoped payment analytics (JWT)
|
|
1274
|
+
payout_destinations.rb payout destinations: register, confirm, update, delete, reserves (JWT)
|
|
1275
|
+
payouts.rb merchant payouts: prepare, sign, submit (JWT)
|
|
1077
1276
|
query.rb shared query-string and path-segment helpers
|
|
1078
1277
|
```
|
|
1079
1278
|
|
|
@@ -1115,7 +1314,8 @@ A release is an approved pull request: merging it is what publishes.
|
|
|
1115
1314
|
**Or on a feature branch**, to release a change in the same review: run it on a clean
|
|
1116
1315
|
branch that is pushed, contains `origin/main` and has an open pull request into `main`.
|
|
1117
1316
|
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
|
|
1317
|
+
request's own title counts toward the version, gets a line in the notes and gains a
|
|
1318
|
+
` (release vX.Y.Z)` suffix, and the
|
|
1119
1319
|
notes go into its description between `<!-- release:start -->` markers — the text above
|
|
1120
1320
|
them is kept. Re-run it after new commits or a merge of `main`: it refreshes the version
|
|
1121
1321
|
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,30 @@ 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 or no_minimum, 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 payment method (token and chain) already has a destination — one per method; delete it to register another address (payout_destinations.list)",
|
|
119
|
+
"payout_destination_label_taken" => "another destination of this account already has this label (compared without regard to case) — pick another name, or delete that destination to free it (payout_destinations.list)",
|
|
120
|
+
"label_required" => "a payout destination needs a label — a name, not blank or whitespace (payout_destinations.create / update)",
|
|
121
|
+
"payout_destination_zero_address" => "the zero address cannot receive a payout — register the venue's deposit address",
|
|
122
|
+
"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",
|
|
123
|
+
"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",
|
|
124
|
+
"payout_destination_disabled" => "the destination is disabled — re-enable it (payout_destinations.update(id, active: true)) or choose another one",
|
|
125
|
+
"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",
|
|
126
|
+
"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",
|
|
127
|
+
"minimum_required" => "a payout destination needs its venue's minimum deposit: min_amount (base units, from the venue's deposit page), or no_minimum: true when the venue has none — a blank min_amount is not one (to remove a minimum, send no_minimum: true)",
|
|
128
|
+
"minimum_conflict" => "min_amount and no_minimum: true contradict each other — send the venue's minimum, or no_minimum: true when it has none, not both",
|
|
129
|
+
"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 what is kept back (reserves, gas) is below it",
|
|
130
|
+
"insufficient_balance" => "the wallet does not hold enough of the token for this amount after what the payout keeps back (refund reserve, gas reserve, gas allowance) — the detail names what is available; payout_destinations.reserves(id) shows the figures",
|
|
131
|
+
"payout_limit_reached" => "this wallet reached its hourly payout limit — retry later",
|
|
132
|
+
"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",
|
|
133
|
+
"payout_not_pending" => "the payout is already submitted or settled — prepare a new one",
|
|
134
|
+
"payout_not_signed" => "the payout holds no signed transaction to resend — submit the signed transfer first",
|
|
135
|
+
"payout_hash_in_use" => "another payout already records this transaction hash — one transaction settles one payout",
|
|
136
|
+
"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",
|
|
137
|
+
"invalid_amount" => "the amount is not valid: base units (digits only, below 2^120) for min_amount, a positive decimal within the token's decimals elsewhere",
|
|
138
|
+
"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",
|
|
139
|
+
"unknown_chain" => "that chain id is not configured on this gateway — see chains.list",
|
|
116
140
|
# A BARE forbidden is not a party mismatch: the gateway split those into codes of
|
|
117
141
|
# their own (not_the_payee, not_the_payer, wallet_deactivated, account_deactivated,
|
|
118
142
|
# 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".
|