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.
@@ -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
@@ -74,7 +74,12 @@ module Rail0
74
74
  # @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
75
75
  # @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
76
76
  # @param per_page [Integer, nil] Items per page (max 100).
77
- # @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }. Each row
77
+ # @param after [String, nil] Cursor paging: the previous page's `meta[:next_cursor]`.
78
+ # Walks newest first at a constant cost per page, with no total (`total` reads 0);
79
+ # `page:` and any `sort:` other than "-created_at" beside it are 422 `invalid_cursor`.
80
+ # Omit it on the first page (pass `per_page:` alone) and follow `next_cursor` until nil.
81
+ # @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total:, total_pages:, links:,
82
+ # next_cursor: } }. Each row
78
83
  # carries, besides the payment fields, `chain_id`, `decimals` (Integer, or nil when
79
84
  # the gateway can't resolve the token — fall back to {Tokens#list} then) to render the
80
85
  # base-unit amounts without a token lookup, and `in_flight` (Boolean: a transaction is
@@ -84,7 +89,7 @@ module Rail0
84
89
  chain_id: nil, disputed: nil, operation: nil, min_amount: nil, max_amount: nil,
85
90
  created_from: nil, created_to: nil, scope: nil, account_id: nil, has_error: nil,
86
91
  expiring_within: nil, reference: nil, reference_origin: nil, sort: nil, page: nil,
87
- per_page: nil)
92
+ per_page: nil, after: nil)
88
93
  query = build_query(status: status, mode: mode, payer: payer, payee: payee, token: token,
89
94
  rail0_id: rail0_id, reference: reference, reference_origin: reference_origin,
90
95
  chain_id: chain_id, disputed: disputed,
@@ -93,7 +98,7 @@ module Rail0
93
98
  created_from: created_from, created_to: created_to,
94
99
  scope: scope, account_id: account_id, has_error: has_error,
95
100
  expiring_within: expiring_within,
96
- sort: sort, page: page, per_page: per_page)
101
+ sort: sort, page: page, per_page: per_page, after: after)
97
102
  http.get_list("/payments#{query}")
98
103
  end
99
104
 
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query"
4
+
5
+ module Rail0
6
+ module Resources
7
+ # Payout destinations: where a merchant wallet sends its balance — a deposit address at
8
+ # an exchange, Circle Mint, an off-ramp provider or the merchant's own custody (JWT,
9
+ # merchant session).
10
+ #
11
+ # A destination is registered +unverified+. It becomes +verified+ after a small PROOF
12
+ # transfer ({Payouts#prepare} with +purpose: "proof"+) has confirmed on-chain AND the
13
+ # merchant has checked the venue credited it ({#confirm}). Until then it can receive the
14
+ # proof amount at most. See {Payouts} for the transfer itself.
15
+ #
16
+ # Amounts on a destination (+min_amount+) are BASE-UNIT strings (digits only, below
17
+ # 2^120); the +*_formatted+ fields beside them carry the human decimals. What a payout
18
+ # keeps back in the wallet (the refund and gas reserves) is decided at payout time, not
19
+ # stored on the destination: see {#reserves} and {Payouts#prepare}.
20
+ class PayoutDestinations
21
+ # The default of {update}'s keywords: "not passed", told apart from an explicit value.
22
+ UNSET = Object.new.freeze
23
+ private_constant :UNSET
24
+
25
+ include Query
26
+
27
+ # The text of the gateway's +details_v1+ attestation, which +confirm_details: true+
28
+ # stands for on {create} and on an {update} that changes +min_amount+ or +no_minimum+. Show it to the
29
+ # merchant and send +confirm_details: true+ only once they have accepted it.
30
+ CONFIRM_DETAILS_TEXT =
31
+ "I have checked the address, network, token and minimum deposit on the venue's deposit " \
32
+ "page and confirm they are correct. Funds sent to a wrong address or network, or below " \
33
+ "the venue's minimum, may be permanently lost and cannot be recovered by rail0."
34
+
35
+ # A destination's statuses — the values {list}'s +status+ filter accepts.
36
+ STATUSES = %w[unverified verified disabled].freeze
37
+
38
+ attr_reader :http
39
+
40
+ def initialize(http)
41
+ @http = http
42
+ freeze
43
+ end
44
+
45
+ # List the account's payout destinations (every wallet of the account).
46
+ # @param wallet_id [String, nil] Only this wallet's destinations (uuid).
47
+ # @param status [String, nil] One of {STATUSES}.
48
+ # @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
49
+ # @param page [Integer, nil] Page number (1-based).
50
+ # @param per_page [Integer, nil] Items per page (max 100).
51
+ # @param include_deleted [Boolean, nil] +true+ lists deleted destinations too
52
+ # (+deleted_at+ set); left out by default.
53
+ # @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total:, total_pages:, links: } }
54
+ def list(wallet_id: nil, status: nil, sort: nil, page: nil, per_page: nil, include_deleted: nil)
55
+ query = build_query(wallet_id: wallet_id, status: status, sort: sort, page: page, per_page: per_page,
56
+ include_deleted: include_deleted)
57
+ http.get_list("/payout_destinations#{query}")
58
+ end
59
+
60
+ # Register a payout destination for the session's own wallet. Answers 201 with the
61
+ # destination, +unverified+.
62
+ #
63
+ # +confirm_details+ is REQUIRED and has no default on purpose: +true+ is the merchant's
64
+ # attestation of {CONFIRM_DETAILS_TEXT} (the gateway's +details_v1+) —
65
+ #
66
+ # "I have checked the address, network, token and minimum deposit on the venue's
67
+ # deposit page and confirm they are correct. Funds sent to a wrong address or network,
68
+ # or below the venue's minimum, may be permanently lost and cannot be recovered by
69
+ # rail0."
70
+ #
71
+ # — recorded with who and when in the +destination.created+ event. Anything but +true+ is
72
+ # refused 422 +confirmation_required+ and nothing is saved. Pass the merchant's answer,
73
+ # never a hard-coded +true+.
74
+ #
75
+ # The venue's minimum deposit is REQUIRED too, as EXACTLY ONE of: +min_amount+, the
76
+ # figure the merchant read on the venue's deposit page, or +no_minimum: true+, the
77
+ # merchant's statement that the venue has none. Neither, a blank figure, or both raise
78
+ # ArgumentError here and nothing is sent (the gateway would answer 422
79
+ # +minimum_required+ / +minimum_conflict+). Nothing is defaulted: a minimum nobody
80
+ # looked up is how a deposit gets lost, so pass what the merchant answered.
81
+ #
82
+ # +label+ is REQUIRED: the name the merchant knows the destination by. The gateway
83
+ # trims it; it must be unique among the account's live destinations, compared without
84
+ # regard to case (409 +payout_destination_label_taken+ otherwise; deleting a destination
85
+ # frees its label). A blank or whitespace-only label raises ArgumentError here and
86
+ # nothing is sent (the gateway would answer 422 +label_required+).
87
+ #
88
+ # @param wallet_id [String] The wallet (uuid or address) that will sign transfers to it;
89
+ # must be the session's own.
90
+ # @param chain_id [Integer] The chain the deposit address was issued for.
91
+ # @param token [String] The token's address on that chain.
92
+ # @param address [String] The deposit address, generated at the venue WITH THE NETWORK
93
+ # SET TO THIS CHAIN.
94
+ # @param label [String] The destination's name, unique per account (case-insensitive).
95
+ # @param confirm_details [Boolean] The merchant's acceptance of {CONFIRM_DETAILS_TEXT}.
96
+ # @param min_amount [String, nil] The venue's deposit minimum in base units. A payout
97
+ # below it is refused (+payout_below_minimum+) and the proof is raised to it.
98
+ # @param no_minimum [Boolean] +true+: the merchant states the venue has no minimum.
99
+ # @return [Hash] The destination.
100
+ # @raise [ArgumentError] when +label+ is blank, when neither or both of +min_amount+ /
101
+ # +no_minimum: true+ are given, or +min_amount+ is blank.
102
+ def create(wallet_id:, chain_id:, token:, address:, label:, confirm_details:, min_amount: nil,
103
+ no_minimum: false)
104
+ check_label!(label)
105
+ figure = !min_amount.nil? && !min_amount.to_s.strip.empty?
106
+ if figure == (no_minimum == true)
107
+ raise ArgumentError, "exactly one of min_amount (the venue's minimum, base units) or no_minimum: true " \
108
+ "is required"
109
+ end
110
+
111
+ body = { wallet_id: wallet_id, chain_id: chain_id, token: token, address: address,
112
+ confirm_details: confirm_details, label: label, min_amount: min_amount,
113
+ no_minimum: (true if no_minimum == true) }.compact
114
+ http.post("/payout_destinations", body)
115
+ end
116
+
117
+ # Read one destination (any wallet of the account may).
118
+ # @param id [String] Destination uuid.
119
+ # @return [Hash]
120
+ def get(id)
121
+ http.get("/payout_destinations/#{segment(id)}")
122
+ end
123
+
124
+ # Update a destination. Only the keywords you PASS are sent, and at least one is
125
+ # required (ArgumentError otherwise).
126
+ #
127
+ # - +label+: renames it. Never blank (ArgumentError here, 422 +label_required+ at the
128
+ # gateway) and unique among the account's live destinations, case-insensitive (409
129
+ # +payout_destination_label_taken+).
130
+ # - +min_amount+: the venue's minimum, base units (+"0"+ is a figure like any other); it
131
+ # also clears +no_minimum+. Never blank: an empty string raises ArgumentError here.
132
+ # - +no_minimum: true+: the venue has no minimum — how a minimum is removed (+min_amount+
133
+ # becomes nil). Never together with +min_amount+ (ArgumentError here, 422
134
+ # +minimum_conflict+ at the gateway).
135
+ # - A request that CHANGES +min_amount+ or +no_minimum+ also needs +confirm_details: true+ — the same
136
+ # attestation as {create} ({CONFIRM_DETAILS_TEXT}), else 422 +confirmation_required+
137
+ # and nothing is written. Raising the minimum above the destination's latest proof
138
+ # takes its verification away: prove it again with a new proof.
139
+ # - +active: false+ disables it (any wallet of the account may); its unsigned pending
140
+ # payout is cancelled, a signed one refuses the switch-off 409 +payout_already_signed+.
141
+ # +active: true+ re-enables it with the verification it had.
142
+ #
143
+ # @param id [String] Destination uuid.
144
+ # @return [Hash] The destination.
145
+ def update(id, label: UNSET, min_amount: UNSET, no_minimum: UNSET, active: UNSET, confirm_details: UNSET)
146
+ body = { label: label, min_amount: min_amount, no_minimum: no_minimum, active: active,
147
+ confirm_details: confirm_details }.reject { |_, v| v.equal?(UNSET) }
148
+ if body.except(:confirm_details).empty?
149
+ raise ArgumentError, "at least one of label, min_amount, no_minimum or active is required"
150
+ end
151
+
152
+ check_label!(body[:label]) if body.key?(:label)
153
+ check_minimum!(body)
154
+
155
+ http.patch("/payout_destinations/#{segment(id)}", body)
156
+ end
157
+
158
+ # Delete a destination (soft): it leaves the lists ({list} shows it again with
159
+ # +include_deleted: true+), and its payment method and its label are free to be
160
+ # registered again. The row is
161
+ # kept — its payouts and audit trail still name it, and {get} / {events} still read it
162
+ # with +deleted_at+ set and +status+ "disabled". Its pending payout is settled first, as
163
+ # on a switch-off: an unsigned one is cancelled, a signed one refuses the delete 409
164
+ # +payout_already_signed+. Only the destination's own wallet may delete it; a deleted
165
+ # destination takes no further write (404). Recorded as +destination.deleted+.
166
+ # @param id [String] Destination uuid.
167
+ # @return [Hash] The destination as it now stands.
168
+ def delete(id)
169
+ http.delete("/payout_destinations/#{segment(id)}")
170
+ end
171
+
172
+ # What a payout to the destination would keep back in the wallet right now, computed
173
+ # from the wallet's payments and the chain's current fees (nothing is stored):
174
+ #
175
+ # - +refund_reserve+: the refundable balance of the wallet's payments in this token
176
+ # still inside their refund window (+refundable_payments+ of them).
177
+ # - +gas_reserve+: on a chain whose gas IS this token (+native_gas+, Arc's USDC), the gas
178
+ # for those refunds and for capturing the open authorizations plus +base_captures+
179
+ # more; "0" elsewhere.
180
+ # - +gas_allowance+: the payout transfer's own fee, always kept back on a native-gas chain.
181
+ # - +available+: what +amount: "all"+ moves with both reserves kept (the default).
182
+ #
183
+ # Every amount is base units with its +*_formatted+ twin. Both reserves can be opted
184
+ # out of per payout ({Payouts#prepare}'s +keep_refund_reserve:+ / +keep_gas_reserve:+).
185
+ # Any wallet of the account may read it; a deleted destination still reads.
186
+ # @param id [String] Destination uuid.
187
+ # @return [Hash] The reserves (see Rail0::Types::PayoutReserves).
188
+ def reserves(id)
189
+ http.get("/payout_destinations/#{segment(id)}/reserves")
190
+ end
191
+
192
+ # The merchant's attestation that the venue CREDITED the proof transfer: marks the
193
+ # destination +verified+. The proof payout must be +confirmed+ on-chain first (422
194
+ # +payout_destination_proof_missing+ otherwise).
195
+ # @param id [String] Destination uuid.
196
+ # @return [Hash] The destination, now verified.
197
+ def confirm(id)
198
+ http.post("/payout_destinations/#{segment(id)}/confirm")
199
+ end
200
+
201
+ # The destination's audit trail, newest first: its own events and those of every
202
+ # payout made to it (+destination.created+, +payout.prepared+, +payout.confirmed+, …).
203
+ # @param id [String] Destination uuid.
204
+ # @param page [Integer, nil] Page number (1-based).
205
+ # @param per_page [Integer, nil] Items per page (default 25, max 100).
206
+ # @return [Hash] { data: Array<Hash>, meta: { ... } }
207
+ def events(id, page: nil, per_page: nil)
208
+ http.get_list("/payout_destinations/#{segment(id)}/events#{build_query(page: page, per_page: per_page)}")
209
+ end
210
+
211
+ private
212
+
213
+ # A destination's label is required and never blank (the gateway's +label_required+);
214
+ # refused here so nothing is sent.
215
+ def check_label!(label)
216
+ return unless label.to_s.strip.empty?
217
+
218
+ raise ArgumentError, "label is required and cannot be blank: name the destination"
219
+ end
220
+
221
+ # {update}'s local minimum checks, before anything is sent: a blank +min_amount+ is
222
+ # never a minimum (the gateway's +minimum_required+; +no_minimum: true+ removes one),
223
+ # and a figure beside +no_minimum: true+ contradicts itself (+minimum_conflict+).
224
+ def check_minimum!(body)
225
+ if body.key?(:min_amount) && body[:min_amount].to_s.strip.empty?
226
+ raise ArgumentError, "min_amount cannot be blank: pass the venue's minimum, or no_minimum: true to remove it"
227
+ end
228
+ return unless body.key?(:min_amount) && body[:no_minimum] == true
229
+
230
+ raise ArgumentError, "pass min_amount or no_minimum: true, not both"
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query"
4
+
5
+ module Rail0
6
+ module Resources
7
+ # Merchant payouts: an ERC-20 transfer of the wallet's balance to one of its
8
+ # {PayoutDestinations} (JWT, merchant session; the destination's own wallet signs).
9
+ #
10
+ # Two-phase like the payment operations. {prepare} builds the unsigned EIP-1559
11
+ # transaction — a +transfer(address,uint256)+ on the TOKEN contract — and answers the
12
+ # pending payout with it in +unsigned_transaction+. Then either:
13
+ #
14
+ # - sign it offline and {submit} the raw bytes (the gateway broadcasts them) — or do
15
+ # both with {sign_and_submit}, which VERIFIES the transaction before signing; or
16
+ # - broadcast it yourself (a wallet that signs and sends in one step) and report the
17
+ # hash with {submit_by_hash}.
18
+ #
19
+ # The payout then settles asynchronously: poll {get} until +status+ is +confirmed+ or
20
+ # +failed+, or subscribe to the +payouts.confirmed+ / +payouts.failed+ webhooks.
21
+ #
22
+ # Amounts: +prepare+'s +amount+ is a HUMAN decimal ("250.00") or "all" (the balance
23
+ # minus what the payout keeps back); the payout record's +amount+ is base units, with
24
+ # +amount_formatted+ beside it, and +refund_reserve+ / +gas_reserve+ record what it kept
25
+ # back (base units; nil for a proof).
26
+ class Payouts
27
+ include Query
28
+
29
+ # What a payout is for: "proof" (the small test transfer that verifies a
30
+ # destination) or "payout" (the default).
31
+ PURPOSES = %w[payout proof].freeze
32
+
33
+ # A payout's statuses — the values {list}'s +status+ filter accepts.
34
+ STATUSES = %w[pending submitted confirmed failed].freeze
35
+
36
+ attr_reader :http
37
+
38
+ def initialize(http)
39
+ @http = http
40
+ freeze
41
+ end
42
+
43
+ # List the account's payouts, newest first by default.
44
+ # @param destination_id [String, nil] Only payouts to this destination (uuid).
45
+ # @param wallet_id [String, nil] Only this wallet's payouts (uuid).
46
+ # @param status [String, Array<String>, nil] One of {STATUSES}, or an Array matching
47
+ # ANY of them (sent comma-separated).
48
+ # @param purpose [String, nil] One of {PURPOSES}.
49
+ # @param created_from [String, nil] Only payouts created at/after this ISO-8601 time.
50
+ # @param created_to [String, nil] Only payouts created at/before this ISO-8601 time.
51
+ # @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
52
+ # @param page [Integer, nil] Page number (1-based).
53
+ # @param per_page [Integer, nil] Items per page (default 25, max 100).
54
+ # @param after [String, nil] Cursor paging, as on {Payments#list}: the previous page's
55
+ # `meta[:next_cursor]`; no total, and not combinable with `page:` or another sort.
56
+ # @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total:, total_pages:, links:,
57
+ # next_cursor: } }
58
+ def list(destination_id: nil, wallet_id: nil, status: nil, purpose: nil, created_from: nil,
59
+ created_to: nil, sort: nil, page: nil, per_page: nil, after: nil)
60
+ query = build_query(destination_id: destination_id, wallet_id: wallet_id, status: status,
61
+ purpose: purpose, created_from: created_from, created_to: created_to,
62
+ sort: sort, page: page, per_page: per_page, after: after)
63
+ http.get_list("/payouts#{query}")
64
+ end
65
+
66
+ # Prepare a transfer to a destination (POST /payouts/prepare): 201 with a new pending
67
+ # payout, or 200 re-preparing the destination's unsigned pending one (one pending
68
+ # payout per destination). Its +unsigned_transaction+ is what to sign.
69
+ #
70
+ # - +purpose: "proof"+ sends the gateway's proof amount, raised to the destination's
71
+ # +min_amount+ when that is higher; +amount+ is ignored. An unverified destination
72
+ # may receive the proof at most.
73
+ # - +purpose: "payout"+ (the default) sends +amount+: a human decimal, or "all" for
74
+ # the balance minus what is kept back. Below the destination's +min_amount+ it is
75
+ # refused 422 +payout_below_minimum+.
76
+ # - What a payout keeps back in the wallet is decided here, at payout time: the refund
77
+ # reserve (the refundable balance of payments still inside their refund window) and,
78
+ # on a chain whose gas is this very token (Arc's USDC), the gas reserve (gas for the
79
+ # refunds and captures the wallet still owes). Both are kept by default;
80
+ # +keep_refund_reserve: false+ / +keep_gas_reserve: false+ opts this payout out of one
81
+ # (a refund or capture after it may then fail for lack of funds). Payout purpose only;
82
+ # sent only when given. {PayoutDestinations#reserves} shows the figures beforehand.
83
+ # - A payout that already holds a signed transaction cannot be re-prepared (409
84
+ # +payout_already_signed+): {resend} it, or let it settle.
85
+ #
86
+ # @param destination_id [String] Destination uuid.
87
+ # @param purpose [String, nil] One of {PURPOSES}; the gateway defaults to "payout".
88
+ # @param amount [String, nil] Human decimal or "all"; required for a payout.
89
+ # @param keep_refund_reserve [Boolean, nil] +false+ opts out of keeping the refund
90
+ # reserve; the gateway defaults to +true+.
91
+ # @param keep_gas_reserve [Boolean, nil] +false+ opts out of keeping the gas reserve;
92
+ # the gateway defaults to +true+.
93
+ # @return [Hash] The pending payout.
94
+ def prepare(destination_id:, purpose: nil, amount: nil, keep_refund_reserve: nil, keep_gas_reserve: nil)
95
+ body = { destination_id: destination_id, purpose: purpose, amount: amount,
96
+ keep_refund_reserve: keep_refund_reserve, keep_gas_reserve: keep_gas_reserve }.compact
97
+ http.post("/payouts/prepare", body)
98
+ end
99
+
100
+ # Read one payout.
101
+ # @param id [String] Payout uuid.
102
+ # @return [Hash]
103
+ def get(id)
104
+ http.get("/payouts/#{segment(id)}")
105
+ end
106
+
107
+ # The payout's audit trail, newest first.
108
+ # @param id [String] Payout uuid.
109
+ # @param page [Integer, nil] Page number (1-based).
110
+ # @param per_page [Integer, nil] Items per page (default 25, max 100).
111
+ # @return [Hash] { data: Array<Hash>, meta: { ... } }
112
+ def events(id, page: nil, per_page: nil)
113
+ http.get_list("/payouts/#{segment(id)}/events#{build_query(page: page, per_page: per_page)}")
114
+ end
115
+
116
+ # Submit the signed transfer; the gateway stores it, then broadcasts it (HTTP 202,
117
+ # now +submitted+). A node refusal answers 502 and leaves the payout +pending+ with
118
+ # its signature: {resend} it. Prefer {sign_and_submit}, which verifies before signing.
119
+ # @param id [String] Payout uuid.
120
+ # @param params [Hash] { signed_transaction: "0x…" }.
121
+ # @return [Hash] The payout.
122
+ def submit(id, params)
123
+ http.post("/payouts/#{segment(id)}/submit", params)
124
+ end
125
+
126
+ # Verify, sign and submit a prepared payout in one call.
127
+ #
128
+ # {Rail0::Signing.sign_payout} first holds the +unsigned_transaction+ to the payout —
129
+ # +to+ the token, value 0, the payout's chain, calldata exactly
130
+ # +transfer(destination address, payout amount)+, nothing trailing — and, when given,
131
+ # to the registered +destination:+ and to the +amount:+ you asked prepare for (scaled
132
+ # by +decimals:+). Anything else raises {Rail0::TxVerify::RefusedSignature} and
133
+ # nothing is signed or sent. Then {submit}.
134
+ #
135
+ # destination = client.payout_destinations.get(destination_id)
136
+ # payout = client.payouts.prepare(destination_id: destination_id, amount: "250.00")
137
+ # client.payouts.sign_and_submit(payout, MERCHANT_PRIVATE_KEY, destination: destination,
138
+ # amount: "250.00", decimals: 6)
139
+ #
140
+ # Needs the optional +eth+ gem (loaded here, not by +require "rail0"+).
141
+ #
142
+ # @param payout [Hash] The prepared payout (from {prepare} or {get}).
143
+ # @param private_key [String, Eth::Key] The destination wallet's key.
144
+ # @param destination [Hash, nil] The destination the payout must be for.
145
+ # @param amount [String, nil] The human decimal passed to prepare (not "all").
146
+ # @param decimals [Integer, nil] The token's decimals, required with +amount:+.
147
+ # @return [Hash] The payout, now +submitted+.
148
+ def sign_and_submit(payout, private_key, destination: nil, amount: nil, decimals: nil)
149
+ require_relative "../signing"
150
+
151
+ raw = Rail0::Signing.sign_payout(payout, private_key, destination: destination,
152
+ amount: amount, decimals: decimals)
153
+ submit(payout[:id] || payout["id"], { signed_transaction: raw })
154
+ end
155
+
156
+ # Re-broadcast the signed transaction a {submit} stored (no body): the same bytes, so
157
+ # the same nonce and hash. For a submit whose broadcast was refused or went
158
+ # unanswered (422 +payout_not_signed+ when there is nothing to resend).
159
+ # @param id [String] Payout uuid.
160
+ # @return [Hash] The payout.
161
+ def resend(id)
162
+ http.post("/payouts/#{segment(id)}/resend")
163
+ end
164
+
165
+ # Cancel an UNSIGNED pending payout: it becomes +failed+ with +error_code+ "cancelled"
166
+ # and the destination is free for a new prepare. A signed one is refused 409
167
+ # +payout_already_signed+ — its transaction may still be mined.
168
+ # @param id [String] Payout uuid.
169
+ # @return [Hash] The payout.
170
+ def cancel(id)
171
+ http.post("/payouts/#{segment(id)}/cancel")
172
+ end
173
+
174
+ # Report a transfer the wallet broadcast itself, by hash (POST /payouts/{id}/submitted,
175
+ # HTTP 202). The hash must be THIS transfer — from the wallet, on the token, this amount
176
+ # to this destination — or 422 +foreign_transfer_hash+. Also accepts a LATE hash on a
177
+ # payout that failed +submission_timeout+ or +dropped+.
178
+ # @param id [String] Payout uuid.
179
+ # @param params [Hash] { transaction_hash: "0x…" }.
180
+ # @return [Hash] The payout, now +submitted+.
181
+ def submit_by_hash(id, params)
182
+ http.post("/payouts/#{segment(id)}/submitted", params)
183
+ end
184
+ end
185
+ end
186
+ end
@@ -16,6 +16,10 @@ module Rail0
16
16
  include Query
17
17
 
18
18
  # Event topics a subscription can carry. It may carry any non-empty subset.
19
+ # The payout topics deliver the same envelope ({id, emitted_at, topic}) with the
20
+ # subject under its own key: +payout+ for payouts.confirmed / payouts.failed (a
21
+ # failure's reason is its +error_code+), +payout_destination+ for
22
+ # payout_destinations.verified.
19
23
  TOPICS = %w[
20
24
  payments.created
21
25
  payments.signed
@@ -30,6 +34,9 @@ module Rail0
30
34
  payments.failed
31
35
  payments.disputed
32
36
  payments.dispute_closed
37
+ payouts.confirmed
38
+ payouts.failed
39
+ payout_destinations.verified
33
40
  ].freeze
34
41
 
35
42
  attr_reader :http
data/lib/rail0/signing.rb CHANGED
@@ -15,6 +15,7 @@ end
15
15
 
16
16
  require_relative "tx_verify"
17
17
  require_relative "payload_verify"
18
+ require_relative "payout_verify"
18
19
 
19
20
  module Rail0
20
21
  # EIP-712 and EIP-3009 signing utilities for RAIL0 payments.
@@ -277,6 +278,49 @@ module Rail0
277
278
  "so it is verified first", uplevel: 1, category: :deprecated)
278
279
  end
279
280
 
281
+ assemble_and_sign(unsigned_transaction, key)
282
+ end
283
+
284
+ # Verify, then sign, a prepared payout's +unsigned_transaction+ — the ERC-20
285
+ # +transfer(address,uint256)+ that moves the merchant's balance to a payout
286
+ # destination — and return the signed raw transaction for {Resources::Payouts#submit}.
287
+ #
288
+ # Before the key touches it, {Rail0::PayoutVerify.verify} holds the transaction to the
289
+ # payout: value 0, the payout's chain, +to+ = the payout's token, the key's own address
290
+ # as +from+, and calldata EXACTLY +transfer(payout.address, payout.amount)+ with nothing
291
+ # trailing. With +destination:+ the payout must also be for that registered destination
292
+ # (chain, token, address); with +amount:+ (the human decimal you asked prepare for) and
293
+ # +decimals:+ its amount must be exactly that. A mismatch raises
294
+ # {Rail0::TxVerify::RefusedSignature} naming the field, and nothing is signed. There is
295
+ # no blind form.
296
+ #
297
+ # destination = client.payout_destinations.get(destination_id)
298
+ # payout = client.payouts.prepare(destination_id: destination_id, amount: "250.00")
299
+ # raw = Rail0::Signing.sign_payout(payout, MERCHANT_PRIVATE_KEY, destination: destination,
300
+ # amount: "250.00", decimals: 6)
301
+ # client.payouts.submit(payout[:id], signed_transaction: raw)
302
+ #
303
+ # {Resources::Payouts#sign_and_submit} does the last two steps in one call.
304
+ #
305
+ # @param payout [Hash] the prepared payout (from +payouts.prepare+ or +payouts.get+)
306
+ # @param private_key [String, Eth::Key] the destination wallet's key
307
+ # @param destination [Hash, nil] the registered destination the payout must be for
308
+ # @param amount [String, nil] the human decimal passed to prepare (not "all")
309
+ # @param decimals [Integer, nil] the token's decimals, required with +amount:+
310
+ # @return [String] 0x-prefixed RLP-encoded signed transaction
311
+ def self.sign_payout(payout, private_key, destination: nil, amount: nil, decimals: nil)
312
+ key = private_key.is_a?(Eth::Key) ? private_key : Eth::Key.new(priv: private_key.delete_prefix("0x"))
313
+ unsigned = payout.is_a?(Hash) ? (payout[:unsigned_transaction] || payout["unsigned_transaction"]) : nil
314
+ tx = PayoutVerify.verify(unsigned, payout: payout, destination: destination, amount: amount,
315
+ decimals: decimals, signer: key.address.to_s)
316
+ assemble_and_sign(tx, key)
317
+ end
318
+
319
+ # Assembles the EIP-1559 transaction from the gateway's field-set and signs it with
320
+ # +key+ (an Eth::Key). Shared by {sign_transaction} and {sign_payout}, AFTER their
321
+ # checks: the field-set has already been verified, or deliberately not (the deprecated
322
+ # blind form).
323
+ def self.assemble_and_sign(unsigned_transaction, key)
280
324
  f = unsigned_transaction.is_a?(String) ? JSON.parse(unsigned_transaction) : unsigned_transaction
281
325
  f = f.transform_keys(&:to_s)
282
326
 
@@ -309,6 +353,8 @@ module Rail0
309
353
  "0x#{tx.hex}"
310
354
  end
311
355
 
356
+ private_class_method :assemble_and_sign
357
+
312
358
  # Verify, then sign, the EIP-3009 +signing_payload+ of a create, get or refund
313
359
  # prepare response. The checked form — prefer it over the bare {sign_payload}.
314
360
  #
@@ -185,7 +185,8 @@ module Rail0
185
185
 
186
186
  # The encodings a zero native value legitimately arrives in — absent, nil, the
187
187
  # number 0, or the string "0" / "0x0". A whitelist, deliberately: anything
188
- # unrecognised must refuse the signature rather than be guessed at.
188
+ # unrecognised must refuse the signature rather than be guessed at. Public because
189
+ # {Rail0::PayoutVerify} holds a payout transfer to the same rule.
189
190
  def self.zero_native_value?(value)
190
191
  case value
191
192
  when nil then true
@@ -201,7 +202,7 @@ module Rail0
201
202
  left.to_s.casecmp(right.to_s).zero?
202
203
  end
203
204
 
204
- private_class_method :refuse, :zero_native_value?, :same_address?
205
+ private_class_method :refuse, :same_address?
205
206
 
206
207
  # Scales the human amount the CALLER typed by the payment's token decimals, for
207
208
  # comparison against the calldata. nil when the operation takes no amount. For one