rail0-sdk 1.8.1 → 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 +4 -4
- data/README.md +152 -3
- data/lib/rail0/client.rb +10 -1
- data/lib/rail0/error_hints.rb +20 -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 +149 -0
- data/lib/rail0/resources/payouts.rb +173 -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 +144 -56
- data/lib/rail0/version.rb +1 -1
- metadata +5 -2
|
@@ -0,0 +1,149 @@
|
|
|
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 (+reserve_amount+, +min_amount+) are BASE-UNIT strings (digits
|
|
17
|
+
# only, below 2^120); the +*_formatted+ fields beside them carry the human decimals.
|
|
18
|
+
class PayoutDestinations
|
|
19
|
+
# The default of {update}'s keywords: "not passed", told apart from an explicit value.
|
|
20
|
+
UNSET = Object.new.freeze
|
|
21
|
+
private_constant :UNSET
|
|
22
|
+
|
|
23
|
+
include Query
|
|
24
|
+
|
|
25
|
+
# The text of the gateway's +details_v1+ attestation, which +confirm_details: true+
|
|
26
|
+
# stands for on {create} and on an {update} that changes +min_amount+. Show it to the
|
|
27
|
+
# merchant and send +confirm_details: true+ only once they have accepted it.
|
|
28
|
+
CONFIRM_DETAILS_TEXT =
|
|
29
|
+
"I have checked the address, network, token and minimum deposit on the venue's deposit " \
|
|
30
|
+
"page and confirm they are correct. Funds sent to a wrong address or network, or below " \
|
|
31
|
+
"the venue's minimum, may be permanently lost and cannot be recovered by rail0."
|
|
32
|
+
|
|
33
|
+
# The venue kinds a destination can be registered as.
|
|
34
|
+
KINDS = %w[exchange circle_mint self_custody provider].freeze
|
|
35
|
+
|
|
36
|
+
# A destination's statuses — the values {list}'s +status+ filter accepts.
|
|
37
|
+
STATUSES = %w[unverified verified disabled].freeze
|
|
38
|
+
|
|
39
|
+
attr_reader :http
|
|
40
|
+
|
|
41
|
+
def initialize(http)
|
|
42
|
+
@http = http
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# List the account's payout destinations (every wallet of the account).
|
|
47
|
+
# @param wallet_id [String, nil] Only this wallet's destinations (uuid).
|
|
48
|
+
# @param status [String, nil] One of {STATUSES}.
|
|
49
|
+
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
50
|
+
# @param page [Integer, nil] Page number (1-based).
|
|
51
|
+
# @param per_page [Integer, nil] Items per page (max 100).
|
|
52
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total:, total_pages:, links: } }
|
|
53
|
+
def list(wallet_id: nil, status: nil, sort: nil, page: nil, per_page: nil)
|
|
54
|
+
query = build_query(wallet_id: wallet_id, status: status, sort: sort, page: page, per_page: per_page)
|
|
55
|
+
http.get_list("/payout_destinations#{query}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Register a payout destination for the session's own wallet. Answers 201 with the
|
|
59
|
+
# destination, +unverified+.
|
|
60
|
+
#
|
|
61
|
+
# +confirm_details+ is REQUIRED and has no default on purpose: +true+ is the merchant's
|
|
62
|
+
# attestation of {CONFIRM_DETAILS_TEXT} (the gateway's +details_v1+) —
|
|
63
|
+
#
|
|
64
|
+
# "I have checked the address, network, token and minimum deposit on the venue's
|
|
65
|
+
# deposit page and confirm they are correct. Funds sent to a wrong address or network,
|
|
66
|
+
# or below the venue's minimum, may be permanently lost and cannot be recovered by
|
|
67
|
+
# rail0."
|
|
68
|
+
#
|
|
69
|
+
# — recorded with who and when in the +destination.created+ event. Anything but +true+ is
|
|
70
|
+
# refused 422 +confirmation_required+ and nothing is saved. Pass the merchant's answer,
|
|
71
|
+
# never a hard-coded +true+.
|
|
72
|
+
#
|
|
73
|
+
# @param wallet_id [String] The wallet (uuid or address) that will sign transfers to it;
|
|
74
|
+
# must be the session's own.
|
|
75
|
+
# @param chain_id [Integer] The chain the deposit address was issued for.
|
|
76
|
+
# @param token [String] The token's address on that chain.
|
|
77
|
+
# @param address [String] The deposit address, generated at the venue WITH THE NETWORK
|
|
78
|
+
# SET TO THIS CHAIN.
|
|
79
|
+
# @param confirm_details [Boolean] The merchant's acceptance of {CONFIRM_DETAILS_TEXT}.
|
|
80
|
+
# @param label [String, nil] A name for the destination.
|
|
81
|
+
# @param kind [String, nil] One of {KINDS}; the gateway defaults to "exchange".
|
|
82
|
+
# @param reserve_amount [String, nil] Base units to keep in the wallet on a full-balance
|
|
83
|
+
# ("all") payout; omitted, the gateway's suggestion (the refundable balance still
|
|
84
|
+
# inside its refund window) applies.
|
|
85
|
+
# @param min_amount [String, nil] The venue's deposit minimum in base units; omitted, none.
|
|
86
|
+
# A payout below it is refused (+payout_below_minimum+) and the proof is raised to it.
|
|
87
|
+
# @return [Hash] The destination.
|
|
88
|
+
def create(wallet_id:, chain_id:, token:, address:, confirm_details:, label: nil, kind: nil,
|
|
89
|
+
reserve_amount: nil, min_amount: nil)
|
|
90
|
+
body = { wallet_id: wallet_id, chain_id: chain_id, token: token, address: address,
|
|
91
|
+
confirm_details: confirm_details, label: label, kind: kind,
|
|
92
|
+
reserve_amount: reserve_amount, min_amount: min_amount }.compact
|
|
93
|
+
http.post("/payout_destinations", body)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Read one destination (any wallet of the account may).
|
|
97
|
+
# @param id [String] Destination uuid.
|
|
98
|
+
# @return [Hash]
|
|
99
|
+
def get(id)
|
|
100
|
+
http.get("/payout_destinations/#{segment(id)}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Update a destination. Only the keywords you PASS are sent, and at least one is
|
|
104
|
+
# required (ArgumentError otherwise).
|
|
105
|
+
#
|
|
106
|
+
# - +reserve_amount+ / +min_amount+: base units; an empty string restores the suggestion
|
|
107
|
+
# (reserve) or removes the minimum.
|
|
108
|
+
# - A request that CHANGES +min_amount+ also needs +confirm_details: true+ — the same
|
|
109
|
+
# attestation as {create} ({CONFIRM_DETAILS_TEXT}), else 422 +confirmation_required+
|
|
110
|
+
# and nothing is written. Raising the minimum above the destination's latest proof
|
|
111
|
+
# takes its verification away: prove it again with a new proof.
|
|
112
|
+
# - +active: false+ disables it (any wallet of the account may); its unsigned pending
|
|
113
|
+
# payout is cancelled, a signed one refuses the switch-off 409 +payout_already_signed+.
|
|
114
|
+
# +active: true+ re-enables it with the verification it had.
|
|
115
|
+
#
|
|
116
|
+
# @param id [String] Destination uuid.
|
|
117
|
+
# @return [Hash] The destination.
|
|
118
|
+
def update(id, label: UNSET, reserve_amount: UNSET, min_amount: UNSET, active: UNSET,
|
|
119
|
+
confirm_details: UNSET)
|
|
120
|
+
body = { label: label, reserve_amount: reserve_amount, min_amount: min_amount, active: active,
|
|
121
|
+
confirm_details: confirm_details }.reject { |_, v| v.equal?(UNSET) }
|
|
122
|
+
if body.except(:confirm_details).empty?
|
|
123
|
+
raise ArgumentError, "at least one of label, reserve_amount, min_amount or active is required"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
http.patch("/payout_destinations/#{segment(id)}", body)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The merchant's attestation that the venue CREDITED the proof transfer: marks the
|
|
130
|
+
# destination +verified+. The proof payout must be +confirmed+ on-chain first (422
|
|
131
|
+
# +payout_destination_proof_missing+ otherwise).
|
|
132
|
+
# @param id [String] Destination uuid.
|
|
133
|
+
# @return [Hash] The destination, now verified.
|
|
134
|
+
def confirm(id)
|
|
135
|
+
http.post("/payout_destinations/#{segment(id)}/confirm")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The destination's audit trail, newest first: its own events and those of every
|
|
139
|
+
# payout made to it (+destination.created+, +payout.prepared+, +payout.confirmed+, …).
|
|
140
|
+
# @param id [String] Destination uuid.
|
|
141
|
+
# @param page [Integer, nil] Page number (1-based).
|
|
142
|
+
# @param per_page [Integer, nil] Items per page (default 25, max 100).
|
|
143
|
+
# @return [Hash] { data: Array<Hash>, meta: { ... } }
|
|
144
|
+
def events(id, page: nil, per_page: nil)
|
|
145
|
+
http.get_list("/payout_destinations/#{segment(id)}/events#{build_query(page: page, per_page: per_page)}")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
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 the destination's reserve); the payout record's +amount+ is base units, with
|
|
24
|
+
# +amount_formatted+ beside it.
|
|
25
|
+
class Payouts
|
|
26
|
+
include Query
|
|
27
|
+
|
|
28
|
+
# What a payout is for: "proof" (the small test transfer that verifies a
|
|
29
|
+
# destination) or "payout" (the default).
|
|
30
|
+
PURPOSES = %w[payout proof].freeze
|
|
31
|
+
|
|
32
|
+
# A payout's statuses — the values {list}'s +status+ filter accepts.
|
|
33
|
+
STATUSES = %w[pending submitted confirmed failed].freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :http
|
|
36
|
+
|
|
37
|
+
def initialize(http)
|
|
38
|
+
@http = http
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# List the account's payouts, newest first by default.
|
|
43
|
+
# @param destination_id [String, nil] Only payouts to this destination (uuid).
|
|
44
|
+
# @param wallet_id [String, nil] Only this wallet's payouts (uuid).
|
|
45
|
+
# @param status [String, Array<String>, nil] One of {STATUSES}, or an Array matching
|
|
46
|
+
# ANY of them (sent comma-separated).
|
|
47
|
+
# @param purpose [String, nil] One of {PURPOSES}.
|
|
48
|
+
# @param created_from [String, nil] Only payouts created at/after this ISO-8601 time.
|
|
49
|
+
# @param created_to [String, nil] Only payouts created at/before this ISO-8601 time.
|
|
50
|
+
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
51
|
+
# @param page [Integer, nil] Page number (1-based).
|
|
52
|
+
# @param per_page [Integer, nil] Items per page (default 25, max 100).
|
|
53
|
+
# @param after [String, nil] Cursor paging, as on {Payments#list}: the previous page's
|
|
54
|
+
# `meta[:next_cursor]`; no total, and not combinable with `page:` or another sort.
|
|
55
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total:, total_pages:, links:,
|
|
56
|
+
# next_cursor: } }
|
|
57
|
+
def list(destination_id: nil, wallet_id: nil, status: nil, purpose: nil, created_from: nil,
|
|
58
|
+
created_to: nil, sort: nil, page: nil, per_page: nil, after: nil)
|
|
59
|
+
query = build_query(destination_id: destination_id, wallet_id: wallet_id, status: status,
|
|
60
|
+
purpose: purpose, created_from: created_from, created_to: created_to,
|
|
61
|
+
sort: sort, page: page, per_page: per_page, after: after)
|
|
62
|
+
http.get_list("/payouts#{query}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Prepare a transfer to a destination (POST /payouts/prepare): 201 with a new pending
|
|
66
|
+
# payout, or 200 re-preparing the destination's unsigned pending one (one pending
|
|
67
|
+
# payout per destination). Its +unsigned_transaction+ is what to sign.
|
|
68
|
+
#
|
|
69
|
+
# - +purpose: "proof"+ sends the gateway's proof amount, raised to the destination's
|
|
70
|
+
# +min_amount+ when that is higher; +amount+ is ignored. An unverified destination
|
|
71
|
+
# may receive the proof at most.
|
|
72
|
+
# - +purpose: "payout"+ (the default) sends +amount+: a human decimal, or "all" for
|
|
73
|
+
# the balance minus the reserve. Below the destination's +min_amount+ it is refused
|
|
74
|
+
# 422 +payout_below_minimum+.
|
|
75
|
+
# - A payout that already holds a signed transaction cannot be re-prepared (409
|
|
76
|
+
# +payout_already_signed+): {resend} it, or let it settle.
|
|
77
|
+
#
|
|
78
|
+
# @param destination_id [String] Destination uuid.
|
|
79
|
+
# @param purpose [String, nil] One of {PURPOSES}; the gateway defaults to "payout".
|
|
80
|
+
# @param amount [String, nil] Human decimal or "all"; required for a payout.
|
|
81
|
+
# @return [Hash] The pending payout.
|
|
82
|
+
def prepare(destination_id:, purpose: nil, amount: nil)
|
|
83
|
+
body = { destination_id: destination_id, purpose: purpose, amount: amount }.compact
|
|
84
|
+
http.post("/payouts/prepare", body)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Read one payout.
|
|
88
|
+
# @param id [String] Payout uuid.
|
|
89
|
+
# @return [Hash]
|
|
90
|
+
def get(id)
|
|
91
|
+
http.get("/payouts/#{segment(id)}")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The payout's audit trail, newest first.
|
|
95
|
+
# @param id [String] Payout uuid.
|
|
96
|
+
# @param page [Integer, nil] Page number (1-based).
|
|
97
|
+
# @param per_page [Integer, nil] Items per page (default 25, max 100).
|
|
98
|
+
# @return [Hash] { data: Array<Hash>, meta: { ... } }
|
|
99
|
+
def events(id, page: nil, per_page: nil)
|
|
100
|
+
http.get_list("/payouts/#{segment(id)}/events#{build_query(page: page, per_page: per_page)}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Submit the signed transfer; the gateway stores it, then broadcasts it (HTTP 202,
|
|
104
|
+
# now +submitted+). A node refusal answers 502 and leaves the payout +pending+ with
|
|
105
|
+
# its signature: {resend} it. Prefer {sign_and_submit}, which verifies before signing.
|
|
106
|
+
# @param id [String] Payout uuid.
|
|
107
|
+
# @param params [Hash] { signed_transaction: "0x…" }.
|
|
108
|
+
# @return [Hash] The payout.
|
|
109
|
+
def submit(id, params)
|
|
110
|
+
http.post("/payouts/#{segment(id)}/submit", params)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Verify, sign and submit a prepared payout in one call.
|
|
114
|
+
#
|
|
115
|
+
# {Rail0::Signing.sign_payout} first holds the +unsigned_transaction+ to the payout —
|
|
116
|
+
# +to+ the token, value 0, the payout's chain, calldata exactly
|
|
117
|
+
# +transfer(destination address, payout amount)+, nothing trailing — and, when given,
|
|
118
|
+
# to the registered +destination:+ and to the +amount:+ you asked prepare for (scaled
|
|
119
|
+
# by +decimals:+). Anything else raises {Rail0::TxVerify::RefusedSignature} and
|
|
120
|
+
# nothing is signed or sent. Then {submit}.
|
|
121
|
+
#
|
|
122
|
+
# destination = client.payout_destinations.get(destination_id)
|
|
123
|
+
# payout = client.payouts.prepare(destination_id: destination_id, amount: "250.00")
|
|
124
|
+
# client.payouts.sign_and_submit(payout, MERCHANT_PRIVATE_KEY, destination: destination,
|
|
125
|
+
# amount: "250.00", decimals: 6)
|
|
126
|
+
#
|
|
127
|
+
# Needs the optional +eth+ gem (loaded here, not by +require "rail0"+).
|
|
128
|
+
#
|
|
129
|
+
# @param payout [Hash] The prepared payout (from {prepare} or {get}).
|
|
130
|
+
# @param private_key [String, Eth::Key] The destination wallet's key.
|
|
131
|
+
# @param destination [Hash, nil] The destination the payout must be for.
|
|
132
|
+
# @param amount [String, nil] The human decimal passed to prepare (not "all").
|
|
133
|
+
# @param decimals [Integer, nil] The token's decimals, required with +amount:+.
|
|
134
|
+
# @return [Hash] The payout, now +submitted+.
|
|
135
|
+
def sign_and_submit(payout, private_key, destination: nil, amount: nil, decimals: nil)
|
|
136
|
+
require_relative "../signing"
|
|
137
|
+
|
|
138
|
+
raw = Rail0::Signing.sign_payout(payout, private_key, destination: destination,
|
|
139
|
+
amount: amount, decimals: decimals)
|
|
140
|
+
submit(payout[:id] || payout["id"], { signed_transaction: raw })
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Re-broadcast the signed transaction a {submit} stored (no body): the same bytes, so
|
|
144
|
+
# the same nonce and hash. For a submit whose broadcast was refused or went
|
|
145
|
+
# unanswered (422 +payout_not_signed+ when there is nothing to resend).
|
|
146
|
+
# @param id [String] Payout uuid.
|
|
147
|
+
# @return [Hash] The payout.
|
|
148
|
+
def resend(id)
|
|
149
|
+
http.post("/payouts/#{segment(id)}/resend")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Cancel an UNSIGNED pending payout: it becomes +failed+ with +error_code+ "cancelled"
|
|
153
|
+
# and the destination is free for a new prepare. A signed one is refused 409
|
|
154
|
+
# +payout_already_signed+ — its transaction may still be mined.
|
|
155
|
+
# @param id [String] Payout uuid.
|
|
156
|
+
# @return [Hash] The payout.
|
|
157
|
+
def cancel(id)
|
|
158
|
+
http.post("/payouts/#{segment(id)}/cancel")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Report a transfer the wallet broadcast itself, by hash (POST /payouts/{id}/submitted,
|
|
162
|
+
# HTTP 202). The hash must be THIS transfer — from the wallet, on the token, this amount
|
|
163
|
+
# to this destination — or 422 +foreign_transfer_hash+. Also accepts a LATE hash on a
|
|
164
|
+
# payout that failed +submission_timeout+ or +dropped+.
|
|
165
|
+
# @param id [String] Payout uuid.
|
|
166
|
+
# @param params [Hash] { transaction_hash: "0x…" }.
|
|
167
|
+
# @return [Hash] The payout, now +submitted+.
|
|
168
|
+
def submit_by_hash(id, params)
|
|
169
|
+
http.post("/payouts/#{segment(id)}/submitted", params)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
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
|