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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60c25c2843d57ee49df3b17267e56a612f56701d61e48430786b1c6435987eb9
4
- data.tar.gz: d8ef2d01d59b1a775f116753608bba6ab90d0d868a360040c0bc8fdc64f64de0
3
+ metadata.gz: 23d06a854473333eb6779af7bcec4ea5d370ab9c24106cef8a1db9809ca29803
4
+ data.tar.gz: b6a28727e25233378a5c39feceb00f7a638d44590a8f9f01f356e1e6e0d35f91
5
5
  SHA512:
6
- metadata.gz: e7e6441337f7daf05f4e55ba22d94a380755f5775fd203b0699e8f16029fc493c88e9863a81e2d1559e71c383db7b79330503cd724c2219dd0a1b169badfac81
7
- data.tar.gz: 943a28ce8740139e30e4966ce1021b9a85271789418a2274d505a6457733a09e8cc84c3c8f435ff34962dec3ed7e5beded3fc0f7cec131de626d8e5d6535fc5d
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
- Every payment row carries `chain_id`, list rows included. You need it to display
391
- an amount: `amount` is in base units and the token's decimals resolve from
392
- `token` **plus** its chain — an address alone identifies a token only within one
393
- chain — so a listing never needs a `get(id)` per row to render totals.
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 helper
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 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.
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.compact.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }
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("/accounts/#{account_id}/wallets/#{id_or_address}")
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("/accounts/#{account_id}/wallets/#{id_or_address}", body)
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("/accounts/#{account_id}/wallets/#{id_or_address}")
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("/accounts/#{account_id}/wallets/#{id_or_address}/balances#{query}")
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("/accounts/#{account_id}/wallets/#{id_or_address}/tokens", body)
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("/accounts/#{account_id}/wallets/#{id_or_address}/tokens/#{token_id}")
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("/accounts/#{account_id}/wallets/#{id_or_address}/tokens/#{token_id}/enable")
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("/accounts/#{account_id}/wallets/#{id_or_address}/tokens/#{token_id}/disable")
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.2.0"
8
+ VERSION = "1.3.1"
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rail0-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Commerce Layer