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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eaaadaf08346573c426e649979548088c8cbbb1a2b037e63026c5512fa44feb8
4
- data.tar.gz: b4eea2d0cd8cb675f2d31e5fbddee08f62fd3421f2cb969db246fc8e4d4338fb
3
+ metadata.gz: ef5875508f67b6ca5ba08aa397f5afa33ad2fb1d3c53d956c1ea5c1f6f0c066c
4
+ data.tar.gz: 251404c40cce8af924a7b0ffded8052adb3a0fdea8c0f91345a3a9f7dceae3ba
5
5
  SHA512:
6
- metadata.gz: 9433218dbe34e8d9a6cf7c200aca99d15695940fc099e5347b85e14c9612dbef6d824c303b3cd52e81e00787c25a0c2459cc40aa5e62bd0adb4555294f984816
7
- data.tar.gz: 5e185810e4e78e7282ceda6760e5c2ee2857bf7c5469806fbb48fdc92fed12592fd8367aa1707bdafc76a52d4d7e65251d3dce36222174434bce9c53125398ff
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 and gets a line in the notes, and the
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
@@ -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
@@ -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: page_links(response["link"])
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".