rail0-sdk 1.8.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '078c5043f2867777ccba9da57fe022e3db252cff6010f50dbad7c9dd37b7c8a7'
4
- data.tar.gz: e64f79000eeb6b94b78367c223eb33f48ac5ffc23218ac89dffb3b99e23504d1
3
+ metadata.gz: 7e9c4a0434db81266c43b414c1f3f2f4b6f2466ef7aa9f1f4f1e1406542fabdf
4
+ data.tar.gz: a01332eba373c295821bd20edaa16076f4ef20b9fdb204064a0c9c378a5b8f1b
5
5
  SHA512:
6
- metadata.gz: 4030fedd465183a1e4dba618ad1c3d7ff75d667032e3ebce6e7da87aeb4e1c75c9ab1205378d89fd2329a005ceb7f8a1b22ab3504b466e0c06b387117a5736a3
7
- data.tar.gz: d894eea1f54c0c8514be643b3fdb7300cdc8c2158dd4c4db0de29b0b8fb09c7226d2910e981c921735a5b5589310484243cc9ddc9b01b92b75692ae68e5c43f0
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 and gets a line in the notes, and the
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
@@ -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
@@ -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".
@@ -58,7 +58,7 @@ module Rail0
58
58
  #
59
59
  # +admin+ is true only for an account holding the operator (administrators)
60
60
  # grant: the gateway adds the field on an admin's session alone, and it is
61
- # normalised here to a boolean so a standard login reads false rather than nil.
61
+ # normalized here to a boolean so a standard login reads false rather than nil.
62
62
  # Visibility only — every gated route re-checks the grant per request.
63
63
  def verify(message:, signature:)
64
64
  session = http.post("/auth", { message: message, signature: signature })
@@ -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, name, native_symbol, network_type, explorer_url,
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