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.
@@ -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