rail0-sdk 1.0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +753 -0
- data/lib/rail0/api_error.rb +50 -0
- data/lib/rail0/backoff.rb +66 -0
- data/lib/rail0/client.rb +89 -0
- data/lib/rail0/default_logger.rb +82 -0
- data/lib/rail0/error_hints.rb +80 -0
- data/lib/rail0/http_client.rb +99 -0
- data/lib/rail0/request.rb +211 -0
- data/lib/rail0/resources/accounts.rb +35 -0
- data/lib/rail0/resources/analytics.rb +112 -0
- data/lib/rail0/resources/auth.rb +192 -0
- data/lib/rail0/resources/chains.rb +29 -0
- data/lib/rail0/resources/disputes.rb +35 -0
- data/lib/rail0/resources/health.rb +23 -0
- data/lib/rail0/resources/payment_methods.rb +37 -0
- data/lib/rail0/resources/payments.rb +344 -0
- data/lib/rail0/resources/query.rb +19 -0
- data/lib/rail0/resources/tokens.rb +28 -0
- data/lib/rail0/resources/wallets.rb +160 -0
- data/lib/rail0/resources/webhooks.rb +146 -0
- data/lib/rail0/signing.rb +370 -0
- data/lib/rail0/stablecoins.rb +123 -0
- data/lib/rail0/types.rb +295 -0
- data/lib/rail0/version.rb +9 -0
- data/lib/rail0/webhook_signature.rb +80 -0
- data/lib/rail0-sdk.rb +11 -0
- data/lib/rail0.rb +20 -0
- metadata +91 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "query"
|
|
4
|
+
|
|
5
|
+
module Rail0
|
|
6
|
+
module Resources
|
|
7
|
+
# Payment lifecycle operations.
|
|
8
|
+
#
|
|
9
|
+
# Every on-chain operation is two-phase: a +*_prepare+ call returns an unsigned
|
|
10
|
+
# EIP-1559 transaction (sign it locally with {Rail0::Signing.sign_transaction}),
|
|
11
|
+
# then the matching submit call broadcasts the signed raw tx (HTTP 202, async —
|
|
12
|
+
# poll {get} until the status settles). Wallets that sign and broadcast in one
|
|
13
|
+
# step (e.g. MetaMask) skip the local signer and report the hash via
|
|
14
|
+
# {submit_by_hash} instead.
|
|
15
|
+
class Payments
|
|
16
|
+
include Query
|
|
17
|
+
|
|
18
|
+
# Operations accepted by the prepare/submit endpoints.
|
|
19
|
+
OPERATIONS = %w[authorize capture charge void release refund].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :http
|
|
22
|
+
|
|
23
|
+
def initialize(http)
|
|
24
|
+
@http = http
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# List payments for the authenticated wallet (requires JWT). Returns
|
|
29
|
+
# payments where the caller is the payer or payee.
|
|
30
|
+
# @param status [String, nil] Filter by payment status.
|
|
31
|
+
# @param mode [String, nil] Filter by mode ("authorize" or "charge").
|
|
32
|
+
# @param payer [String, nil] Filter by payer address.
|
|
33
|
+
# @param payee [String, nil] Filter by payee address.
|
|
34
|
+
# @param token [String, nil] Filter by token contract address.
|
|
35
|
+
# @param rail0_id [String, nil] Filter by the logical on-chain payment id (0x…).
|
|
36
|
+
# @param chain_id [Integer, nil] Filter by the payment's chain.
|
|
37
|
+
# @param disputed [Boolean, nil] Filter by whether an open dispute exists.
|
|
38
|
+
# @param min_amount [String, nil] Minimum amount in token BASE UNITS (inclusive).
|
|
39
|
+
# @param max_amount [String, nil] Maximum amount in token BASE UNITS (inclusive).
|
|
40
|
+
# These two really are base units — the filter runs against the stored column.
|
|
41
|
+
# Amounts you SEND (create, capture, refund) are human decimals instead, which
|
|
42
|
+
# the gateway scales by the token's decimals. The two units coexist in the API.
|
|
43
|
+
# @param created_from [String, nil] Only payments created at/after this ISO-8601 time.
|
|
44
|
+
# @param created_to [String, nil] Only payments created at/before this ISO-8601 time.
|
|
45
|
+
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
46
|
+
# @param page [Integer, nil] Page number (1-based).
|
|
47
|
+
# @param per_page [Integer, nil] Items per page (max 100).
|
|
48
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
49
|
+
def list(status: nil, mode: nil, payer: nil, payee: nil, token: nil, rail0_id: nil,
|
|
50
|
+
chain_id: nil, disputed: nil, min_amount: nil, max_amount: nil,
|
|
51
|
+
created_from: nil, created_to: nil, sort: nil, page: nil, per_page: nil)
|
|
52
|
+
query = build_query(status: status, mode: mode, payer: payer, payee: payee, token: token,
|
|
53
|
+
rail0_id: rail0_id, chain_id: chain_id, disputed: disputed,
|
|
54
|
+
min_amount: min_amount, max_amount: max_amount,
|
|
55
|
+
created_from: created_from, created_to: created_to,
|
|
56
|
+
sort: sort, page: page, per_page: per_page)
|
|
57
|
+
http.get_list("/payments#{query}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Create a payment. Returns the record — when still unsigned it embeds the
|
|
61
|
+
# EIP-3009 +signing_payload+ for the payer to sign.
|
|
62
|
+
#
|
|
63
|
+
# Pass +idempotency_key+ to make the request replay-safe: a repeated call
|
|
64
|
+
# with the same key returns the existing payment (HTTP 200) instead of
|
|
65
|
+
# creating a new one.
|
|
66
|
+
#
|
|
67
|
+
# Accepts either a params Hash or keyword fields:
|
|
68
|
+
# create(chain_id: 84532, mode: "authorize", amount: "100.00", token: "0x…", payer: "0x…", payee: "0x…")
|
|
69
|
+
# create({ chain_id: 84532, ... }, idempotency_key: "order-42")
|
|
70
|
+
#
|
|
71
|
+
# @param params [Hash, nil] chain_id, mode, amount, token, payer, payee, description (opt), metadata (opt).
|
|
72
|
+
# @param idempotency_key [String, nil] Optional Idempotency-Key header value.
|
|
73
|
+
# @param fields [Hash] Field keywords, used when +params+ is omitted.
|
|
74
|
+
# @return [Hash]
|
|
75
|
+
def create(params = nil, idempotency_key: nil, **fields)
|
|
76
|
+
body = params || fields
|
|
77
|
+
headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
|
|
78
|
+
http.post("/payments", body, headers: headers)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Fetch current payment state (DB status + live on-chain amounts + transactions).
|
|
82
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
83
|
+
# @return [Hash]
|
|
84
|
+
def get(id)
|
|
85
|
+
http.get("/payments/#{id}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# List on-chain transactions for a payment.
|
|
89
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
90
|
+
# @param operation [String, nil] Filter by operation (see {OPERATIONS}).
|
|
91
|
+
# @param status [String, nil] Filter by transaction status.
|
|
92
|
+
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
93
|
+
# @param page [Integer, nil] Page number (1-based).
|
|
94
|
+
# @param per_page [Integer, nil] Items per page (max 100).
|
|
95
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
96
|
+
def transactions(id, operation: nil, status: nil, sort: nil, page: nil, per_page: nil)
|
|
97
|
+
query = build_query(operation: operation, status: status, sort: sort, page: page, per_page: per_page)
|
|
98
|
+
http.get_list("/payments/#{id}/transactions#{query}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Fetch ONE of a payment's transactions
|
|
102
|
+
# (GET /payments/{id}/transactions/{transaction_id}).
|
|
103
|
+
#
|
|
104
|
+
# The lookup for an action id: anything handed a transaction id when an
|
|
105
|
+
# operation was accepted resolves it directly, instead of fetching the payment
|
|
106
|
+
# and scanning its transactions for an id it already holds. Readable by either
|
|
107
|
+
# participant; an unknown, malformed or foreign id all answer 404 alike.
|
|
108
|
+
# (rail0-gateway#330)
|
|
109
|
+
#
|
|
110
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
111
|
+
# @param transaction_id [String] Transaction UUID.
|
|
112
|
+
# @return [Hash]
|
|
113
|
+
def get_transaction(id, transaction_id)
|
|
114
|
+
http.get("/payments/#{id}/transactions/#{transaction_id}")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Re-enqueue a stuck broadcast
|
|
118
|
+
# (POST /payments/{id}/transactions/{transaction_id}/redrive).
|
|
119
|
+
#
|
|
120
|
+
# For the one shape a retry can fix: a transaction that is `pending` and whose
|
|
121
|
+
# SIGNED bytes the gateway already holds — prepared and signed, never landed on the
|
|
122
|
+
# chain (a worker that died between the two, a queue drained by hand). Nothing about
|
|
123
|
+
# the payment changes; the same bytes go back to the broadcaster.
|
|
124
|
+
#
|
|
125
|
+
# Offer this on the transaction's `redrivable` flag, which is the same predicate the
|
|
126
|
+
# gateway guards the route with — not on `status == "pending"`. A pending row holding
|
|
127
|
+
# no signed transaction is NOT redrivable, and there the next step is submitting the
|
|
128
|
+
# signature, not retrying a send that never happened.
|
|
129
|
+
#
|
|
130
|
+
# The transaction id is resolved THROUGH the payment, so one belonging to another
|
|
131
|
+
# payment answers 404 rather than redriving someone else's row.
|
|
132
|
+
#
|
|
133
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
134
|
+
# @param transaction_id [String] The transaction row to redrive.
|
|
135
|
+
# @return [Hash] The transaction, re-enqueued.
|
|
136
|
+
def redrive(id, transaction_id)
|
|
137
|
+
http.post("/payments/#{id}/transactions/#{transaction_id}/redrive", {})
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Submit the payer's EIP-712 signature (PUT /payments/{id}/sign).
|
|
141
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
142
|
+
# @param params [Hash] { signature: "0x…" } (65-byte 0x-prefixed hex).
|
|
143
|
+
# @return [Hash]
|
|
144
|
+
def sign(id, params)
|
|
145
|
+
http.put("/payments/#{id}/sign", params)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# List a payment's dispute open/close history.
|
|
149
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
150
|
+
# @param status [String, nil] Filter by dispute status ("open" or "closed").
|
|
151
|
+
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
152
|
+
# @param page [Integer, nil] Page number (1-based).
|
|
153
|
+
# @param per_page [Integer, nil] Items per page (max 100).
|
|
154
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
155
|
+
def disputes(id, status: nil, sort: nil, page: nil, per_page: nil)
|
|
156
|
+
query = build_query(status: status, sort: sort, page: page, per_page: per_page)
|
|
157
|
+
http.get_list("/payments/#{id}/disputes#{query}")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Build the unsigned transaction for an operation
|
|
161
|
+
# (POST /payments/{id}/{op}/prepare). +body+ carries operation-specific
|
|
162
|
+
# fields: amount (capture, refund), signature (refund phase-2), from
|
|
163
|
+
# (release). On the refund prepare, omitting the signature returns the
|
|
164
|
+
# EIP-3009 signing payload (refund phase-1) instead of an unsigned tx.
|
|
165
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
166
|
+
# @param operation [String] One of {OPERATIONS}.
|
|
167
|
+
# @param body [Hash, nil] Operation-specific fields.
|
|
168
|
+
# @return [Hash]
|
|
169
|
+
# Pass +idempotency_key+ to make a repeat safe. Without one, a retry that
|
|
170
|
+
# arrives AFTER the first transaction was signed and broadcast opens a SECOND
|
|
171
|
+
# one — right for a genuine sequential partial capture, wrong for a retry, and
|
|
172
|
+
# nothing but the caller can tell those apart. Replaying a key returns the
|
|
173
|
+
# transaction the first call created (HTTP 200 rather than 201); the same key
|
|
174
|
+
# with different terms is refused 422 +idempotency_key_reused+. Scoped to this
|
|
175
|
+
# payment. (rail0-gateway#331)
|
|
176
|
+
def prepare(id, operation, body = nil, idempotency_key: nil)
|
|
177
|
+
http.post("/payments/#{id}/#{operation}/prepare", body,
|
|
178
|
+
headers: idempotency_headers(idempotency_key))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Broadcast a signed transaction for an operation (POST /payments/{id}/{op}); HTTP 202.
|
|
182
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
183
|
+
# @param operation [String] One of {OPERATIONS}.
|
|
184
|
+
# @param params [Hash] { signed_transaction: "0x…" }.
|
|
185
|
+
# @return [Hash]
|
|
186
|
+
def submit(id, operation, params)
|
|
187
|
+
http.post("/payments/#{id}/#{operation}", params)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Record a transaction the caller broadcast themselves (MetaMask/wallet flow)
|
|
191
|
+
# for an operation (POST /payments/{id}/{op}/submitted); HTTP 202.
|
|
192
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
193
|
+
# @param operation [String] One of {OPERATIONS}.
|
|
194
|
+
# @param params [Hash] { transaction_hash: "0x…" }.
|
|
195
|
+
# @return [Hash]
|
|
196
|
+
def submit_by_hash(id, operation, params)
|
|
197
|
+
http.post("/payments/#{id}/#{operation}/submitted", params)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Phase 1 — build the unsigned authorize() transaction (escrow hold).
|
|
201
|
+
def authorize_prepare(id, idempotency_key: nil)
|
|
202
|
+
prepare(id, "authorize", nil, idempotency_key: idempotency_key)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Phase 2 — submit the signed authorize transaction.
|
|
206
|
+
def authorize(id, params)
|
|
207
|
+
submit(id, "authorize", params)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Phase 1 — build the unsigned charge() transaction (one-shot authorize+capture).
|
|
211
|
+
def charge_prepare(id, idempotency_key: nil)
|
|
212
|
+
prepare(id, "charge", nil, idempotency_key: idempotency_key)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Phase 2 — submit the signed charge transaction.
|
|
216
|
+
def charge(id, params)
|
|
217
|
+
submit(id, "charge", params)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Phase 1 — build the unsigned capture() transaction. +amount+ is required.
|
|
221
|
+
def capture_prepare(id, amount, idempotency_key: nil)
|
|
222
|
+
prepare(id, "capture", { amount: amount }, idempotency_key: idempotency_key)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Phase 2 — submit the signed capture transaction.
|
|
226
|
+
def capture(id, params)
|
|
227
|
+
submit(id, "capture", params)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Phase 1 — build the unsigned void() transaction. Valid only while nothing
|
|
231
|
+
# has been captured yet; after any capture the contract reverts AlreadyCaptured
|
|
232
|
+
# (use {release_prepare} to return the uncaptured remainder instead).
|
|
233
|
+
def void_prepare(id, idempotency_key: nil)
|
|
234
|
+
prepare(id, "void", nil, idempotency_key: idempotency_key)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Phase 2 — submit the signed void transaction.
|
|
238
|
+
def void(id, params)
|
|
239
|
+
submit(id, "void", params)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Phase 1 — build the unsigned release() transaction. +from+ overrides the
|
|
243
|
+
# submitter address (defaults to the payer). Returns uncaptured escrow to the
|
|
244
|
+
# payer.
|
|
245
|
+
def release_prepare(id, from: nil, idempotency_key: nil)
|
|
246
|
+
prepare(id, "release", from ? { from: from } : {}, idempotency_key: idempotency_key)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Phase 2 — submit the signed release transaction.
|
|
250
|
+
def release(id, params)
|
|
251
|
+
submit(id, "release", params)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Two-phase EIP-3009 refund.
|
|
255
|
+
# Phase 1: pass only +amount+ → returns a signing payload for the payee to sign.
|
|
256
|
+
# Phase 2: pass +amount+ and +signature+ → returns the unsigned refund tx.
|
|
257
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
258
|
+
# @param amount [String] Amount to refund, as a human decimal (e.g. "20.00").
|
|
259
|
+
# @param signature [String, nil] Payee's EIP-3009 signature (0x…), phase 2 only.
|
|
260
|
+
# @return [Hash]
|
|
261
|
+
def refund_prepare(id, amount:, signature: nil, idempotency_key: nil)
|
|
262
|
+
body = { amount: amount }
|
|
263
|
+
body[:signature] = signature unless signature.nil?
|
|
264
|
+
prepare(id, "refund", body, idempotency_key: idempotency_key)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Phase 2 — submit the signed refund transaction.
|
|
268
|
+
def refund(id, params)
|
|
269
|
+
submit(id, "refund", params)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Phase 1 — build the unsigned dispute() transaction (payer only).
|
|
273
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
274
|
+
# @param reason [String, nil] Optional bytes32 code (0x…); defaults to zero server-side.
|
|
275
|
+
# @return [Hash]
|
|
276
|
+
def dispute_prepare(id, reason: nil, idempotency_key: nil)
|
|
277
|
+
prepare_dispute("dispute/prepare", id, reason, idempotency_key: idempotency_key)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Phase 2 — submit the signed dispute transaction (payer only).
|
|
281
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
282
|
+
# @param params [Hash] { signed_transaction: "0x…" }.
|
|
283
|
+
# @return [Hash]
|
|
284
|
+
def dispute(id, params)
|
|
285
|
+
http.post("/payments/#{id}/dispute", params)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Phase 1 — build the unsigned closeDispute() transaction (payer only).
|
|
289
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
290
|
+
# @param reason [String, nil] Optional bytes32 code (0x…).
|
|
291
|
+
# @return [Hash]
|
|
292
|
+
def close_dispute_prepare(id, reason: nil, idempotency_key: nil)
|
|
293
|
+
prepare_dispute("dispute/close/prepare", id, reason, idempotency_key: idempotency_key)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Phase 2 — submit the signed close-dispute transaction (payer only).
|
|
297
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
298
|
+
# @param params [Hash] { signed_transaction: "0x…" }.
|
|
299
|
+
# @return [Hash]
|
|
300
|
+
def close_dispute(id, params)
|
|
301
|
+
http.post("/payments/#{id}/dispute/close", params)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# The payer's counterpart to {#submit_by_hash}, which covers only the operations
|
|
305
|
+
# under /payments/{id}/{operation}/submitted — the two dispute paths are not shaped
|
|
306
|
+
# that way (`dispute/close` is two segments), so they need their own methods. Both
|
|
307
|
+
# exist in rail0-go and rail0-ts; without them a Ruby caller signing with a wallet
|
|
308
|
+
# that broadcasts on its own (MetaMask) could open and close disputes with a raw
|
|
309
|
+
# signed transaction, but never report one it had already sent. (#19)
|
|
310
|
+
#
|
|
311
|
+
# Payer-only, and the payer authenticates account-less via SIWE: a bare hash
|
|
312
|
+
# carries no signature, so the session is what proves who is reporting it.
|
|
313
|
+
|
|
314
|
+
# Report an already-broadcast dispute transaction by hash (payer only); HTTP 202.
|
|
315
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
316
|
+
# @param params [Hash] { transaction_hash: "0x…" }.
|
|
317
|
+
# @return [Hash]
|
|
318
|
+
def dispute_submit_by_hash(id, params)
|
|
319
|
+
http.post("/payments/#{id}/dispute/submitted", params)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Report an already-broadcast close-dispute transaction by hash (payer only); HTTP 202.
|
|
323
|
+
# @param id [String] Payment UUID or rail0_id.
|
|
324
|
+
# @param params [Hash] { transaction_hash: "0x…" }.
|
|
325
|
+
# @return [Hash]
|
|
326
|
+
def close_dispute_submit_by_hash(id, params)
|
|
327
|
+
http.post("/payments/#{id}/dispute/close/submitted", params)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
def prepare_dispute(path, id, reason, idempotency_key: nil)
|
|
333
|
+
body = reason ? { reason: reason } : {}
|
|
334
|
+
http.post("/payments/#{id}/#{path}", body,
|
|
335
|
+
headers: idempotency_headers(idempotency_key))
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# {} when no key, so an un-keyed call sends exactly the headers it sent before.
|
|
339
|
+
def idempotency_headers(key)
|
|
340
|
+
key ? { "Idempotency-Key" => key } : {}
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Rail0
|
|
6
|
+
module Resources
|
|
7
|
+
# Shared query-string builder for resource classes. Included so every
|
|
8
|
+
# resource turns keyword filters into a URL query the same way: nil values
|
|
9
|
+
# are dropped, and booleans/integers are stringified.
|
|
10
|
+
module Query
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def build_query(**params)
|
|
14
|
+
pairs = params.compact.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }
|
|
15
|
+
pairs.empty? ? "" : "?#{pairs.join('&')}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "query"
|
|
4
|
+
|
|
5
|
+
module Rail0
|
|
6
|
+
module Resources
|
|
7
|
+
# Public token catalog (GET /tokens, no auth).
|
|
8
|
+
class Tokens
|
|
9
|
+
include Query
|
|
10
|
+
|
|
11
|
+
attr_reader :http
|
|
12
|
+
|
|
13
|
+
def initialize(http)
|
|
14
|
+
@http = http
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# List active tokens, optionally filtered by chain and/or symbol.
|
|
19
|
+
# @param chain_id [Integer, nil] Chain ID to filter by. Pass nil or 0 for all chains.
|
|
20
|
+
# @param symbol [String, nil] Filter by token symbol (case-insensitive, e.g. "USDC").
|
|
21
|
+
# @return [Array<Hash>] chain_id, symbol, address, decimals
|
|
22
|
+
def list(chain_id: nil, symbol: nil)
|
|
23
|
+
chain_id = nil if chain_id == 0
|
|
24
|
+
http.get("/tokens#{build_query(chain_id: chain_id, symbol: symbol)}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "query"
|
|
4
|
+
|
|
5
|
+
module Rail0
|
|
6
|
+
module Resources
|
|
7
|
+
# Account-scoped wallet management (requires JWT).
|
|
8
|
+
#
|
|
9
|
+
# Wallets live under /accounts/{account_id}/wallets, so every method takes the
|
|
10
|
+
# account id as its first argument. +list+ returns the account's wallets, each
|
|
11
|
+
# with its token holdings nested under +tokens+ (a wallet with no matching
|
|
12
|
+
# tokens is still returned with an empty list). +id_or_address+ accepts either
|
|
13
|
+
# the wallet UUID or its 0x address (unique per account).
|
|
14
|
+
class Wallets
|
|
15
|
+
include Query
|
|
16
|
+
|
|
17
|
+
attr_reader :http
|
|
18
|
+
|
|
19
|
+
def initialize(http)
|
|
20
|
+
@http = http
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# List the account's wallets, each with nested token holdings.
|
|
25
|
+
# @param account_id [String] Account UUID.
|
|
26
|
+
# @param chain_id [Integer, nil] Restrict nested tokens to this chain id.
|
|
27
|
+
# @param token_symbol [String, nil] Restrict nested tokens to this symbol (e.g. "USDC").
|
|
28
|
+
# @param active [Boolean, nil] Filter wallets by active flag.
|
|
29
|
+
# @param default [Boolean, nil] Restrict nested holdings to the default one.
|
|
30
|
+
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
31
|
+
# @param page [Integer, nil] Page number (1-based).
|
|
32
|
+
# @param per_page [Integer, nil] Items per page (max 100).
|
|
33
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
34
|
+
def list(account_id, chain_id: nil, token_symbol: nil, active: nil, default: nil,
|
|
35
|
+
sort: nil, page: nil, per_page: nil)
|
|
36
|
+
query = build_query(chain_id: chain_id, token_symbol: token_symbol, active: active,
|
|
37
|
+
default: default, sort: sort, page: page, per_page: per_page)
|
|
38
|
+
http.get_list("/accounts/#{account_id}/wallets#{query}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Fetch a single wallet by its id or 0x address.
|
|
42
|
+
# @param account_id [String] Account UUID.
|
|
43
|
+
# @param id_or_address [String] Wallet UUID or 0x address.
|
|
44
|
+
# @return [Hash] id, address, label, active
|
|
45
|
+
def get(account_id, id_or_address)
|
|
46
|
+
http.get("/accounts/#{account_id}/wallets/#{id_or_address}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Add a wallet to the account.
|
|
50
|
+
#
|
|
51
|
+
# Requires a SIWE PROOF-OF-OWNERSHIP of the address being added, not merely
|
|
52
|
+
# the session JWT: the gateway verifies that +signature+ recovers to
|
|
53
|
+
# +address+ (422 otherwise), consumes the nonce carried in +message+, and
|
|
54
|
+
# enforces global address uniqueness (409 if registered anywhere). The proven
|
|
55
|
+
# address need not be the session address — a merchant may control several
|
|
56
|
+
# payee wallets.
|
|
57
|
+
#
|
|
58
|
+
# Obtain the pair from Auth#prove_address, which signs with the WALLET-LINK
|
|
59
|
+
# statement. A login proof is refused with 422 siwe_purpose_mismatch, so the
|
|
60
|
+
# message cannot simply be one taken from #login.
|
|
61
|
+
#
|
|
62
|
+
# proof = client.auth.prove_address(private_key: added_key, domain: "api.rail0.xyz")
|
|
63
|
+
# client.wallets.create(account_id, address: added, **proof, label: "Payouts")
|
|
64
|
+
#
|
|
65
|
+
# @param account_id [String] Account UUID.
|
|
66
|
+
# @param address [String] EVM wallet address (0x, 42 chars) — the address the proof is for.
|
|
67
|
+
# @param message [String] EIP-4361 SIWE message signed by +address+ (from Auth#prove_address).
|
|
68
|
+
# @param signature [String] Signature over +message+ (0x…), proving control of +address+.
|
|
69
|
+
# @param label [String, nil] Human-readable label.
|
|
70
|
+
# @return [Hash] id, address, label, active
|
|
71
|
+
def create(account_id, address:, message:, signature:, label: nil)
|
|
72
|
+
body = { address: address, message: message, signature: signature }
|
|
73
|
+
body[:label] = label unless label.nil?
|
|
74
|
+
http.post("/accounts/#{account_id}/wallets", body)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Update a wallet's label and/or active status.
|
|
78
|
+
# @param account_id [String] Account UUID.
|
|
79
|
+
# @param id_or_address [String] Wallet UUID or 0x address.
|
|
80
|
+
# @param label [String, nil] New label.
|
|
81
|
+
# @param active [Boolean, nil] New active status.
|
|
82
|
+
# @return [Hash] id, address, label, active
|
|
83
|
+
def update(account_id, id_or_address, label: nil, active: nil)
|
|
84
|
+
body = {}
|
|
85
|
+
body[:label] = label unless label.nil?
|
|
86
|
+
body[:active] = active unless active.nil?
|
|
87
|
+
http.patch("/accounts/#{account_id}/wallets/#{id_or_address}", body)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Soft-delete (deactivate) a wallet. Returns HTTP 204.
|
|
91
|
+
# @param account_id [String] Account UUID.
|
|
92
|
+
# @param id_or_address [String] Wallet UUID or 0x address.
|
|
93
|
+
# @return [nil]
|
|
94
|
+
def delete(account_id, id_or_address)
|
|
95
|
+
http.delete("/accounts/#{account_id}/wallets/#{id_or_address}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Read a wallet's live on-chain balances across the configured chains. Each
|
|
99
|
+
# per-chain entry carries the native gas-token balance plus the active ERC-20
|
|
100
|
+
# token balances, or an in-band error when that chain's RPC was unreachable
|
|
101
|
+
# (one dead RPC never hides the other chains' balances).
|
|
102
|
+
# @param account_id [String] Account UUID.
|
|
103
|
+
# @param id_or_address [String] Wallet UUID or 0x address.
|
|
104
|
+
# @param chain_id [Integer, nil] Restrict to one chain id (default: all configured chains).
|
|
105
|
+
# @param token_symbol [String, nil] Restrict tokens to this symbol (default: all active tokens).
|
|
106
|
+
# @return [Hash] wallet_id, address, balances
|
|
107
|
+
def balances(account_id, id_or_address, chain_id: nil, token_symbol: nil)
|
|
108
|
+
query = build_query(chain_id: chain_id, token_symbol: token_symbol)
|
|
109
|
+
http.get("/accounts/#{account_id}/wallets/#{id_or_address}/balances#{query}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ── Token holdings ────────────────────────────────────────────────────────
|
|
113
|
+
# Which tokens a wallet accepts, on which chains. This is what GET
|
|
114
|
+
# /payment_methods then exposes to buyers, so a merchant onboarding flow that
|
|
115
|
+
# can create a wallet but not configure its tokens cannot finish. (#12)
|
|
116
|
+
|
|
117
|
+
# Accept a token (on a chain) on this wallet.
|
|
118
|
+
#
|
|
119
|
+
# Idempotent by (wallet, chain, token): re-adding an existing holding
|
|
120
|
+
# re-enables it and returns 200 rather than creating a second row (201 is the
|
|
121
|
+
# first-time answer). `default` makes it the wallet's default token, which is
|
|
122
|
+
# the one a buyer is offered first.
|
|
123
|
+
#
|
|
124
|
+
# @param account_id [String] Account UUID.
|
|
125
|
+
# @param id_or_address [String] Wallet UUID or 0x address.
|
|
126
|
+
# @param chain_id [Integer] EVM chain id of the token.
|
|
127
|
+
# @param token [String] Token address (0x, 40 hex).
|
|
128
|
+
# @param default [Boolean, nil] Make this the wallet's default token.
|
|
129
|
+
# @return [Hash] the token holding
|
|
130
|
+
def add_token(account_id, id_or_address, chain_id:, token:, default: nil)
|
|
131
|
+
body = { chain_id: chain_id, token: token }
|
|
132
|
+
body[:default] = default unless default.nil?
|
|
133
|
+
http.post("/accounts/#{account_id}/wallets/#{id_or_address}/tokens", body)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Stop accepting a token on this wallet (soft delete). Returns HTTP 204.
|
|
137
|
+
#
|
|
138
|
+
# `token_id` is the id of the HOLDING as returned by {add_token} / {list}'s
|
|
139
|
+
# nested `tokens`, not the token's contract address.
|
|
140
|
+
#
|
|
141
|
+
# @return [nil]
|
|
142
|
+
def remove_token(account_id, id_or_address, token_id)
|
|
143
|
+
http.delete("/accounts/#{account_id}/wallets/#{id_or_address}/tokens/#{token_id}")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Re-enable an existing token holding.
|
|
147
|
+
# @return [Hash] the token holding
|
|
148
|
+
def enable_token(account_id, id_or_address, token_id)
|
|
149
|
+
http.patch("/accounts/#{account_id}/wallets/#{id_or_address}/tokens/#{token_id}/enable")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Disable an existing token holding without forgetting it — the holding (and
|
|
153
|
+
# its default flag) survives, so re-enabling restores the previous setup.
|
|
154
|
+
# @return [Hash] the token holding
|
|
155
|
+
def disable_token(account_id, id_or_address, token_id)
|
|
156
|
+
http.patch("/accounts/#{account_id}/wallets/#{id_or_address}/tokens/#{token_id}/disable")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|