rail0-sdk 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da765ab6a541e6af453972d5bf10ffc052daeac4c909bbb3ee6fb1e47ebee6fd
4
- data.tar.gz: c17f6dce9b41d781e907141e77dbfbad5bc3392900c4003083cc3a0b3bb4ffb6
3
+ metadata.gz: 4f10297affa2fe993f9f4777ba2c6994610de8a6e2abe54d19b790cabfb09681
4
+ data.tar.gz: 75ea2a7fdcc4b18cd8b4d62c689bb9a2b0bcb2030793e24a12224ee205275490
5
5
  SHA512:
6
- metadata.gz: dd490a0832f5814d3620d0d233e5d65bfa5790b9c2987e71b363fbcdf3462bb1b2d81bcb15321a2a7f72c2acf1064694c1b4ba036f35639613b54eb2ab68c787
7
- data.tar.gz: e2aaf5625eb07f543d9b0429c2dbcff2333f723ebe2d44cf1b5bdb624f7df5876baff2cb437528914b18ef1270b20700df2a5cc5fb99348709875837a2587839
6
+ metadata.gz: 211b69279ade20a322f29061a5723ce477e42d6ff50f9f2b2b192569117ffcbc25f35ba32dbe2c56c88ac04501669856cc3f9613ad12a8a2433ba76cf15e4a62
7
+ data.tar.gz: 1e856d8c347d5793915bdbbceec990cc0bcc12f9e85977e293bc268fee9c4985d19cf114559199a9c88f58850b5e6f83e4e94d5d7e82f69f38036592d284be1e
data/README.md CHANGED
@@ -15,7 +15,8 @@ lifecycle plus account, wallet, catalog, and webhook management. It mirrors the
15
15
  - Ruby ≥ 3.0
16
16
  - For SIWE login and off-chain signing: `eth` (`~> 0.5`) and `siwe-rb` (`~> 0.2`)
17
17
 
18
- The core HTTP client has **no runtime dependencies** (Ruby stdlib only). The `eth`
18
+ The core HTTP client needs only the Ruby stdlib plus the `logger` gem (a declared
19
+ dependency, since it is no longer a default gem from Ruby 4.0). The `eth`
19
20
  and `siwe-rb` gems are loaded lazily — `require "rail0"` works without them, and
20
21
  they are needed only when you call `client.auth.login` or `Rail0::Signing`.
21
22
 
@@ -167,7 +168,10 @@ path segments and does not fit the generic shape.
167
168
 
168
169
  ```ruby
169
170
  auth = client.auth.login(private_key: "0x…", domain: "api.rail0.xyz")
170
- # => { token:, address:, account_id:, name:, expires_at: }
171
+ # => { token:, address:, account_id:, name:, expires_at:, admin: }
172
+ # admin is always a boolean (verify too): true only for an account holding the
173
+ # operator grant, false otherwise (visibility only). account_id/name are nil for an
174
+ # account-less session.
171
175
  # login embeds chain_id 1 by default; pass chain_id: to match a gateway whose
172
176
  # SIWE_CHAIN_ID policy differs (e.g. login(private_key:, domain:, chain_id: 5042002)).
173
177
 
@@ -191,9 +195,9 @@ Lower-level building blocks are also available:
191
195
 
192
196
  ```ruby
193
197
  nonce = client.auth.nonce # POST /auth/nonces
194
- session = client.auth.verify(message: siwe_msg, signature: sig) # POST /auth
198
+ session = client.auth.verify(message: siwe_msg, signature: sig) # POST /auth — same hash as login
195
199
  client.auth.logout # POST /auth/logout
196
- client.auth.revoke_all # POST /auth/revoke_all
200
+ client.auth.revoke_all(private_key: "0x...", domain: "api.rail0.xyz") # POST /auth/revoke_all
197
201
  ```
198
202
 
199
203
  `logout` revokes **the token this client carries**, not every session for the address —
@@ -207,14 +211,21 @@ live sessions would need five tokens you do not have. This is per **address** an
207
211
  the ones you never saw — including any an attacker is holding — which makes it the call
208
212
  for a key you no longer trust. The gateway records a cutoff **instant** rather than
209
213
  enumerating tokens, so a session minted a moment before the call is refused by its own
210
- `iat`; that is what makes it durable where a denylist is not. The returned `cutoff` is
211
- the field worth logging: it says exactly which sessions died, which `revoked: true`
212
- cannot.
214
+ `iat`; that is what makes it durable where a denylist is not.
215
+
216
+ It is authorized by a **fresh SIWE proof** of the address, not by the session — whoever
217
+ is reacting to a leaked key holds the wallet, not the stolen token — so it takes the
218
+ private key and domain (and optional `chain_id:`, default 1) exactly like `login`, and
219
+ needs the same optional `eth`/`siwe-rb` gems. The proof carries its own statement
220
+ (`"Sign out of RAIL0 everywhere"`), so a login signature cannot be replayed here. It is
221
+ self-revoking: any token you hold dies too, so sign in again afterwards. It returns
222
+ `{ revoked_all: true, cutoff_at: "<ISO-8601>" }`; `cutoff_at` is the field worth
223
+ logging — it says exactly which sessions died, which `revoked_all: true` cannot.
213
224
 
214
225
  ## Catalog (public)
215
226
 
216
227
  ```ruby
217
- client.chains.list # GET /blockchains
228
+ client.chains.list # GET /blockchains (each chain's :contract is the active RAIL0 deployment)
218
229
  client.chains.list(network_type: "testnet", symbol: "ETH")
219
230
  client.tokens.list # GET /tokens
220
231
  client.tokens.list(chain_id: 84532, symbol: "USDC")
@@ -305,14 +316,15 @@ buyers, so it is the last step of merchant onboarding:
305
316
 
306
317
  ```ruby
307
318
  holding = client.wallets.add_token(account_id, id_or_address, chain_id: 84532, token: "0x…", default: true)
308
- client.wallets.enable_token(account_id, id_or_address, holding[:id])
309
- client.wallets.disable_token(account_id, id_or_address, holding[:id]) # keeps the holding
310
- client.wallets.remove_token(account_id, id_or_address, holding[:id]) # 204, soft delete
319
+ client.wallets.enable_token(account_id, id_or_address, holding[:token_id])
320
+ client.wallets.disable_token(account_id, id_or_address, holding[:token_id]) # keeps the holding
321
+ client.wallets.remove_token(account_id, id_or_address, holding[:token_id]) # 204, soft delete
311
322
  ```
312
323
 
313
324
  `add_token` is idempotent by (wallet, chain, token): re-adding an existing holding
314
325
  re-enables it and answers 200 instead of creating a second row. The id passed to
315
- the other three is the **holding's** id, not the token address.
326
+ the other three is the **token's** UUID (`token_id` on the holding), not an id of the
327
+ holding row and not the token address — the gateway looks the holding up by (wallet, token).
316
328
 
317
329
  ## Payments
318
330
 
@@ -365,8 +377,9 @@ client.payments.refund(rail0_id, { signed_transaction: raw })
365
377
 
366
378
  ### Disputes (payer-driven)
367
379
 
368
- Disputes are authorized on-chain by the payer (no JWT) and follow the same
369
- prepare → submit pattern:
380
+ Disputes are signed on-chain by the payer and follow the same prepare → submit
381
+ pattern. Like the rest of `/payments` they need a session, but the payer's
382
+ account-less SIWE login is enough:
370
383
 
371
384
  ```ruby
372
385
  prep = client.payments.dispute_prepare(rail0_id, reason: "0x…") # reason optional
@@ -709,6 +722,8 @@ lib/rail0/
709
722
  default_logger.rb Rail0::LogEntry + Rail0::DefaultLogger (Logger subclass) for `logger:`
710
723
  api_error.rb Rail0::ApiError (code/title/detail + #hint)
711
724
  error_hints.rb Rail0.describe_error — per-code next steps, shared with the other SDKs
725
+ backoff.rb Rail0::Backoff — retry delays and 429 jitter
726
+ webhook_signature.rb Rail0::WebhookSignature — delivery verification
712
727
  signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
713
728
  stablecoins.rb stablecoin address registry
714
729
  types.rb generated Struct docs of the gateway schema (reference only)
@@ -721,6 +736,8 @@ lib/rail0/
721
736
  payment_methods.rb public payment-method discovery
722
737
  wallets.rb account-scoped wallet management (JWT)
723
738
  payments.rb payment lifecycle + disputes
739
+ disputes.rb account-level dispute listing (JWT)
740
+ accounts.rb the caller's own account profile (JWT)
724
741
  webhooks.rb webhook subscription management (JWT)
725
742
  analytics.rb account-scoped payment analytics (JWT)
726
743
  query.rb shared query-string helper
@@ -31,6 +31,13 @@ module Rail0
31
31
  # account. Never collapse the two into a single constant.
32
32
  WALLET_LINK_STATEMENT = "Add this wallet to your RAIL0 account"
33
33
 
34
+ # The SIWE statement for revoking every session of an address —
35
+ # POST /auth/revoke_all. The gateway asserts it exactly
36
+ # (Policy::SIWE_REVOKE_ALL_STATEMENT) for the reason the other two are
37
+ # separate: a proof is spendable at exactly one endpoint, so a captured login
38
+ # signature cannot be replayed to sign its address out everywhere.
39
+ REVOKE_ALL_STATEMENT = "Sign out of RAIL0 everywhere"
40
+
34
41
  attr_reader :http
35
42
 
36
43
  def initialize(http)
@@ -47,9 +54,15 @@ module Rail0
47
54
  # Submit a pre-built SIWE message and its signature, returning a JWT.
48
55
  # @param message [String] EIP-4361 formatted message string.
49
56
  # @param signature [String] 0x-prefixed hex signature.
50
- # @return [Hash] { token:, address:, account_id:, name:, expires_at: }
57
+ # @return [Hash] { token:, address:, account_id:, name:, expires_at:, admin: }
58
+ #
59
+ # +admin+ is true only for an account holding the operator (administrators)
60
+ # grant: the gateway adds the field on an admin's session alone, and it is
61
+ # normalised here to a boolean so a standard login reads false rather than nil.
62
+ # Visibility only — every gated route re-checks the grant per request.
51
63
  def verify(message:, signature:)
52
- http.post("/auth", { message: message, signature: signature })
64
+ session = http.post("/auth", { message: message, signature: signature })
65
+ session.merge(admin: session[:admin] == true)
53
66
  end
54
67
 
55
68
  # End the session whose token this client carries.
@@ -70,23 +83,43 @@ module Rail0
70
83
  http.post("/auth/logout", {})
71
84
  end
72
85
 
73
- # End EVERY session of the calling address (POST /auth/revoke_all).
86
+ # End EVERY session of the address controlled by +private_key+
87
+ # (POST /auth/revoke_all).
74
88
  #
75
89
  # The answer to a key you no longer trust, and {#logout} cannot be that answer: it
76
90
  # is per TOKEN, so an address with five live sessions needs five tokens the caller
77
91
  # does not have. This is per ADDRESS and reaches the ones it never saw — including
78
92
  # any an attacker is holding.
79
93
  #
94
+ # Authorized by a FRESH SIWE PROOF of the address, not by the session: someone
95
+ # reacting to a leaked key holds the wallet, not the stolen token, so requiring the
96
+ # session would make this useless exactly when it is needed. The proof is the same
97
+ # handshake as {#login} — fetch a single-use nonce, build an EIP-4361 message, sign
98
+ # it with EIP-191 personal_sign — but carries {REVOKE_ALL_STATEMENT}, so a login
99
+ # proof cannot be replayed here (the gateway answers 422 siwe_purpose_mismatch).
100
+ # This method used to post an empty body, which the gateway refused on every call,
101
+ # and read `revoked`/`cutoff`, which the gateway never sends.
102
+ #
80
103
  # The gateway records a cutoff INSTANT rather than enumerating tokens, so a session
81
- # minted a moment before the call is refused by its own `iat`. That is what makes it
82
- # durable where a denylist is not: there is nothing to enumerate and nothing to miss.
104
+ # minted a moment before the call is refused by its own `iat`. It is self-revoking:
105
+ # the cutoff is just after now, so any token this caller holds dies with the rest —
106
+ # sign in again afterwards. (The client does not store the JWT, so there is nothing
107
+ # local to clear; drop your own copy.)
83
108
  #
84
- # `cutoff` is the field worth logging. It says exactly which sessions died, which
85
- # `revoked: true` cannot.
109
+ # `cutoff_at` is the field worth logging. It says exactly which sessions died, which
110
+ # `revoked_all: true` cannot.
86
111
  #
87
- # @return [Hash] { revoked: true|false, cutoff: "2026-08-27T21:00:00Z" }
88
- def revoke_all
89
- http.post("/auth/revoke_all", {})
112
+ # Requires the optional 'eth' and 'siwe-rb' gems.
113
+ #
114
+ # @param private_key [String] 0x-prefixed hex private key of the address to sign out.
115
+ # @param domain [String] Host of the API server (e.g. "api.rail0.xyz").
116
+ # @param chain_id [Integer] Chain ID to embed; same meaning and default as #login.
117
+ # @return [Hash] { revoked_all: true, cutoff_at: "2026-09-24T18:00:01Z" } — cutoff_at
118
+ # is an ISO-8601 instant; every token of the address issued before it is refused.
119
+ def revoke_all(private_key:, domain:, chain_id: 1)
120
+ message, signature = sign_proof(private_key, domain, chain_id, REVOKE_ALL_STATEMENT)
121
+ result = http.post("/auth/revoke_all", { message: message, signature: signature })
122
+ { revoked_all: result[:revoked_all], cutoff_at: result[:cutoff_at] }
90
123
  end
91
124
 
92
125
  # Perform the full SIWE authentication flow:
@@ -102,7 +135,7 @@ module Rail0
102
135
  # @param chain_id [Integer] Chain ID to embed in the SIWE message. Must match
103
136
  # the gateway's SIWE_CHAIN_ID policy (default 1); override only when the
104
137
  # gateway is configured with a different login chain.
105
- # @return [Hash] { token:, address:, account_id:, name:, expires_at: }
138
+ # @return [Hash] { token:, address:, account_id:, name:, expires_at:, admin: }
106
139
  def login(private_key:, domain:, chain_id: 1)
107
140
  message, signature = sign_proof(private_key, domain, chain_id, LOGIN_STATEMENT)
108
141
  verify(message: message, signature: signature)
@@ -135,9 +168,9 @@ module Rail0
135
168
 
136
169
  private
137
170
 
138
- # The shared core of both handshakes. Everything but the STATEMENT is
139
- # identical, which is precisely why the statement is a parameter and never a
140
- # default — see the note on the two constants.
171
+ # The shared core of every SIWE handshake (login, prove_address, revoke_all).
172
+ # Everything but the STATEMENT is identical, which is precisely why the
173
+ # statement is a parameter and never a default — see the note on the constants.
141
174
  def sign_proof(private_key, domain, chain_id, statement)
142
175
  ensure_signing_deps!
143
176
 
@@ -135,8 +135,10 @@ module Rail0
135
135
 
136
136
  # Stop accepting a token on this wallet (soft delete). Returns HTTP 204.
137
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.
138
+ # `token_id` is the TOKEN's UUID — the `token_id` field on a holding returned
139
+ # by {add_token} / {list}'s nested `tokens` — not an id of the holding row and
140
+ # not the token's contract address. The gateway looks the holding up by
141
+ # (wallet, token_id); the same applies to {enable_token} and {disable_token}.
140
142
  #
141
143
  # @return [nil]
142
144
  def remove_token(account_id, id_or_address, token_id)
@@ -78,13 +78,50 @@ module Rail0
78
78
  "cUSD" => StablecoinInfo.new(address: "0x765DE816845861e75A25fCA122bb6898B8B1282a", decimals: 18, eip3009: true, eip2612: false),
79
79
  "cEUR" => StablecoinInfo.new(address: "0xD8763CBa276a3738E6DE85b4b3bF5FDed6D6cA73", decimals: 18, eip3009: true, eip2612: false)
80
80
  }
81
+ ),
82
+ # ── Testnets ───────────────────────────────────────────────────────────
83
+ # Every address below was read off its own chain, and the token lists are
84
+ # the immutable acceptedTokens() allowlists of the RAIL0 deployments there
85
+ # — which is what a payment on that chain can actually use.
86
+ "arc-testnet" => ChainStablecoins.new(
87
+ chain_id: 5042002,
88
+ tokens: {
89
+ "USDC" => StablecoinInfo.new(address: "0x3600000000000000000000000000000000000000", decimals: 6, eip3009: true, eip2612: true),
90
+ "EURC" => StablecoinInfo.new(address: "0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a", decimals: 6, eip3009: true, eip2612: true)
91
+ }
92
+ ),
93
+ # Celo Sepolia is 11142220. The rail0-go registry carried 44787 here, which is
94
+ # ALFAJORES — a different, now-retired network — alongside a USDC address with
95
+ # no code on this chain. Corrected there in the same change; do not "restore" it.
96
+ "celo-sepolia" => ChainStablecoins.new(
97
+ chain_id: 11142220,
98
+ tokens: {
99
+ "USDC" => StablecoinInfo.new(address: "0x01C5C0122039549AD1493B8220cABEdD739BC44E", decimals: 6, eip3009: true, eip2612: true),
100
+ "USD₮" => StablecoinInfo.new(address: "0xd077A400968890Eacc75cdc901F0356c943e4fDb", decimals: 6, eip3009: true, eip2612: true)
101
+ }
102
+ ),
103
+ # PYUSD is the first non-Circle token served, and the reason this chain is
104
+ # served at all — it is issued nowhere else.
105
+ #
106
+ # Signing against PYUSD directly: its EIP-712 domain name is "PayPal USD", not
107
+ # the symbol, and its version is "1", which version() does not expose (it
108
+ # reverts). Through a RAIL0 payment this never bites — the domain comes down in
109
+ # the gateway's signing_payload — but building the domain by hand from the
110
+ # symbol yields a signature the token rejects.
111
+ "ethereum-sepolia" => ChainStablecoins.new(
112
+ chain_id: 11155111,
113
+ tokens: {
114
+ "USDC" => StablecoinInfo.new(address: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", decimals: 6, eip3009: true, eip2612: true),
115
+ "PYUSD" => StablecoinInfo.new(address: "0xCaC524BcA292aaade2DF8A05cC58F0a65B1B3bB9", decimals: 6, eip3009: true, eip2612: true)
116
+ }
81
117
  )
82
118
  }.freeze
83
119
 
84
120
  # Returns the registry entry for a chain, or +nil+ if the chain is unknown.
85
121
  #
86
122
  # Supported chain names: "ethereum", "base", "polygon", "arbitrumOne",
87
- # "optimism", "avalanche", "celo".
123
+ # "optimism", "avalanche", "celo", "arc-testnet", "celo-sepolia",
124
+ # "ethereum-sepolia".
88
125
  #
89
126
  # @param chain [String]
90
127
  # @return [ChainStablecoins, nil]
data/lib/rail0/types.rb CHANGED
@@ -46,36 +46,43 @@ module Rail0
46
46
  keyword_init: true
47
47
  )
48
48
 
49
- # Error envelope. Every error this API returns carries `code` (stable and machine-readable — the
50
- # only field to branch on), `title` (a short label) and `detail` (one or two sentences fit to show a
51
- # user verbatim). The wording comes from the gateway's error catalogue, so the same condition always
52
- # reads the same way wherever it surfaces. `status` and `message` are the pre-code/title/detail
53
- # names, derived from the same entry and kept for existing clients: `status` carries the family a
54
- # client used to switch on (e.g. `forbidden`, `invalid_state`) where `code` is narrower, and
55
- # `message` equals `detail`. Context fields may also be present (`resource`, `param`, `chain_id`,
56
- # `token`, `payee`, `errors`).
49
+ # The error envelope: exactly code (stable, the only field to branch on), title (short label) and
50
+ # detail (a sentence a UI can print verbatim). The legacy status/message/error aliases were removed
51
+ # in #252.
57
52
  ApiErrorBody = Struct.new(
58
- :code, # String
59
- :title, # String
60
- :detail, # String
61
- :status, # String — Legacy: the error family, or the code itself when there is no wider family.
62
- :message, # String — Legacy alias of `detail`.
53
+ :code, # String
54
+ :title, # String
55
+ :detail, # String
63
56
  keyword_init: true
64
57
  )
65
58
 
66
- # Gateway liveness/readiness. Only the database gates the HTTP code (200 healthy, 503 when the DB is
67
- # unreachable); Sidekiq is reported but never flips the code, since the API still serves synchronous
68
- # requests when workers are down. `status` is the global signal: `ok` (all good), `degraded` (DB ok
69
- # but Sidekiq not ok), `error` (DB down — the only 503).
59
+ # Gateway liveness/readiness — public and token-blind: one body for every caller, and every field is
60
+ # public by nature (the versions are on-chain / on the root descriptor; the counts are derivable
61
+ # from the public catalog reads). Only the database gates the HTTP code (200 healthy, 503 when the
62
+ # DB is unreachable); the Sidekiq worker fleet never flips the code, since the API still serves
63
+ # synchronous requests when workers are down. `status` is the global signal: `ok` (all good),
64
+ # `degraded` (DB ok but the worker fleet not ok), `error` (DB down — the only 503). The fleet
65
+ # FIGURES (queue depth, worker count, Redis reachability) are operational internals and live on the
66
+ # operator-only GET /admin/health.
70
67
  Health = Struct.new(
71
68
  :status, # String
69
+ :db, # String
70
+ :timestamp, # String
72
71
  :api_version, # String
73
72
  :contract_version, # String
74
- :db, # String
75
- :sidekiq, # Hash — Worker fleet health (does not gate liveness).
76
73
  :active_chains, # Integer
77
74
  :active_contracts, # Integer
78
- :timestamp, # String
75
+ keyword_init: true
76
+ )
77
+
78
+ # Operator diagnostics for GET /admin/health: one verdict plus a named check per standing failure
79
+ # condition, always 200 (a degraded system is the content, not an error — this is not a liveness
80
+ # probe). Every figure is computed on request from state the gateway already keeps; nothing here is
81
+ # a counter anyone increments.
82
+ AdminHealth = Struct.new(
83
+ :status, # String — The worst of the checks. Exists so "is anything wrong" can be read without knowing which checks there are, nor which will be added later.
84
+ :checks, # Hash
85
+ :timestamp, # String
79
86
  keyword_init: true
80
87
  )
81
88
 
@@ -89,7 +96,9 @@ module Rail0
89
96
  # Issued after a successful SIWE verification. SIWE alone proves control of the address, so a token
90
97
  # is issued even when the address is not registered to any account; in that case `account_id` and
91
98
  # `name` are null (an account-less session, e.g. a buyer). Clients that require an account must
92
- # treat a null `account_id` as not-allowed.
99
+ # treat a null `account_id` as not-allowed. A session whose account holds the operator grant
100
+ # additionally carries `admin: true` (the Session::Admin variant); a standard or account-less
101
+ # session has no admin/role field at all.
93
102
  Session = Struct.new(
94
103
  :token, # String — JWT bearer token.
95
104
  :address, # String — Resolved wallet address.
@@ -99,20 +108,31 @@ module Rail0
99
108
  keyword_init: true
100
109
  )
101
110
 
111
+ # The RAIL0 deployment a NEW payment on this chain is opened against — what escrows the payer's
112
+ # funds, and which version of it. Nothing here is a disclosure: the address is on-chain, every
113
+ # payment response already carries it as `rail0_contract`, and the explorer shows its verified
114
+ # source. It is published so a client does not carry its own address-per-chain table and go quietly
115
+ # stale on the next rollout. Null only for a chain with no deployment, which cannot appear in this
116
+ # listing anyway. The operator's bookkeeping (start_block, active/archived) stays on
117
+ # /admin/contracts: it describes how this gateway and its indexer are run, not what a payer is
118
+ # signing into.
119
+ ChainContract = Struct.new(
120
+ :address, # String
121
+ :version, # String — Semver of the deployed contract. Every active deployment agrees on the one the gateway targets.
122
+ :deployed_at, # String
123
+ keyword_init: true
124
+ )
125
+
102
126
  # Public blockchain view.
103
- #
104
- # required_confirmations and finality_tag together say how long a payer waits. Read
105
- # the PAIR, not the number: where a chain serves a finality tag the gateway gates on
106
- # that tag, and the count is the fallback for chains serving none — so quoting the
107
- # count on a tagged chain names a wait nobody applies.
108
127
  Blockchain = Struct.new(
109
- :chain_id, # Integer
110
- :name, # String
111
- :native_symbol, # String
112
- :network_type, # String
113
- :explorer_url, # String
114
- :required_confirmations, # Integer
115
- :finality_tag, # String, nil where the chain serves no tag
128
+ :chain_id, # Integer
129
+ :name, # String
130
+ :native_symbol, # String
131
+ :network_type, # String
132
+ :explorer_url, # String
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
+ :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
+ :contract, # ChainContract
116
136
  keyword_init: true
117
137
  )
118
138
 
@@ -150,9 +170,10 @@ module Rail0
150
170
  # A wallet's token holding as nested under its wallet (GET /accounts/:id/wallets): the token plus
151
171
  # this wallet's per-token flags, without re-nesting the wallet.
152
172
  WalletTokenHolding = Struct.new(
153
- :token, # Token
154
- :active, # Boolean
155
- :default, # Boolean
173
+ :token_id, # String — The token's UUID — the handle for PATCH/DELETE /accounts/{account_id}/wallets/{id}/tokens/{token_id}.
174
+ :token, # Token
175
+ :active, # Boolean
176
+ :default, # Boolean
156
177
  keyword_init: true
157
178
  )
158
179
 
@@ -167,28 +188,25 @@ module Rail0
167
188
  keyword_init: true
168
189
  )
169
190
 
170
- # Base persisted payment fields.
191
+ # Base persisted payment fields, plus the `chain_id` of the payment's deployment.
171
192
  Payment = Struct.new(
172
193
  :id, # String
173
194
  :contract_id, # String
174
- :chain_id, # Integer — EVM chain id of the deployment. On list rows too: `amount` is
175
- # base units, and the token's decimals resolve from `token` PLUS its chain.
195
+ :chain_id, # Integer — EVM chain id of the payment's deployment. Present on the list view too: `amount` is in base units, and the token's `decimals` can only be resolved from `token` together with its chain.
176
196
  :rail0_id, # String — Protocol-level identifier (66-char hex).
177
- :status, # String
197
+ :status, # String — Lifecycle state. Deliberately a HAPPY-PATH label, not a ledger: a partial operation does NOT move it, so a payment captured 100 and refunded 40 still reads `captured`, and a full refund that leaves uncaptured escrow does too. `capturable_amount` / `refundable_amount` are the authoritative residuals — reconcile on those, not on this. `partially_refunded` is retained for historical rows only and is no longer produced.
178
198
  :mode, # String
179
199
  :amount, # String
180
200
  :capturable_amount, # String — Mirrors on-chain capturableAmount (escrow still held); base units.
181
201
  :refundable_amount, # String — Mirrors on-chain refundableAmount (held by payee, still refundable); base units.
182
- # The window after a PARTIAL capture where neither void nor release can return the
183
- # buyer's remaining escrow — the answer to "why did both just refuse?".
184
- :escrow_stranded, # Boolean
185
- :escrow_returnable_at, # String, nil — ISO-8601 end of that window; nil outside it.
186
202
  :config_hash, # String
187
203
  :payer, # String
188
204
  :payee, # String
189
205
  :token, # String
190
206
  :authorization_expiry, # Integer
191
207
  :refund_expiry, # Integer
208
+ :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
+ :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.
192
210
  :disputed, # Boolean — True while an open dispute exists.
193
211
  :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.
194
212
  :last_error_message, # Human-readable form of last_error_code.
@@ -218,15 +236,16 @@ module Rail0
218
236
  Transaction = Struct.new(
219
237
  :id, # String
220
238
  :payment_id, # String
221
- :operation, # String
239
+ :operation, # String — Which on-chain call this row attempts. A SUPERSET of the `{operation}` path enum: `dispute` and `close_dispute` have their own hand-written routes rather than living under the generic namespace, but they mint transaction rows like any other operation, so a generated client must be able to represent them.
222
240
  :status, # String
241
+ :redrivable, # Boolean — True when this transaction's broadcast can be re-enqueued: it is `pending` and the gateway holds its signed transaction, i.e. a send that was prepared and signed but never reached the chain. The same predicate `POST /payments/{id}/transactions/{transaction_id}/redrive` guards on, so a client can offer the action exactly when it will succeed instead of on a 422. False on a `pending` row that holds no signed transaction - there the next step is submitting the signature, not a redrive.
223
242
  :error_code, # Decoded failure code, null unless `status` is "failed". Same catalogue as an error body's `code`: a RAIL0 custom error (`not_payee`), a token-level revert (`insufficient_token_balance`, `invalid_token_signature`, `authorization_already_used`), a Solidity panic, or a rejection that stopped the broadcast before the chain saw it (`insufficient_gas_funds`, `nonce_too_low`).
224
243
  :error_title, # String — Short label for `error_code`; null unless failed.
225
244
  :error_detail, # String — Sentence explaining the failure; null unless failed. Carries the chain's own words when the revert was not one the gateway recognises.
226
- :error_message, # Legacy alias of `error_detail`.
227
245
  :unsigned_transaction,
228
246
  :transaction_hash,
229
- :amount,
247
+ :sender, # The address that SIGNED the submitted transaction, recovered from the signature by the gateway at submit — a fact, not a client claim. Null where the gateway held no signature to recover from (a report-by-hash submit, where the wallet broadcast it itself) or where nothing has been submitted yet. This is what makes `release` gas attributable: that operation is payer-OR-payee, so whose cost it is depends on who signed.
248
+ :amount, # Amount in token BASE units. On capture and refund it is the amount the caller asked for. On void and release it is PROVISIONAL — those operations carry no amount and move the whole uncaptured escrow, so this is the capturable residual as of prepare, re-sealed with the exact on-chain amount when the indexer confirms.
230
249
  :block_number,
231
250
  :gas_used, # Gas units used, mirrored from the indexer on confirm.
232
251
  :gas_limit, # Gas limit, mirrored from the indexer on confirm.
@@ -241,11 +260,41 @@ module Rail0
241
260
  keyword_init: true
242
261
  )
243
262
 
263
+ # Full RAIL0 deployment row (admin surfaces only). Read alongside AdminBlockchain: a chain with no
264
+ # row here cannot take a payment, which is what keeps it out of service.
265
+ AdminContract = Struct.new(
266
+ :id, # String
267
+ :blockchain_id, # String
268
+ :address, # String
269
+ :version, # String — Semver, and every ACTIVE deployment must agree on the one the code targets.
270
+ :deployed_at, # String
271
+ :start_block, # Where the indexer starts. Null means the deployment block.
272
+ :active, # Boolean — The single deployment new payments are opened against, at most one per chain.
273
+ :archived, # Boolean — Always false here: the listing excludes archived deployments.
274
+ :archived_at,
275
+ :created_at, # String
276
+ :updated_at, # String
277
+ keyword_init: true
278
+ )
279
+
280
+ # The account's own profile as the holder reads it (GET) and as a PATCH returns it — id, name,
281
+ # email, timestamps. Deliberately no admin/role field: the operator grant lives in a separate table,
282
+ # so a standard account's profile carries no trace of that axis. An ADMIN reading any account gets
283
+ # the whole record plus `admin` instead, which is a different shape and not this one.
284
+ Account = Struct.new(
285
+ :id, # String
286
+ :name, # String
287
+ :email,
288
+ :created_at, # String
289
+ :updated_at, # String
290
+ keyword_init: true
291
+ )
292
+
244
293
  Webhook = Struct.new(
245
294
  :id, # String
246
295
  :name, # String
247
296
  :callback_url, # String
248
- :topics, # Array<WebhookTopic> — every event this subscription delivers
297
+ :topics, # Array — Every event this subscription delivers. The delivery itself names the one that fired, in X-Rail0-Topic.
249
298
  :active, # Boolean
250
299
  :circuit_state, # String
251
300
  :circuit_failure_count, # Integer
@@ -272,7 +321,7 @@ module Rail0
272
321
  # Sweeper view of a stale submitted transaction.
273
322
  SyncTransaction = Struct.new(
274
323
  :transaction_hash, # String
275
- :operation, # String
324
+ :operation, # String — Same vocabulary as `Transaction.operation` — dispute rows go stale too.
276
325
  :payment_id, # String — Protocol-level rail0_id.
277
326
  :chain_id, # Integer
278
327
  keyword_init: true
@@ -283,8 +332,7 @@ module Rail0
283
332
  :chain_id, # Integer
284
333
  :start_block, # Integer
285
334
  :required_confirmations, # Integer — Fallback burial depth, used where the chain serves no finality tag.
286
- :finality_tag, # String — Which block the chain calls settled ("safe", "finalized" or
287
- # "depth"); the indexer gates every notify on it.
335
+ :finality_tag, # String — Which block the chain calls settled; the indexer gates every notify on it.
288
336
  :explorer_url, # Block explorer base URL; null when the chain has none.
289
337
  :network_type, # String — "testnet" or "mainnet"; selects which chains a deployment indexes.
290
338
  :rpc_urls, # Array — Ordered list of public RPC endpoints tried in turn (serial fallback).
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.0.0"
8
+ VERSION = "1.1.0"
9
9
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rail0-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Commerce Layer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-21 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger