rail0-sdk 1.2.0 → 1.3.1
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 +74 -6
- data/lib/rail0/resources/accounts.rb +6 -2
- data/lib/rail0/resources/chains.rb +7 -1
- data/lib/rail0/resources/payments.rb +31 -18
- data/lib/rail0/resources/query.rb +68 -4
- data/lib/rail0/resources/wallets.rb +18 -10
- data/lib/rail0/resources/webhooks.rb +10 -9
- data/lib/rail0/types.rb +20 -1
- data/lib/rail0/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23d06a854473333eb6779af7bcec4ea5d370ab9c24106cef8a1db9809ca29803
|
|
4
|
+
data.tar.gz: b6a28727e25233378a5c39feceb00f7a638d44590a8f9f01f356e1e6e0d35f91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fdb399b1895e43ef062221da06357db402bb59fc746a36d6a21499d9ac82625723db5dc454cb8c46cb1e77e4889d05b00f8497da9d858a0e8f470e286c29f4a4
|
|
7
|
+
data.tar.gz: 7f329e33476a42b6d3304c3a1978dab10425740a460d2f6a066660ce9f67e24399c81d783372d65172b5247e670362970f82ddbf16e7fb7d5c7a5172e8fb9e8b
|
data/README.md
CHANGED
|
@@ -237,13 +237,30 @@ logging — it says exactly which sessions died, which `revoked_all: true` canno
|
|
|
237
237
|
## Catalog (public)
|
|
238
238
|
|
|
239
239
|
```ruby
|
|
240
|
-
client.chains.list # GET /blockchains (each chain's :contract is the active RAIL0 deployment)
|
|
240
|
+
client.chains.list # GET /blockchains (each chain's :contract is the active RAIL0 deployment, :settlement its measured confirm time)
|
|
241
241
|
client.chains.list(network_type: "testnet", symbol: "ETH")
|
|
242
242
|
client.tokens.list # GET /tokens — every token, retired ones included
|
|
243
243
|
client.tokens.list(chain_id: 84532, symbol: "USDC")
|
|
244
244
|
client.tokens.list(active: true) # what a NEW payment may use
|
|
245
245
|
```
|
|
246
246
|
|
|
247
|
+
Each chain's `settlement` is how long an operation on it has taken to confirm **on this
|
|
248
|
+
gateway**, end to end (the chain's finality lag plus the gateway/indexer pipeline):
|
|
249
|
+
`{ p50_seconds:, p90_seconds:, sample_size:, window_days: }`, percentiles of broadcast →
|
|
250
|
+
confirmation over the trailing `window_days`, in whole seconds rounded up. It is always
|
|
251
|
+
present, but the percentiles are `nil` below the gateway's minimum sample (20), so size a
|
|
252
|
+
polling deadline from it with a fixed fallback:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
FALLBACK = 15 * 60 # seconds
|
|
256
|
+
chain = client.chains.list.find { |c| c[:chain_id] == 84532 }
|
|
257
|
+
p90 = chain.dig(:settlement, :p90_seconds)
|
|
258
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (p90 ? [3 * p90, FALLBACK].max : FALLBACK)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
It is a trailing-window measurement (a capacity figure, cached by the gateway for 10
|
|
262
|
+
minutes), not a promise, so treat it as a hint and never as a timeout that fails a payment.
|
|
263
|
+
|
|
247
264
|
`tokens.list` without `active:` is the **historical** catalogue — a payment references its
|
|
248
265
|
token forever, so a retired one must still resolve. A checkout picker should pass
|
|
249
266
|
`active: true`: `payments.create` answers 422 `unknown_token` for a retired token.
|
|
@@ -362,8 +379,10 @@ client.payments.create(params, idempotency_key: nil) # or keyword fields
|
|
|
362
379
|
# "idempotency_key_reused" (422) instead of returning the first payment.
|
|
363
380
|
client.payments.get(id)
|
|
364
381
|
client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at")
|
|
382
|
+
client.payments.list(status: %w[authorized expired]) # any of several states → ?status=authorized,expired
|
|
365
383
|
client.payments.list(operation: "refund") # payments with at least one refund transaction
|
|
366
384
|
client.payments.transactions(id, operation: "capture")
|
|
385
|
+
client.payments.transactions(id, status: %w[submitting submitted]) # on its way to the chain
|
|
367
386
|
# `operation` on both takes Payments::TRANSACTION_OPERATIONS — dispute and close_dispute included.
|
|
368
387
|
client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast
|
|
369
388
|
# Offer `redrive` on the row's `redrivable` flag — the same predicate the gateway guards
|
|
@@ -387,10 +406,29 @@ client.disputes.list(status: "closed", sort: "-opened_at")
|
|
|
387
406
|
`payments.list`/`transactions`/`disputes` and `disputes.list` return a paginated
|
|
388
407
|
`{ data:, meta: { page:, per_page:, total: } }` envelope.
|
|
389
408
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
`
|
|
393
|
-
|
|
409
|
+
`status:` on `payments.list` and `payments.transactions` takes one value or an Array; an
|
|
410
|
+
Array is sent comma-separated and matches **any** of the listed states. An empty Array is
|
|
411
|
+
dropped like `nil` (no filter). An unknown value raises `Rail0::ApiError` 400
|
|
412
|
+
`validation_failed`, naming it. Any query filter given an Array is sent the same way.
|
|
413
|
+
|
|
414
|
+
Every payment row carries `chain_id` and `decimals`, list rows included (and the payment
|
|
415
|
+
embedded in `disputes.list`). Amounts (`amount`, `capturable_amount`, `refundable_amount`)
|
|
416
|
+
are in base units: divide by `10**decimals` to render them, with no `tokens.list` join and no
|
|
417
|
+
`get(id)` per row. `decimals` is `nil` only when the gateway cannot resolve the token (a
|
|
418
|
+
contract it has not loaded, a token deleted outright). Fall back to `tokens.list` then,
|
|
419
|
+
matching on `token` **plus** `chain_id`, since an address alone identifies a token only
|
|
420
|
+
within one chain.
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
BigDecimal(row[:amount]) / (10**row[:decimals]) # "100000000", 6 → 100.0
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Every payment row also carries `in_flight`: `true` while one of its transactions is on its
|
|
427
|
+
way to the chain, meaning `submitting`, `submitted`, or `pending` holding its signed
|
|
428
|
+
transaction (the `redrivable` rows). A `pending` row still waiting for its signature does not
|
|
429
|
+
count, nor do `confirmed`/`failed` rows. While it is true the balances are about to move, so
|
|
430
|
+
disable further actions on the payment. It comes on list rows too, which embed no
|
|
431
|
+
transactions, so a bulk action can skip busy rows without a `get(id)` each.
|
|
394
432
|
|
|
395
433
|
### Refund (two-phase EIP-3009)
|
|
396
434
|
|
|
@@ -462,6 +500,25 @@ client.webhooks.redeliver(id, callback_id) # 202 { status: "queued" } — repla
|
|
|
462
500
|
client.webhooks.delete(id) # 204
|
|
463
501
|
```
|
|
464
502
|
|
|
503
|
+
Every delivery's `payment` object carries `capturable_amount` (base units) and
|
|
504
|
+
`authorization_expiry` (epoch seconds), whatever the topic. That is what makes
|
|
505
|
+
`payments.authorization_expiring` actionable. The gateway sends it **once per payment**, a
|
|
506
|
+
configurable notice (24h by default) before `authorization_expiry`, for a payment that can
|
|
507
|
+
still be captured (`authorized`, `partially_captured`, `partially_refunded`) and still holds
|
|
508
|
+
escrow. It is the last chance to capture; `payments.expired` says the chance has gone. Its
|
|
509
|
+
`transaction` is `null`, and the time left is `authorization_expiry` minus the event's
|
|
510
|
+
`emitted_at` (ISO-8601). There is no separate field for it because it would be stale on
|
|
511
|
+
every retry:
|
|
512
|
+
|
|
513
|
+
```ruby
|
|
514
|
+
event = JSON.parse(raw, symbolize_names: true)
|
|
515
|
+
if event[:topic] == "payments.authorization_expiring"
|
|
516
|
+
payment = event[:payment]
|
|
517
|
+
secs_left = payment[:authorization_expiry] - Time.iso8601(event[:emitted_at]).to_i
|
|
518
|
+
# capture payment[:capturable_amount] (base units) within secs_left, or let it expire
|
|
519
|
+
end
|
|
520
|
+
```
|
|
521
|
+
|
|
465
522
|
`redeliver` replays a recorded delivery's exact payload — the same event id, so a receiver
|
|
466
523
|
that already processed it deduplicates — under a fresh signature. It is the way back for
|
|
467
524
|
events lost while the circuit breaker was open. Delivery is async and the dispatcher drops
|
|
@@ -713,6 +770,17 @@ RAIL0 itself:
|
|
|
713
770
|
A failed transaction carries the same triple as `error_code`, `error_title` and
|
|
714
771
|
`error_detail`, whether it reverted on-chain or was refused before broadcast.
|
|
715
772
|
|
|
773
|
+
### Ids in request paths
|
|
774
|
+
|
|
775
|
+
Every id (and operation name) a method interpolates into a request path is
|
|
776
|
+
percent-encoded as ONE path segment, so a value carrying `/`, `?`, `#`, a space or
|
|
777
|
+
non-ASCII characters can never address a different route than the method names
|
|
778
|
+
(`payments.get("../webhooks/x")` requests `/payments/..%2Fwebhooks%2Fx`, not
|
|
779
|
+
`/webhooks/x`). The ids the gateway hands out — UUIDs, `0x` hex — are unchanged by it.
|
|
780
|
+
A value that is `nil`, empty, `"."` or `".."` is never a valid id, and would still
|
|
781
|
+
re-target the request once encoded, so the method raises `ArgumentError` before
|
|
782
|
+
sending anything.
|
|
783
|
+
|
|
716
784
|
## Configuration
|
|
717
785
|
|
|
718
786
|
```ruby
|
|
@@ -797,7 +865,7 @@ lib/rail0/
|
|
|
797
865
|
accounts.rb the caller's own account profile, read and update (JWT)
|
|
798
866
|
webhooks.rb webhook subscription management (JWT)
|
|
799
867
|
analytics.rb account-scoped payment analytics (JWT)
|
|
800
|
-
query.rb shared query-string
|
|
868
|
+
query.rb shared query-string and path-segment helpers
|
|
801
869
|
```
|
|
802
870
|
|
|
803
871
|
## Development
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "query"
|
|
4
|
+
|
|
3
5
|
module Rail0
|
|
4
6
|
module Resources
|
|
5
7
|
# The merchant account itself (requires JWT).
|
|
@@ -17,6 +19,8 @@ module Rail0
|
|
|
17
19
|
# {Resources::Wallets}; buyer-facing discovery of what a merchant accepts lives on
|
|
18
20
|
# {Resources::PaymentMethods}.
|
|
19
21
|
class Accounts
|
|
22
|
+
include Query
|
|
23
|
+
|
|
20
24
|
attr_reader :http
|
|
21
25
|
|
|
22
26
|
def initialize(http)
|
|
@@ -29,7 +33,7 @@ module Rail0
|
|
|
29
33
|
# @return [Hash] `id`, `name`, `email`, `created_at`, `updated_at`. `email` is part of
|
|
30
34
|
# the response because only the account's owner, or an operator, can read it.
|
|
31
35
|
def get(account_id)
|
|
32
|
-
http.get("/accounts/#{account_id}")
|
|
36
|
+
http.get("/accounts/#{segment(account_id)}")
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
# Update the account's own profile (PATCH /accounts/:account_id).
|
|
@@ -55,7 +59,7 @@ module Rail0
|
|
|
55
59
|
body[:email] = email unless email.nil?
|
|
56
60
|
raise ArgumentError, "at least one of name or email is required" if body.empty?
|
|
57
61
|
|
|
58
|
-
http.patch("/accounts/#{account_id}", body)
|
|
62
|
+
http.patch("/accounts/#{segment(account_id)}", body)
|
|
59
63
|
end
|
|
60
64
|
end
|
|
61
65
|
end
|
|
@@ -22,7 +22,13 @@ module Rail0
|
|
|
22
22
|
# required_confirmations, finality_tag (the settlement rule: the tag where the
|
|
23
23
|
# chain serves one, the count only where it does not), and contract — the chain's
|
|
24
24
|
# active RAIL0 deployment as `{ address:, version:, deployed_at: }` (what holds
|
|
25
|
-
# the money; nullable in principle, present in practice)
|
|
25
|
+
# the money; nullable in principle, present in practice), and settlement — how long
|
|
26
|
+
# an operation on the chain has taken to confirm ON THIS GATEWAY, as
|
|
27
|
+
# `{ p50_seconds:, p90_seconds:, sample_size:, window_days: }`: broadcast-to-confirmation
|
|
28
|
+
# percentiles (whole seconds, rounded up) over the trailing `window_days`. Always
|
|
29
|
+
# present; the percentiles are nil below the gateway's minimum sample (20), so check
|
|
30
|
+
# `sample_size` and keep a fixed fallback. A wait-deadline hint (e.g. 3 × p90_seconds,
|
|
31
|
+
# floored at your fallback), not a guarantee.
|
|
26
32
|
def list(network_type: nil, symbol: nil)
|
|
27
33
|
http.get("/blockchains#{build_query(network_type: network_type, symbol: symbol)}")
|
|
28
34
|
end
|
|
@@ -32,7 +32,9 @@ module Rail0
|
|
|
32
32
|
|
|
33
33
|
# List payments for the authenticated wallet (requires JWT). Returns
|
|
34
34
|
# payments where the caller is the payer or payee.
|
|
35
|
-
# @param status [String, nil] Filter by payment status
|
|
35
|
+
# @param status [String, Array<String>, nil] Filter by payment status: one state, or
|
|
36
|
+
# an Array matching ANY of them (sent comma-separated, `status=authorized,expired`).
|
|
37
|
+
# An unknown value is 400 `validation_failed` naming it.
|
|
36
38
|
# @param mode [String, nil] Filter by mode ("authorize" or "charge").
|
|
37
39
|
# @param payer [String, nil] Filter by payer address.
|
|
38
40
|
# @param payee [String, nil] Filter by payee address.
|
|
@@ -52,7 +54,12 @@ module Rail0
|
|
|
52
54
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
53
55
|
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
54
56
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
55
|
-
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
57
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }. Each row
|
|
58
|
+
# carries, besides the payment fields, `chain_id`, `decimals` (Integer, or nil when
|
|
59
|
+
# the gateway can't resolve the token — fall back to {Tokens#list} then) to render the
|
|
60
|
+
# base-unit amounts without a token lookup, and `in_flight` (Boolean: a transaction is
|
|
61
|
+
# `submitting`, `submitted`, or `pending` holding its signed transaction — disable
|
|
62
|
+
# further actions on the row while true).
|
|
56
63
|
def list(status: nil, mode: nil, payer: nil, payee: nil, token: nil, rail0_id: nil,
|
|
57
64
|
chain_id: nil, disputed: nil, operation: nil, min_amount: nil, max_amount: nil,
|
|
58
65
|
created_from: nil, created_to: nil, sort: nil, page: nil, per_page: nil)
|
|
@@ -88,23 +95,26 @@ module Rail0
|
|
|
88
95
|
|
|
89
96
|
# Fetch current payment state (DB status + live on-chain amounts + transactions).
|
|
90
97
|
# @param id [String] Payment UUID or rail0_id.
|
|
91
|
-
# @return [Hash]
|
|
98
|
+
# @return [Hash] The payment with its embedded transactions, including `decimals`
|
|
99
|
+
# (Integer or nil — divide `amount`, `capturable_amount` and `refundable_amount` by
|
|
100
|
+
# 10**decimals to render them) and `in_flight` (Boolean — see {list}).
|
|
92
101
|
def get(id)
|
|
93
|
-
http.get("/payments/#{id}")
|
|
102
|
+
http.get("/payments/#{segment(id)}")
|
|
94
103
|
end
|
|
95
104
|
|
|
96
105
|
# List on-chain transactions for a payment.
|
|
97
106
|
# @param id [String] Payment UUID or rail0_id.
|
|
98
107
|
# @param operation [String, nil] Filter by operation (see {TRANSACTION_OPERATIONS} —
|
|
99
108
|
# dispute and close_dispute included).
|
|
100
|
-
# @param status [String, nil] Filter by transaction status
|
|
109
|
+
# @param status [String, Array<String>, nil] Filter by transaction status: one, or an
|
|
110
|
+
# Array matching any of them (`%w[submitting submitted]` = on its way to the chain).
|
|
101
111
|
# @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
|
|
102
112
|
# @param page [Integer, nil] Page number (1-based; 1..1,000,000, 400 outside).
|
|
103
113
|
# @param per_page [Integer, nil] Items per page (max 100).
|
|
104
114
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
105
115
|
def transactions(id, operation: nil, status: nil, sort: nil, page: nil, per_page: nil)
|
|
106
116
|
query = build_query(operation: operation, status: status, sort: sort, page: page, per_page: per_page)
|
|
107
|
-
http.get_list("/payments/#{id}/transactions#{query}")
|
|
117
|
+
http.get_list("/payments/#{segment(id)}/transactions#{query}")
|
|
108
118
|
end
|
|
109
119
|
|
|
110
120
|
# Fetch ONE of a payment's transactions
|
|
@@ -120,7 +130,7 @@ module Rail0
|
|
|
120
130
|
# @param transaction_id [String] Transaction UUID.
|
|
121
131
|
# @return [Hash]
|
|
122
132
|
def get_transaction(id, transaction_id)
|
|
123
|
-
http.get("/payments/#{id}/transactions/#{transaction_id}")
|
|
133
|
+
http.get("/payments/#{segment(id)}/transactions/#{segment(transaction_id)}")
|
|
124
134
|
end
|
|
125
135
|
|
|
126
136
|
# Re-enqueue a stuck broadcast
|
|
@@ -143,7 +153,7 @@ module Rail0
|
|
|
143
153
|
# @param transaction_id [String] The transaction row to redrive.
|
|
144
154
|
# @return [Hash] The transaction, re-enqueued.
|
|
145
155
|
def redrive(id, transaction_id)
|
|
146
|
-
http.post("/payments/#{id}/transactions/#{transaction_id}/redrive", {})
|
|
156
|
+
http.post("/payments/#{segment(id)}/transactions/#{segment(transaction_id)}/redrive", {})
|
|
147
157
|
end
|
|
148
158
|
|
|
149
159
|
# Submit the payer's EIP-712 signature (PUT /payments/{id}/sign).
|
|
@@ -151,7 +161,7 @@ module Rail0
|
|
|
151
161
|
# @param params [Hash] { signature: "0x…" } (65-byte 0x-prefixed hex).
|
|
152
162
|
# @return [Hash]
|
|
153
163
|
def sign(id, params)
|
|
154
|
-
http.put("/payments/#{id}/sign", params)
|
|
164
|
+
http.put("/payments/#{segment(id)}/sign", params)
|
|
155
165
|
end
|
|
156
166
|
|
|
157
167
|
# List a payment's dispute open/close history.
|
|
@@ -163,7 +173,7 @@ module Rail0
|
|
|
163
173
|
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
164
174
|
def disputes(id, status: nil, sort: nil, page: nil, per_page: nil)
|
|
165
175
|
query = build_query(status: status, sort: sort, page: page, per_page: per_page)
|
|
166
|
-
http.get_list("/payments/#{id}/disputes#{query}")
|
|
176
|
+
http.get_list("/payments/#{segment(id)}/disputes#{query}")
|
|
167
177
|
end
|
|
168
178
|
|
|
169
179
|
# Build the unsigned transaction for an operation
|
|
@@ -183,7 +193,7 @@ module Rail0
|
|
|
183
193
|
# with different terms is refused 422 +idempotency_key_reused+. Scoped to this
|
|
184
194
|
# payment. (rail0-gateway#331)
|
|
185
195
|
def prepare(id, operation, body = nil, idempotency_key: nil)
|
|
186
|
-
http.post("/payments/#{id}/#{operation}/prepare", body,
|
|
196
|
+
http.post("/payments/#{segment(id)}/#{segment(operation)}/prepare", body,
|
|
187
197
|
headers: idempotency_headers(idempotency_key))
|
|
188
198
|
end
|
|
189
199
|
|
|
@@ -193,7 +203,7 @@ module Rail0
|
|
|
193
203
|
# @param params [Hash] { signed_transaction: "0x…" }.
|
|
194
204
|
# @return [Hash]
|
|
195
205
|
def submit(id, operation, params)
|
|
196
|
-
http.post("/payments/#{id}/#{operation}", params)
|
|
206
|
+
http.post("/payments/#{segment(id)}/#{segment(operation)}", params)
|
|
197
207
|
end
|
|
198
208
|
|
|
199
209
|
# Record a transaction the caller broadcast themselves (MetaMask/wallet flow)
|
|
@@ -203,7 +213,7 @@ module Rail0
|
|
|
203
213
|
# @param params [Hash] { transaction_hash: "0x…" }.
|
|
204
214
|
# @return [Hash]
|
|
205
215
|
def submit_by_hash(id, operation, params)
|
|
206
|
-
http.post("/payments/#{id}/#{operation}/submitted", params)
|
|
216
|
+
http.post("/payments/#{segment(id)}/#{segment(operation)}/submitted", params)
|
|
207
217
|
end
|
|
208
218
|
|
|
209
219
|
# Phase 1 — build the unsigned authorize() transaction (escrow hold).
|
|
@@ -292,7 +302,7 @@ module Rail0
|
|
|
292
302
|
# @param params [Hash] { signed_transaction: "0x…" }.
|
|
293
303
|
# @return [Hash]
|
|
294
304
|
def dispute(id, params)
|
|
295
|
-
http.post("/payments/#{id}/dispute", params)
|
|
305
|
+
http.post("/payments/#{segment(id)}/dispute", params)
|
|
296
306
|
end
|
|
297
307
|
|
|
298
308
|
# Phase 1 — build the unsigned closeDispute() transaction (payer only).
|
|
@@ -308,7 +318,7 @@ module Rail0
|
|
|
308
318
|
# @param params [Hash] { signed_transaction: "0x…" }.
|
|
309
319
|
# @return [Hash]
|
|
310
320
|
def close_dispute(id, params)
|
|
311
|
-
http.post("/payments/#{id}/dispute/close", params)
|
|
321
|
+
http.post("/payments/#{segment(id)}/dispute/close", params)
|
|
312
322
|
end
|
|
313
323
|
|
|
314
324
|
# The payer's counterpart to {#submit_by_hash}, which covers only the operations
|
|
@@ -326,7 +336,7 @@ module Rail0
|
|
|
326
336
|
# @param params [Hash] { transaction_hash: "0x…" }.
|
|
327
337
|
# @return [Hash]
|
|
328
338
|
def dispute_submit_by_hash(id, params)
|
|
329
|
-
http.post("/payments/#{id}/dispute/submitted", params)
|
|
339
|
+
http.post("/payments/#{segment(id)}/dispute/submitted", params)
|
|
330
340
|
end
|
|
331
341
|
|
|
332
342
|
# Report an already-broadcast close-dispute transaction by hash (payer only); HTTP 202.
|
|
@@ -334,14 +344,17 @@ module Rail0
|
|
|
334
344
|
# @param params [Hash] { transaction_hash: "0x…" }.
|
|
335
345
|
# @return [Hash]
|
|
336
346
|
def close_dispute_submit_by_hash(id, params)
|
|
337
|
-
http.post("/payments/#{id}/dispute/close/submitted", params)
|
|
347
|
+
http.post("/payments/#{segment(id)}/dispute/close/submitted", params)
|
|
338
348
|
end
|
|
339
349
|
|
|
340
350
|
private
|
|
341
351
|
|
|
352
|
+
# +path+ is one of this class's own literals ("dispute/prepare",
|
|
353
|
+
# "dispute/close/prepare"), never caller input, so it is interpolated as-is:
|
|
354
|
+
# its `/` is meant to separate segments. Only +id+ goes through {#segment}.
|
|
342
355
|
def prepare_dispute(path, id, reason, idempotency_key: nil)
|
|
343
356
|
body = reason ? { reason: reason } : {}
|
|
344
|
-
http.post("/payments/#{id}/#{path}", body,
|
|
357
|
+
http.post("/payments/#{segment(id)}/#{path}", body,
|
|
345
358
|
headers: idempotency_headers(idempotency_key))
|
|
346
359
|
end
|
|
347
360
|
|
|
@@ -1,19 +1,83 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "cgi"
|
|
4
|
+
require "erb"
|
|
4
5
|
|
|
5
6
|
module Rail0
|
|
6
7
|
module Resources
|
|
7
|
-
# Shared
|
|
8
|
-
#
|
|
9
|
-
# are
|
|
8
|
+
# Shared URL builders for resource classes. Included so every resource turns
|
|
9
|
+
# keyword filters into a URL query the same way (nil values are dropped,
|
|
10
|
+
# booleans/integers are stringified) and escapes every caller-supplied path
|
|
11
|
+
# segment the same way ({#segment}).
|
|
10
12
|
module Query
|
|
13
|
+
# Segments that would not stay put once interpolated: "." and ".." are dot
|
|
14
|
+
# segments that a URL normaliser (a proxy, a CDN, the server's router) may
|
|
15
|
+
# resolve away, and an empty one collapses the path onto its parent.
|
|
16
|
+
UNSAFE_SEGMENTS = ["", ".", ".."].freeze
|
|
17
|
+
private_constant :UNSAFE_SEGMENTS
|
|
18
|
+
|
|
11
19
|
private
|
|
12
20
|
|
|
21
|
+
# Escapes ONE caller-supplied value for interpolation into a request path:
|
|
22
|
+
#
|
|
23
|
+
# http.get("/payments/#{segment(id)}/transactions/#{segment(transaction_id)}")
|
|
24
|
+
#
|
|
25
|
+
# Paths used to be plain interpolation, so an id was pasted in raw: one
|
|
26
|
+
# containing `/`, `?`, `#` or `..` addressed a different route than the method
|
|
27
|
+
# named — `payments.get("../webhooks/x")` was a GET on /webhooks/x with the
|
|
28
|
+
# caller's token. The ids this SDK is handed normally come from the gateway, but
|
|
29
|
+
# a caller that forwards one from a URL (a proxy route, a CLI argument) passes
|
|
30
|
+
# along whatever it was given.
|
|
31
|
+
#
|
|
32
|
+
# ERB::Util.url_encode percent-encodes everything outside the RFC 3986
|
|
33
|
+
# unreserved set (A-Z a-z 0-9 - . _ ~), UTF-8 byte by byte, with a space as
|
|
34
|
+
# %20 — the path-correct form (CGI.escape, used for queries, would turn a
|
|
35
|
+
# space into `+`, which in a path is a literal plus). For the values legitimately
|
|
36
|
+
# passed (UUIDs, 0x hex, operation names, integer ids) it changes nothing. This
|
|
37
|
+
# matches rail0-ts's `path` tag, which uses encodeURIComponent.
|
|
38
|
+
#
|
|
39
|
+
# A value that is exactly "", "." or ".." raises ArgumentError instead: encoding
|
|
40
|
+
# leaves the dots as they are (they are unreserved), so the segment would still
|
|
41
|
+
# be a dot segment, and an empty one silently re-targets the parent route
|
|
42
|
+
# (`payments.get("")` would list payments). None of them is a valid id, so
|
|
43
|
+
# failing before the request is the only honest answer.
|
|
44
|
+
#
|
|
45
|
+
# @param value [#to_s] The id (or other caller-supplied value) to interpolate.
|
|
46
|
+
# @return [String] The percent-encoded segment.
|
|
47
|
+
# @raise [ArgumentError] If the value is nil, empty, "." or "..".
|
|
48
|
+
def segment(value)
|
|
49
|
+
str = value.to_s
|
|
50
|
+
if value.nil? || UNSAFE_SEGMENTS.include?(str)
|
|
51
|
+
raise ArgumentError, "invalid path segment #{value.inspect}: an id must not be nil, empty, \".\" or \"..\""
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ERB::Util.url_encode(str)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Builds "?k=v&…" from keyword filters, or "" when every value is nil.
|
|
58
|
+
#
|
|
59
|
+
# An Array value becomes ONE comma-separated pair (`status: %w[a b]` →
|
|
60
|
+
# `status=a,b`): the gateway's multi-value filters (e.g. `status` on
|
|
61
|
+
# GET /payments, #367) take the comma form. Each element is escaped on its
|
|
62
|
+
# own and the separating comma is left literal, so a comma can never leak
|
|
63
|
+
# in from an element. nil elements are dropped; an Array left empty drops
|
|
64
|
+
# the whole pair, the same as nil, rather than sending a blank `status=`
|
|
65
|
+
# the gateway would reject with 400.
|
|
13
66
|
def build_query(**params)
|
|
14
|
-
pairs = params.
|
|
67
|
+
pairs = params.filter_map do |k, v|
|
|
68
|
+
value = query_value(v)
|
|
69
|
+
"#{k}=#{value}" unless value.nil?
|
|
70
|
+
end
|
|
15
71
|
pairs.empty? ? "" : "?#{pairs.join('&')}"
|
|
16
72
|
end
|
|
73
|
+
|
|
74
|
+
def query_value(value)
|
|
75
|
+
return nil if value.nil?
|
|
76
|
+
return CGI.escape(value.to_s) unless value.is_a?(Array)
|
|
77
|
+
|
|
78
|
+
items = value.compact
|
|
79
|
+
items.map { |item| CGI.escape(item.to_s) }.join(",") unless items.empty?
|
|
80
|
+
end
|
|
17
81
|
end
|
|
18
82
|
end
|
|
19
83
|
end
|
|
@@ -38,7 +38,7 @@ module Rail0
|
|
|
38
38
|
query = build_query(chain_id: chain_id, token_symbol: token_symbol, active: active,
|
|
39
39
|
default: default, token_active: token_active,
|
|
40
40
|
sort: sort, page: page, per_page: per_page)
|
|
41
|
-
http.get_list("/accounts/#{account_id}/wallets#{query}")
|
|
41
|
+
http.get_list("/accounts/#{segment(account_id)}/wallets#{query}")
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Fetch a single wallet by its id or 0x address.
|
|
@@ -46,7 +46,7 @@ module Rail0
|
|
|
46
46
|
# @param id_or_address [String] Wallet UUID or 0x address.
|
|
47
47
|
# @return [Hash] id, address, label, active
|
|
48
48
|
def get(account_id, id_or_address)
|
|
49
|
-
http.get(
|
|
49
|
+
http.get(wallet_path(account_id, id_or_address))
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
# Add a wallet to the account.
|
|
@@ -74,7 +74,7 @@ module Rail0
|
|
|
74
74
|
def create(account_id, address:, message:, signature:, label: nil)
|
|
75
75
|
body = { address: address, message: message, signature: signature }
|
|
76
76
|
body[:label] = label unless label.nil?
|
|
77
|
-
http.post("/accounts/#{account_id}/wallets", body)
|
|
77
|
+
http.post("/accounts/#{segment(account_id)}/wallets", body)
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# Update a wallet's label and/or active status.
|
|
@@ -87,7 +87,7 @@ module Rail0
|
|
|
87
87
|
body = {}
|
|
88
88
|
body[:label] = label unless label.nil?
|
|
89
89
|
body[:active] = active unless active.nil?
|
|
90
|
-
http.patch(
|
|
90
|
+
http.patch(wallet_path(account_id, id_or_address), body)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
# Soft-delete (deactivate) a wallet. Returns HTTP 204.
|
|
@@ -95,7 +95,7 @@ module Rail0
|
|
|
95
95
|
# @param id_or_address [String] Wallet UUID or 0x address.
|
|
96
96
|
# @return [nil]
|
|
97
97
|
def delete(account_id, id_or_address)
|
|
98
|
-
http.delete(
|
|
98
|
+
http.delete(wallet_path(account_id, id_or_address))
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
# Read a wallet's live on-chain balances across the configured chains. Each
|
|
@@ -109,7 +109,7 @@ module Rail0
|
|
|
109
109
|
# @return [Hash] wallet_id, address, balances
|
|
110
110
|
def balances(account_id, id_or_address, chain_id: nil, token_symbol: nil)
|
|
111
111
|
query = build_query(chain_id: chain_id, token_symbol: token_symbol)
|
|
112
|
-
http.get("
|
|
112
|
+
http.get("#{wallet_path(account_id, id_or_address)}/balances#{query}")
|
|
113
113
|
end
|
|
114
114
|
|
|
115
115
|
# ── Token holdings ────────────────────────────────────────────────────────
|
|
@@ -133,7 +133,7 @@ module Rail0
|
|
|
133
133
|
def add_token(account_id, id_or_address, chain_id:, token:, default: nil)
|
|
134
134
|
body = { chain_id: chain_id, token: token }
|
|
135
135
|
body[:default] = default unless default.nil?
|
|
136
|
-
http.post("
|
|
136
|
+
http.post("#{wallet_path(account_id, id_or_address)}/tokens", body)
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
# Stop accepting a token on this wallet (soft delete). Returns HTTP 204.
|
|
@@ -150,13 +150,13 @@ module Rail0
|
|
|
150
150
|
#
|
|
151
151
|
# @return [nil]
|
|
152
152
|
def remove_token(account_id, id_or_address, token_id)
|
|
153
|
-
http.delete("
|
|
153
|
+
http.delete("#{wallet_path(account_id, id_or_address)}/tokens/#{segment(token_id)}")
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
# Re-enable an existing token holding.
|
|
157
157
|
# @return [Hash] the token holding
|
|
158
158
|
def enable_token(account_id, id_or_address, token_id)
|
|
159
|
-
http.patch("
|
|
159
|
+
http.patch("#{wallet_path(account_id, id_or_address)}/tokens/#{segment(token_id)}/enable")
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
# Disable an existing token holding without forgetting it — the holding (and
|
|
@@ -166,7 +166,15 @@ module Rail0
|
|
|
166
166
|
# active default holding — promote another token to default first.
|
|
167
167
|
# @return [Hash] the token holding
|
|
168
168
|
def disable_token(account_id, id_or_address, token_id)
|
|
169
|
-
http.patch("
|
|
169
|
+
http.patch("#{wallet_path(account_id, id_or_address)}/tokens/#{segment(token_id)}/disable")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
# "/accounts/{account_id}/wallets/{id_or_address}" with both ids escaped by
|
|
175
|
+
# {Query#segment} — the prefix every single-wallet route shares.
|
|
176
|
+
def wallet_path(account_id, id_or_address)
|
|
177
|
+
"/accounts/#{segment(account_id)}/wallets/#{segment(id_or_address)}"
|
|
170
178
|
end
|
|
171
179
|
end
|
|
172
180
|
end
|
|
@@ -25,6 +25,7 @@ module Rail0
|
|
|
25
25
|
payments.voided
|
|
26
26
|
payments.released
|
|
27
27
|
payments.refunded
|
|
28
|
+
payments.authorization_expiring
|
|
28
29
|
payments.expired
|
|
29
30
|
payments.failed
|
|
30
31
|
payments.disputed
|
|
@@ -70,7 +71,7 @@ module Rail0
|
|
|
70
71
|
# @param id [String] Webhook UUID.
|
|
71
72
|
# @return [Hash]
|
|
72
73
|
def get(id)
|
|
73
|
-
http.get("/webhooks/#{id}")
|
|
74
|
+
http.get("/webhooks/#{segment(id)}")
|
|
74
75
|
end
|
|
75
76
|
|
|
76
77
|
# Update a webhook's name, callback_url, and/or topics.
|
|
@@ -86,35 +87,35 @@ module Rail0
|
|
|
86
87
|
body[:name] = name unless name.nil?
|
|
87
88
|
body[:callback_url] = callback_url unless callback_url.nil?
|
|
88
89
|
body[:topics] = Array(topics) unless topics.nil?
|
|
89
|
-
http.patch("/webhooks/#{id}", body)
|
|
90
|
+
http.patch("/webhooks/#{segment(id)}", body)
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
# Re-enable a disabled webhook.
|
|
93
94
|
# @param id [String] Webhook UUID.
|
|
94
95
|
# @return [Hash]
|
|
95
96
|
def enable(id)
|
|
96
|
-
http.put("/webhooks/#{id}/enable")
|
|
97
|
+
http.put("/webhooks/#{segment(id)}/enable")
|
|
97
98
|
end
|
|
98
99
|
|
|
99
100
|
# Disable a webhook (stops deliveries without deleting it).
|
|
100
101
|
# @param id [String] Webhook UUID.
|
|
101
102
|
# @return [Hash]
|
|
102
103
|
def disable(id)
|
|
103
|
-
http.put("/webhooks/#{id}/disable")
|
|
104
|
+
http.put("/webhooks/#{segment(id)}/disable")
|
|
104
105
|
end
|
|
105
106
|
|
|
106
107
|
# Generate a new shared secret, returned once on the response.
|
|
107
108
|
# @param id [String] Webhook UUID.
|
|
108
109
|
# @return [Hash] webhook record including the new shared_secret
|
|
109
110
|
def rotate_secret(id)
|
|
110
|
-
http.put("/webhooks/#{id}/rotate_secret")
|
|
111
|
+
http.put("/webhooks/#{segment(id)}/rotate_secret")
|
|
111
112
|
end
|
|
112
113
|
|
|
113
114
|
# Reset the delivery circuit breaker and re-enable the webhook.
|
|
114
115
|
# @param id [String] Webhook UUID.
|
|
115
116
|
# @return [Hash]
|
|
116
117
|
def reset_circuit(id)
|
|
117
|
-
http.put("/webhooks/#{id}/reset_circuit")
|
|
118
|
+
http.put("/webhooks/#{segment(id)}/reset_circuit")
|
|
118
119
|
end
|
|
119
120
|
|
|
120
121
|
# List delivery attempts for a webhook.
|
|
@@ -137,7 +138,7 @@ module Rail0
|
|
|
137
138
|
query = build_query(status: status, topic: topic, payment_id: payment_id,
|
|
138
139
|
response_code: response_code, since: since, until: until_time,
|
|
139
140
|
sort: sort, page: page, per_page: per_page)
|
|
140
|
-
http.get_list("/webhooks/#{id}/event_callbacks#{query}")
|
|
141
|
+
http.get_list("/webhooks/#{segment(id)}/event_callbacks#{query}")
|
|
141
142
|
end
|
|
142
143
|
|
|
143
144
|
# Re-deliver one recorded delivery's exact payload
|
|
@@ -160,14 +161,14 @@ module Rail0
|
|
|
160
161
|
# @param callback_id [String] The event callback's UUID (from {event_callbacks}).
|
|
161
162
|
# @return [Hash] `{ status: "queued" }` (HTTP 202).
|
|
162
163
|
def redeliver(id, callback_id)
|
|
163
|
-
http.post("/webhooks/#{id}/event_callbacks/#{callback_id}/redeliver", {})
|
|
164
|
+
http.post("/webhooks/#{segment(id)}/event_callbacks/#{segment(callback_id)}/redeliver", {})
|
|
164
165
|
end
|
|
165
166
|
|
|
166
167
|
# Delete a webhook. Returns HTTP 204.
|
|
167
168
|
# @param id [String] Webhook UUID.
|
|
168
169
|
# @return [nil]
|
|
169
170
|
def delete(id)
|
|
170
|
-
http.delete("/webhooks/#{id}")
|
|
171
|
+
http.delete("/webhooks/#{segment(id)}")
|
|
171
172
|
end
|
|
172
173
|
end
|
|
173
174
|
end
|
data/lib/rail0/types.rb
CHANGED
|
@@ -133,6 +133,22 @@ module Rail0
|
|
|
133
133
|
:required_confirmations, # Integer — Confirmations this gateway waits for before treating a transaction as settled. The FALLBACK rule: where the chain serves a finality tag (see finality_tag) that tag governs instead, so a client showing this number on such a chain is describing a rule that is not in force.
|
|
134
134
|
:finality_tag, # String — The block tag the chain calls settled (`safe`, `finalized`), when it serves one — and what the gateway actually gates on. Null where the chain serves none, in which case required_confirmations is counted.
|
|
135
135
|
:contract, # ChainContract
|
|
136
|
+
:settlement, # BlockchainSettlement
|
|
137
|
+
keyword_init: true
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# How long an operation on this chain has taken to settle ON THIS GATEWAY: percentiles of broadcast
|
|
141
|
+
# (`submitted_at`) to confirmation (`confirmed_at`) over the confirmed transactions of the trailing
|
|
142
|
+
# `window_days`. End-to-end — the chain's finality lag plus the gateway/indexer pipeline — so it is
|
|
143
|
+
# what a client actually waits for, not the chain's finality. A measurement, not a guarantee: use it
|
|
144
|
+
# as a wait-deadline hint (e.g. a small multiple of `p90_seconds`) and keep a fixed fallback for
|
|
145
|
+
# when it is null. Deliberately a long-window capacity figure, not a live alarm (#289). Always
|
|
146
|
+
# present; the percentiles are null when `sample_size` is below the gateway's minimum (20).
|
|
147
|
+
BlockchainSettlement = Struct.new(
|
|
148
|
+
:p50_seconds, # Integer — Median settlement time, whole seconds rounded up. Null below the minimum sample.
|
|
149
|
+
:p90_seconds, # Integer — 90th-percentile settlement time, whole seconds rounded up — the figure to size a deadline from. Null below the minimum sample.
|
|
150
|
+
:sample_size, # Integer — Confirmed transactions measured in the window (0 when none).
|
|
151
|
+
:window_days, # Integer — The trailing window the figures cover, in days.
|
|
136
152
|
keyword_init: true
|
|
137
153
|
)
|
|
138
154
|
|
|
@@ -188,7 +204,8 @@ module Rail0
|
|
|
188
204
|
keyword_init: true
|
|
189
205
|
)
|
|
190
206
|
|
|
191
|
-
# Base persisted payment fields, plus the `chain_id` of the payment's deployment
|
|
207
|
+
# Base persisted payment fields, plus the `chain_id` of the payment's deployment, its token's
|
|
208
|
+
# `decimals`, and whether a transaction is `in_flight`.
|
|
192
209
|
Payment = Struct.new(
|
|
193
210
|
:id, # String
|
|
194
211
|
:contract_id, # String
|
|
@@ -203,11 +220,13 @@ module Rail0
|
|
|
203
220
|
:payer, # String
|
|
204
221
|
:payee, # String
|
|
205
222
|
:token, # String
|
|
223
|
+
:decimals, # Decimals of the payment's token: divide `amount`, `capturable_amount` and `refundable_amount` (base units) by 10^decimals to render them, with no GET /tokens join on `token` + `chain_id`. Resolved from the gateway's in-memory chain catalogue, retired tokens and archived contract versions included. Null only when the gateway cannot resolve the token (a contract it has not loaded, a token deleted outright) — fall back to GET /tokens then.
|
|
206
224
|
:authorization_expiry, # Integer
|
|
207
225
|
:refund_expiry, # Integer
|
|
208
226
|
:escrow_stranded, # Boolean — True exactly inside the stranded-escrow window (#233): a partial capture has permanently ruled void out, and release only opens at authorization_expiry — so no verb can return the buyer's uncaptured escrow until then. Mirrors RAIL0.sol; the gateway names the window, it cannot shorten it.
|
|
209
227
|
:escrow_returnable_at, # String — When the stranded escrow becomes returnable (release opens) — the authorization expiry as ISO-8601. Null whenever nothing is stranded, so presence alone is the signal.
|
|
210
228
|
:disputed, # Boolean — True while an open dispute exists.
|
|
229
|
+
:in_flight, # Boolean — True while one of the payment's transactions is on its way to the chain and not yet settled: status `submitting`, `submitted`, or `pending` holding its signed transaction (the `redrivable` rows — signed and handed over, broadcast not yet sent). A `pending` row still awaiting its signature (an unfinished prepare) does not count, nor do `confirmed`/`failed`. While true the mirrored balances are about to move, so a client should disable further actions on the payment. Carried by list rows too, which embed no transactions; computed for a whole page in one query.
|
|
211
230
|
:last_error_code, # Decoded reason of the last failed on-chain attempt; null once the payment makes forward progress. Non-null means the latest attempt failed.
|
|
212
231
|
:last_error_message, # Human-readable form of last_error_code.
|
|
213
232
|
:description,
|
data/lib/rail0/version.rb
CHANGED
|
@@ -5,5 +5,5 @@ module Rail0
|
|
|
5
5
|
# consumed by path from a sibling checkout, where the number bought nothing: no registry
|
|
6
6
|
# ever served a 1.x, no tag was ever cut, and the one consumer resolves the working tree.
|
|
7
7
|
# Publishing from 1.2.0 would have implied two releases nobody can install.
|
|
8
|
-
VERSION = "1.
|
|
8
|
+
VERSION = "1.3.1"
|
|
9
9
|
end
|