rail0-sdk 1.8.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +203 -3
- data/lib/rail0/client.rb +10 -1
- data/lib/rail0/error_hints.rb +24 -0
- data/lib/rail0/http_client.rb +2 -1
- data/lib/rail0/payout_verify.rb +211 -0
- data/lib/rail0/request.rb +19 -1
- data/lib/rail0/resources/chains.rb +51 -1
- data/lib/rail0/resources/payments.rb +8 -3
- data/lib/rail0/resources/payout_destinations.rb +234 -0
- data/lib/rail0/resources/payouts.rb +186 -0
- data/lib/rail0/resources/webhooks.rb +7 -0
- data/lib/rail0/signing.rb +46 -0
- data/lib/rail0/tx_verify.rb +3 -2
- data/lib/rail0/types.rb +185 -56
- data/lib/rail0/version.rb +1 -1
- metadata +5 -2
|
@@ -18,7 +18,9 @@ module Rail0
|
|
|
18
18
|
# List active blockchains supported by RAIL0.
|
|
19
19
|
# @param network_type [String, nil] Filter by "testnet" or "mainnet".
|
|
20
20
|
# @param symbol [String, nil] Filter by native symbol (case-insensitive, e.g. "ETH").
|
|
21
|
-
# @return [Array<Hash>] chain_id,
|
|
21
|
+
# @return [Array<Hash>] chain_id, slug (shared by a chain's testnet and mainnet, e.g.
|
|
22
|
+
# "arc"; unique per network_type), name, native_symbol, network_type, explorer_url,
|
|
23
|
+
# public_rpc_urls (keyless RPC endpoints published for clients),
|
|
22
24
|
# required_confirmations, finality_tag (the settlement rule: the tag where the
|
|
23
25
|
# chain serves one, the count only where it does not), and contract — the chain's
|
|
24
26
|
# active RAIL0 deployment as `{ address:, version:, deployed_at: }` (what holds
|
|
@@ -32,6 +34,54 @@ module Rail0
|
|
|
32
34
|
def list(network_type: nil, symbol: nil)
|
|
33
35
|
http.get("/blockchains#{build_query(network_type: network_type, symbol: symbol)}")
|
|
34
36
|
end
|
|
37
|
+
|
|
38
|
+
# Picks one chain out of a {list} result by numeric chain id OR slug. No request.
|
|
39
|
+
#
|
|
40
|
+
# +ref+ is an Integer or an all-digit String (a chain id: "5042002", 5042002) or a
|
|
41
|
+
# slug, compared case-insensitively ("Arc"). A slug is shared by a chain's testnet
|
|
42
|
+
# and mainnet, so when the list holds both it is AMBIGUOUS and raises rather than
|
|
43
|
+
# guessing which network the caller meant: pass the chain id, or a list filtered
|
|
44
|
+
# with +network_type:+. Nothing matching raises too, naming the slugs available.
|
|
45
|
+
#
|
|
46
|
+
# chains = client.chains.list(network_type: "testnet")
|
|
47
|
+
# Rail0::Resources::Chains.resolve(chains, "arc") # => { chain_id: 5042002, slug: "arc", ... }
|
|
48
|
+
# Rail0::Resources::Chains.resolve(chains, 5042002) # the same chain
|
|
49
|
+
#
|
|
50
|
+
# @param chains [Array<Hash>] blockchains, as {list} returns them (symbol or string keys)
|
|
51
|
+
# @param ref [Integer, String] chain id or slug
|
|
52
|
+
# @return [Hash] the matching chain
|
|
53
|
+
# @raise [ArgumentError] when nothing matches, or the slug matches several chains
|
|
54
|
+
def self.resolve(chains, ref)
|
|
55
|
+
key = ref.to_s.strip
|
|
56
|
+
raise ArgumentError, "a chain id or slug is required" if key.empty?
|
|
57
|
+
|
|
58
|
+
rows = Array(chains).map { |c| c.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k } }
|
|
59
|
+
return resolve_id(rows, key) if key.match?(/\A\d+\z/)
|
|
60
|
+
|
|
61
|
+
matches = rows.select { |c| c[:slug].to_s.casecmp(key).zero? }
|
|
62
|
+
return matches.first if matches.size == 1
|
|
63
|
+
|
|
64
|
+
if matches.empty?
|
|
65
|
+
raise ArgumentError, "no chain with slug #{key.inspect} (available slugs: #{describe_available(rows)})"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
ids = matches.map { |c| "#{c[:chain_id]} (#{c[:network_type] || 'unknown network'})" }.join(", ")
|
|
69
|
+
raise ArgumentError, "slug #{key.inspect} is ambiguous: it matches chains #{ids} — pass the chain id, " \
|
|
70
|
+
"or list with network_type:"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.resolve_id(rows, key)
|
|
74
|
+
found = rows.find { |c| c[:chain_id].to_s == key.sub(/\A0+(?=\d)/, "") }
|
|
75
|
+
return found if found
|
|
76
|
+
|
|
77
|
+
raise ArgumentError, "no chain with id #{key} (available: #{describe_available(rows)})"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.describe_available(rows)
|
|
81
|
+
slugs = rows.map { |c| c[:slug].to_s.downcase }.reject(&:empty?).uniq.sort
|
|
82
|
+
slugs.empty? ? "none" : slugs.join(", ")
|
|
83
|
+
end
|
|
84
|
+
private_class_method :resolve_id, :describe_available
|
|
35
85
|
end
|
|
36
86
|
end
|
|
37
87
|
end
|
|
@@ -74,7 +74,12 @@ module Rail0
|
|
|
74
74
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
75
75
|
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
76
76
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
77
|
-
# @
|
|
77
|
+
# @param after [String, nil] Cursor paging: the previous page's `meta[:next_cursor]`.
|
|
78
|
+
# Walks newest first at a constant cost per page, with no total (`total` reads 0);
|
|
79
|
+
# `page:` and any `sort:` other than "-created_at" beside it are 422 `invalid_cursor`.
|
|
80
|
+
# Omit it on the first page (pass `per_page:` alone) and follow `next_cursor` until nil.
|
|
81
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total:, total_pages:, links:,
|
|
82
|
+
# next_cursor: } }. Each row
|
|
78
83
|
# carries, besides the payment fields, `chain_id`, `decimals` (Integer, or nil when
|
|
79
84
|
# the gateway can't resolve the token — fall back to {Tokens#list} then) to render the
|
|
80
85
|
# base-unit amounts without a token lookup, and `in_flight` (Boolean: a transaction is
|
|
@@ -84,7 +89,7 @@ module Rail0
|
|
|
84
89
|
chain_id: nil, disputed: nil, operation: nil, min_amount: nil, max_amount: nil,
|
|
85
90
|
created_from: nil, created_to: nil, scope: nil, account_id: nil, has_error: nil,
|
|
86
91
|
expiring_within: nil, reference: nil, reference_origin: nil, sort: nil, page: nil,
|
|
87
|
-
per_page: nil)
|
|
92
|
+
per_page: nil, after: nil)
|
|
88
93
|
query = build_query(status: status, mode: mode, payer: payer, payee: payee, token: token,
|
|
89
94
|
rail0_id: rail0_id, reference: reference, reference_origin: reference_origin,
|
|
90
95
|
chain_id: chain_id, disputed: disputed,
|
|
@@ -93,7 +98,7 @@ module Rail0
|
|
|
93
98
|
created_from: created_from, created_to: created_to,
|
|
94
99
|
scope: scope, account_id: account_id, has_error: has_error,
|
|
95
100
|
expiring_within: expiring_within,
|
|
96
|
-
sort: sort, page: page, per_page: per_page)
|
|
101
|
+
sort: sort, page: page, per_page: per_page, after: after)
|
|
97
102
|
http.get_list("/payments#{query}")
|
|
98
103
|
end
|
|
99
104
|
|
|
@@ -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
|
#
|
data/lib/rail0/tx_verify.rb
CHANGED
|
@@ -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, :
|
|
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
|