rail0-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +753 -0
- data/lib/rail0/api_error.rb +50 -0
- data/lib/rail0/backoff.rb +66 -0
- data/lib/rail0/client.rb +89 -0
- data/lib/rail0/default_logger.rb +82 -0
- data/lib/rail0/error_hints.rb +80 -0
- data/lib/rail0/http_client.rb +99 -0
- data/lib/rail0/request.rb +211 -0
- data/lib/rail0/resources/accounts.rb +35 -0
- data/lib/rail0/resources/analytics.rb +112 -0
- data/lib/rail0/resources/auth.rb +192 -0
- data/lib/rail0/resources/chains.rb +29 -0
- data/lib/rail0/resources/disputes.rb +35 -0
- data/lib/rail0/resources/health.rb +23 -0
- data/lib/rail0/resources/payment_methods.rb +37 -0
- data/lib/rail0/resources/payments.rb +344 -0
- data/lib/rail0/resources/query.rb +19 -0
- data/lib/rail0/resources/tokens.rb +28 -0
- data/lib/rail0/resources/wallets.rb +160 -0
- data/lib/rail0/resources/webhooks.rb +146 -0
- data/lib/rail0/signing.rb +370 -0
- data/lib/rail0/stablecoins.rb +123 -0
- data/lib/rail0/types.rb +295 -0
- data/lib/rail0/version.rb +9 -0
- data/lib/rail0/webhook_signature.rb +80 -0
- data/lib/rail0-sdk.rb +11 -0
- data/lib/rail0.rb +20 -0
- metadata +91 -0
data/README.md
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
# rail0-ruby
|
|
2
|
+
|
|
3
|
+
Ruby SDK for the [RAIL0](https://github.com/commercelayer/rail0) stablecoin payment gateway.
|
|
4
|
+
|
|
5
|
+
RAIL0 is an immutable smart contract that brings the authorize → capture → refund
|
|
6
|
+
lifecycle of card networks to stablecoin (USDC / EIP-3009) payments — no
|
|
7
|
+
intermediaries, no protocol fees, no permission required. This SDK is a REST client
|
|
8
|
+
for the RAIL0 gateway that sits in front of the contract, covering the full payment
|
|
9
|
+
lifecycle plus account, wallet, catalog, and webhook management. It mirrors the
|
|
10
|
+
[rail0-go](https://github.com/commercelayer/rail0-go) and
|
|
11
|
+
[rail0-ts](https://github.com/commercelayer/rail0-ts) SDKs.
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Ruby ≥ 3.0
|
|
16
|
+
- For SIWE login and off-chain signing: `eth` (`~> 0.5`) and `siwe-rb` (`~> 0.2`)
|
|
17
|
+
|
|
18
|
+
The core HTTP client has **no runtime dependencies** (Ruby stdlib only). The `eth`
|
|
19
|
+
and `siwe-rb` gems are loaded lazily — `require "rail0"` works without them, and
|
|
20
|
+
they are needed only when you call `client.auth.login` or `Rail0::Signing`.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gem install rail0-sdk
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or, in a Gemfile:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
gem "rail0-sdk"
|
|
32
|
+
|
|
33
|
+
# Only if you use SIWE login or off-chain signing:
|
|
34
|
+
gem "eth", "~> 0.5"
|
|
35
|
+
gem "siwe-rb", "~> 0.2"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The gem is **`rail0-sdk`**; the require path is **`rail0`**:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require "rail0" # canonical
|
|
42
|
+
require "rail0-sdk" # also works, for whoever reaches for the installed name
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
RubyGems has one global namespace and no scopes, so the gem is the SDK's name rather than
|
|
46
|
+
the protocol's — while `Rail0` stays the module every caller already uses.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
A full authorize → capture flow. Every on-chain operation is two-phase: a
|
|
51
|
+
`*_prepare` call returns an unsigned transaction, which you sign locally with
|
|
52
|
+
`Rail0::Signing.sign_transaction`, then the matching submit call broadcasts it.
|
|
53
|
+
|
|
54
|
+
**The whole `/payments` surface is authenticated**, and `create` requires the
|
|
55
|
+
caller to be the payer — so sign in first (see
|
|
56
|
+
[Authentication](#authentication-siwe)).
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
require "rail0"
|
|
60
|
+
require "rail0/signing"
|
|
61
|
+
|
|
62
|
+
GATEWAY = "https://api.rail0.xyz"
|
|
63
|
+
|
|
64
|
+
# 0. Sign in as the payer — POST /payments requires payer == caller.
|
|
65
|
+
auth = Rail0::Client.new(base_url: GATEWAY).auth.login(private_key: BUYER_PRIVATE_KEY, domain: "api.rail0.xyz")
|
|
66
|
+
client = Rail0::Client.new(base_url: GATEWAY, headers: { "Authorization" => "Bearer #{auth[:token]}" })
|
|
67
|
+
|
|
68
|
+
# 1. Payer creates the payment — response embeds the EIP-3009 signing payload.
|
|
69
|
+
payment = client.payments.create(
|
|
70
|
+
chain_id: 84532,
|
|
71
|
+
mode: "authorize",
|
|
72
|
+
amount: "50.00", # human decimals, NOT base units
|
|
73
|
+
token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
74
|
+
payer: "0xBuyer…",
|
|
75
|
+
payee: "0xMerchant…"
|
|
76
|
+
)
|
|
77
|
+
rail0_id = payment[:rail0_id]
|
|
78
|
+
|
|
79
|
+
# 2. Payer signs the EIP-3009 payload off-chain and deposits the signature.
|
|
80
|
+
sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
|
|
81
|
+
client.payments.sign(rail0_id, { signature: sig.to_hex })
|
|
82
|
+
|
|
83
|
+
# 3. Payee prepares + broadcasts the on-chain authorize tx (signs it locally).
|
|
84
|
+
prep = client.payments.authorize_prepare(rail0_id)
|
|
85
|
+
raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
|
|
86
|
+
client.payments.authorize(rail0_id, { signed_transaction: raw }) # HTTP 202 (async)
|
|
87
|
+
|
|
88
|
+
# 4. Poll until the authorization confirms.
|
|
89
|
+
loop do
|
|
90
|
+
state = client.payments.get(rail0_id)
|
|
91
|
+
break if state[:status] == "authorized"
|
|
92
|
+
sleep 2
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 5. Payee captures the escrowed funds (partial capture is supported).
|
|
96
|
+
cap = client.payments.capture_prepare(rail0_id, "50.00")
|
|
97
|
+
raw = Rail0::Signing.sign_transaction(cap[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
|
|
98
|
+
client.payments.capture(rail0_id, { signed_transaction: raw })
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
All methods return a `Hash` with symbol keys (or raise `Rail0::ApiError`). A
|
|
102
|
+
payment id argument accepts either the payment UUID or its `rail0_id` (bytes32).
|
|
103
|
+
|
|
104
|
+
## Payment lifecycle
|
|
105
|
+
|
|
106
|
+
Each on-chain operation is a `prepare` → `submit` pair:
|
|
107
|
+
|
|
108
|
+
1. **prepare** — `client.payments.<op>_prepare(...)` returns a transaction whose
|
|
109
|
+
`unsigned_transaction` is the EIP-1559 field-set to sign.
|
|
110
|
+
2. **sign** — `Rail0::Signing.sign_transaction(unsigned, private_key)` returns the
|
|
111
|
+
signed raw tx.
|
|
112
|
+
3. **submit** — `client.payments.<op>(id, { signed_transaction: raw })` broadcasts
|
|
113
|
+
it. The gateway acknowledges with HTTP 202 and confirms asynchronously — poll
|
|
114
|
+
`client.payments.get(id)` until the status settles.
|
|
115
|
+
|
|
116
|
+
Wallets that sign **and** broadcast in one step (e.g. MetaMask via
|
|
117
|
+
`eth_sendTransaction`) skip steps 2–3 and report the hash instead:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
client.payments.submit_by_hash(rail0_id, "capture", { transaction_hash: "0x…" })
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Operation | Caller | What it does |
|
|
124
|
+
|-----------|--------|--------------|
|
|
125
|
+
| `authorize_prepare` + `authorize` | payee | Broadcast the authorize tx; funds move to escrow |
|
|
126
|
+
| `charge_prepare` + `charge` | payee | One-shot authorize + capture; no escrow window |
|
|
127
|
+
| `capture_prepare` + `capture` | payee | Move escrowed funds to the merchant (partial supported) |
|
|
128
|
+
| `void_prepare` + `void` | payee | Cancel the hold, return funds to the payer (only before any capture) |
|
|
129
|
+
| `release_prepare` + `release` | anyone | Return uncaptured escrow to the payer |
|
|
130
|
+
| `refund_prepare` (phase 1+2) + `refund` | payee | Return captured funds to the payer via EIP-3009 |
|
|
131
|
+
| `dispute_prepare` + `dispute` | payer | Open a dispute (signal-only) |
|
|
132
|
+
| `close_dispute_prepare` + `close_dispute` | payer | Close an open dispute |
|
|
133
|
+
| `dispute_submit_by_hash` / `close_dispute_submit_by_hash` | payer | Report a dispute tx the wallet already broadcast |
|
|
134
|
+
|
|
135
|
+
**Payment statuses:** `unsigned`, `signed`, `authorized`, `charged`, `captured`,
|
|
136
|
+
`partially_captured`, `voided`, `released`, `refunded`, `expired` — plus
|
|
137
|
+
`partially_refunded`, which is no longer produced (a partial refund deliberately leaves
|
|
138
|
+
the status alone) but is still a legal value on historical rows, so don't write an
|
|
139
|
+
exhaustive `case` that raises on it.
|
|
140
|
+
|
|
141
|
+
`expired` is a never-captured authorization whose window lapsed. It is **not** terminal:
|
|
142
|
+
the escrow is still on-chain and `release` still works from it (closing the payment as
|
|
143
|
+
`released`), so treating it as closed leaves the buyer's funds where they are.
|
|
144
|
+
**Transaction statuses:** `pending`, `submitting`, `submitted`, `confirmed`, `failed`.
|
|
145
|
+
|
|
146
|
+
## Authentication (SIWE)
|
|
147
|
+
|
|
148
|
+
Sign-In With Ethereum gates **the entire `/payments` sub-tree** (create, sign,
|
|
149
|
+
every prepare/submit, reads and list) plus wallet management, webhooks, disputes
|
|
150
|
+
and analytics. The public surface is small: `chains`, `tokens`, `health` and
|
|
151
|
+
`payment_methods` (buyer-facing discovery). `login` runs the full handshake; pass
|
|
152
|
+
the returned token to the client via `headers`.
|
|
153
|
+
|
|
154
|
+
Two role rules are worth knowing before the first call, because both surface as a
|
|
155
|
+
403 rather than a validation error:
|
|
156
|
+
|
|
157
|
+
- `payments.create` requires the caller to **be the payer** (`payer_must_be_caller`);
|
|
158
|
+
- the merchant operations (authorize, capture, charge, void, refund) are
|
|
159
|
+
**payee-only**, while `release` and the prepare steps accept either participant,
|
|
160
|
+
and `dispute`/`close_dispute` submits are **payer-only**.
|
|
161
|
+
|
|
162
|
+
A wallet that signs *and broadcasts* in one step (MetaMask) reports the result by hash
|
|
163
|
+
instead of handing over a signed transaction: `submit_by_hash` covers the merchant
|
|
164
|
+
operations, and the two dispute paths have their own payer-only methods
|
|
165
|
+
(`dispute_submit_by_hash`, `close_dispute_submit_by_hash`) because `dispute/close` is two
|
|
166
|
+
path segments and does not fit the generic shape.
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
auth = client.auth.login(private_key: "0x…", domain: "api.rail0.xyz")
|
|
170
|
+
# => { token:, address:, account_id:, name:, expires_at: }
|
|
171
|
+
# login embeds chain_id 1 by default; pass chain_id: to match a gateway whose
|
|
172
|
+
# SIWE_CHAIN_ID policy differs (e.g. login(private_key:, domain:, chain_id: 5042002)).
|
|
173
|
+
|
|
174
|
+
authed = Rail0::Client.new(
|
|
175
|
+
base_url: "https://api.rail0.xyz",
|
|
176
|
+
headers: { "Authorization" => "Bearer #{auth[:token]}" }
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
For a long-lived process, pass a **callable** instead of a fixed header so one
|
|
181
|
+
shared client survives a token refresh — it is resolved per request:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
client = Rail0::Client.new(base_url: GATEWAY, token: -> { Current.rail0_jwt })
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
A String `token:` works for the simple case, and an explicit `Authorization` in
|
|
188
|
+
`headers` still takes precedence.
|
|
189
|
+
|
|
190
|
+
Lower-level building blocks are also available:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
nonce = client.auth.nonce # POST /auth/nonces
|
|
194
|
+
session = client.auth.verify(message: siwe_msg, signature: sig) # POST /auth
|
|
195
|
+
client.auth.logout # POST /auth/logout
|
|
196
|
+
client.auth.revoke_all # POST /auth/revoke_all
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`logout` revokes **the token this client carries**, not every session for the address —
|
|
200
|
+
signing out one process leaves the others signed in. Read the answer: the gateway's
|
|
201
|
+
denylist **fails open** by design (a store outage must not sign out the whole platform),
|
|
202
|
+
so `{ revoked: false }` means the token is *still usable* until it expires, and the
|
|
203
|
+
caller should treat its own copy as compromised rather than assume the session is gone.
|
|
204
|
+
|
|
205
|
+
`revoke_all` is the other question, and `logout` cannot answer it: an address with five
|
|
206
|
+
live sessions would need five tokens you do not have. This is per **address** and reaches
|
|
207
|
+
the ones you never saw — including any an attacker is holding — which makes it the call
|
|
208
|
+
for a key you no longer trust. The gateway records a cutoff **instant** rather than
|
|
209
|
+
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.
|
|
213
|
+
|
|
214
|
+
## Catalog (public)
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
client.chains.list # GET /blockchains
|
|
218
|
+
client.chains.list(network_type: "testnet", symbol: "ETH")
|
|
219
|
+
client.tokens.list # GET /tokens
|
|
220
|
+
client.tokens.list(chain_id: 84532, symbol: "USDC")
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Health
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
client.health.get # GET /health → { status:, api_version:, contract_version:, db:, … }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Pagination
|
|
230
|
+
|
|
231
|
+
Paginated calls return `{ data:, meta: }`. `meta` is `{ page:, per_page:, total:, total_pages:, links: }`.
|
|
232
|
+
|
|
233
|
+
`total_pages` is **zero** for an empty collection — "no pages" is what there are, so a pager rendered off it renders none. `links` comes from the `Link` header: `:first` and `:last` are always present, `:prev` and `:next` only where they exist, and the hash is empty when the collection is. The URIs are **relative** (path + query) and resolve against the URL you requested — the gateway emits them that way so they cannot advertise the wrong scheme through a TLS-terminating proxy.
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
page = client.payments.list(per_page: 100)
|
|
237
|
+
while page[:meta][:links][:next]
|
|
238
|
+
page = client.payments.list(page: page[:meta][:page] + 1, per_page: 100)
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Idempotent prepares
|
|
243
|
+
|
|
244
|
+
Every `*_prepare` takes an optional `idempotency_key:`. Without one, a retry that arrives after the first transaction was signed and broadcast opens a **second** one — right for a genuine sequential partial capture, wrong for a retry, and only the caller can tell those apart. Replaying a key returns the first transaction; the same key with different terms is refused `422 idempotency_key_reused`.
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
client.payments.capture_prepare(rail0_id, "50.00", idempotency_key: order_id)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`get_transaction(id, transaction_id)` reads one of a payment's transactions — the lookup for an action id, so a caller holding one resolves it directly instead of listing and scanning.
|
|
251
|
+
|
|
252
|
+
## Payment methods (public discovery)
|
|
253
|
+
|
|
254
|
+
Buyer-facing discovery of a merchant's accepted wallets/tokens — no JWT. Provide
|
|
255
|
+
**exactly one** of `account_id` or `address`.
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
client.payment_methods.list(account_id: "018e…") # all the merchant's wallets
|
|
259
|
+
client.payment_methods.list(address: "0xMerchant…") # just that wallet
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Wallets (account-scoped, JWT)
|
|
263
|
+
|
|
264
|
+
Wallets live under `/accounts/{account_id}/wallets`; every method takes the
|
|
265
|
+
account id first. `id_or_address` accepts the wallet UUID or its 0x address.
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
client.wallets.list(account_id, chain_id: 84532, active: true)
|
|
269
|
+
# => { data: [ { id:, address:, label:, active:, tokens: [...] } ], meta: { page:, per_page:, total: } }
|
|
270
|
+
|
|
271
|
+
client.wallets.get(account_id, id_or_address)
|
|
272
|
+
client.wallets.update(account_id, id_or_address, label: "Renamed", active: false)
|
|
273
|
+
client.wallets.delete(account_id, id_or_address) # 204
|
|
274
|
+
client.wallets.balances(account_id, id_or_address, chain_id: 84532) # live on-chain balances
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Registering a wallet needs a SIWE proof-of-ownership of the address being
|
|
278
|
+
added**, not merely the session JWT. `auth.prove_address` runs that handshake and
|
|
279
|
+
returns the `message` + `signature` to splat into `create`. Sign with **the added
|
|
280
|
+
wallet's own key**, not the session key: the gateway rejects a signature that does
|
|
281
|
+
not recover to `address` (422), and an address already registered anywhere (409 —
|
|
282
|
+
addresses are globally unique). This is what lets one account control several
|
|
283
|
+
payee wallets.
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
proof = client.auth.prove_address(private_key: added_wallet_key, domain: "api.rail0.xyz")
|
|
287
|
+
client.wallets.create(account_id, address: "0x…", **proof, label: "Treasury")
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The proof is **purpose-bound**, and a login proof will not do — the gateway pins
|
|
291
|
+
each endpoint to one statement and refuses the other with 422
|
|
292
|
+
`siwe_purpose_mismatch`:
|
|
293
|
+
|
|
294
|
+
| Endpoint | Statement | Constant |
|
|
295
|
+
|---|---|---|
|
|
296
|
+
| `POST /auth` | `Sign in to RAIL0` | `Rail0::Resources::Auth::LOGIN_STATEMENT` |
|
|
297
|
+
| `POST /accounts/:id/wallets` | `Add this wallet to your RAIL0 account` | `Rail0::Resources::Auth::WALLET_LINK_STATEMENT` |
|
|
298
|
+
|
|
299
|
+
That is a security boundary rather than a label: a login signature is handed out
|
|
300
|
+
on every sign-in, so a wallet-link endpoint that accepted one would let anyone
|
|
301
|
+
holding a captured login proof bind that address to their **own** account.
|
|
302
|
+
|
|
303
|
+
Which tokens a wallet accepts — this is what `payment_methods` then exposes to
|
|
304
|
+
buyers, so it is the last step of merchant onboarding:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
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
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`add_token` is idempotent by (wallet, chain, token): re-adding an existing holding
|
|
314
|
+
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.
|
|
316
|
+
|
|
317
|
+
## Payments
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
client.payments.create(params, idempotency_key: nil) # or keyword fields
|
|
321
|
+
# Reusing a key for DIFFERENT terms raises Rail0::ApiError with code
|
|
322
|
+
# "idempotency_key_reused" (422) instead of returning the first payment.
|
|
323
|
+
client.payments.get(id)
|
|
324
|
+
client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at")
|
|
325
|
+
client.payments.transactions(id, operation: "capture")
|
|
326
|
+
client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast
|
|
327
|
+
# Offer `redrive` on the row's `redrivable` flag — the same predicate the gateway guards
|
|
328
|
+
# the route with — and not on `status == "pending"`: a pending row holding no signed
|
|
329
|
+
# transaction is not redrivable, and there the next step is submitting the signature, not
|
|
330
|
+
# retrying a send that never happened.
|
|
331
|
+
# Each row carries the on-chain gas data (gas_used, effective_gas_price, gas_cost) and
|
|
332
|
+
# `sender`: the address the gateway RECOVERED from the signature at submit. Null for a
|
|
333
|
+
# report-by-hash submit, where the wallet broadcast it itself and the gateway held no
|
|
334
|
+
# signature — which is why a `release` (payer OR payee) is only counted as the merchant's
|
|
335
|
+
# gas when that field names one of its wallets.
|
|
336
|
+
client.payments.sign(id, { signature: "0x…" })
|
|
337
|
+
client.payments.disputes(id, status: "open") # one payment's dispute history
|
|
338
|
+
|
|
339
|
+
# Account-level: every dispute (open AND closed) across your payments, each with
|
|
340
|
+
# the parent payment embedded. A closed dispute drops out of the payments
|
|
341
|
+
# `disputed` filter (current-state) but still appears here.
|
|
342
|
+
client.disputes.list(status: "closed", sort: "-opened_at")
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`payments.list`/`transactions`/`disputes` and `disputes.list` return a paginated
|
|
346
|
+
`{ data:, meta: { page:, per_page:, total: } }` envelope.
|
|
347
|
+
|
|
348
|
+
Every payment row carries `chain_id`, list rows included. You need it to display
|
|
349
|
+
an amount: `amount` is in base units and the token's decimals resolve from
|
|
350
|
+
`token` **plus** its chain — an address alone identifies a token only within one
|
|
351
|
+
chain — so a listing never needs a `get(id)` per row to render totals.
|
|
352
|
+
|
|
353
|
+
### Refund (two-phase EIP-3009)
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
# Phase 1 — amount only → returns a signing payload for the payee to sign.
|
|
357
|
+
p1 = client.payments.refund_prepare(rail0_id, amount: "20.00")
|
|
358
|
+
sig = Rail0::Signing.sign_payload(MERCHANT_PRIVATE_KEY, p1[:signing_payload])
|
|
359
|
+
|
|
360
|
+
# Phase 2 — amount + signature → returns the unsigned on-chain tx.
|
|
361
|
+
p2 = client.payments.refund_prepare(rail0_id, amount: "20.00", signature: sig.to_hex)
|
|
362
|
+
raw = Rail0::Signing.sign_transaction(p2[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
|
|
363
|
+
client.payments.refund(rail0_id, { signed_transaction: raw })
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Disputes (payer-driven)
|
|
367
|
+
|
|
368
|
+
Disputes are authorized on-chain by the payer (no JWT) and follow the same
|
|
369
|
+
prepare → submit pattern:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
prep = client.payments.dispute_prepare(rail0_id, reason: "0x…") # reason optional
|
|
373
|
+
raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
|
|
374
|
+
client.payments.dispute(rail0_id, { signed_transaction: raw })
|
|
375
|
+
# … later …
|
|
376
|
+
client.payments.close_dispute_prepare(rail0_id)
|
|
377
|
+
client.payments.close_dispute(rail0_id, { signed_transaction: raw })
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Generic prepare/submit
|
|
381
|
+
|
|
382
|
+
Every wrapper delegates to the generic form, useful for dynamic operations:
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
client.payments.prepare(id, "capture", { amount: "1" })
|
|
386
|
+
client.payments.submit(id, "capture", { signed_transaction: raw })
|
|
387
|
+
client.payments.submit_by_hash(id, "capture", { transaction_hash: "0x…" })
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
## Webhooks (JWT)
|
|
391
|
+
|
|
392
|
+
One subscription carries a **set** of topics (see `Rail0::Resources::Webhooks::TOPICS`):
|
|
393
|
+
one shared secret and one circuit breaker for all of them, with each delivery naming the
|
|
394
|
+
event that fired in `X-Rail0-Topic`. Two subscriptions for the same `callback_url` must not
|
|
395
|
+
overlap — the gateway answers 409 and names the topic that collided.
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
hook = client.webhooks.create(
|
|
399
|
+
name: "order-lifecycle", callback_url: "https://merchant.example/hook",
|
|
400
|
+
topics: ["payments.authorized", "payments.captured", "payments.voided", "payments.refunded"]
|
|
401
|
+
)
|
|
402
|
+
hook[:shared_secret] # shown once — verify delivery signatures with it
|
|
403
|
+
|
|
404
|
+
# `topic:` is singular here on purpose: which subscriptions deliver THIS event.
|
|
405
|
+
client.webhooks.list(topic: "payments.captured", active: true)
|
|
406
|
+
client.webhooks.get(id)
|
|
407
|
+
client.webhooks.update(id, callback_url: "https://new.example/hook")
|
|
408
|
+
# topics REPLACES the set, so this is also how a topic is removed. The secret is untouched.
|
|
409
|
+
client.webhooks.update(id, topics: ["payments.captured", "payments.refunded"])
|
|
410
|
+
client.webhooks.enable(id)
|
|
411
|
+
client.webhooks.disable(id)
|
|
412
|
+
client.webhooks.rotate_secret(id) # returns a fresh shared_secret
|
|
413
|
+
client.webhooks.reset_circuit(id)
|
|
414
|
+
client.webhooks.event_callbacks(id, status: "failed")
|
|
415
|
+
client.webhooks.delete(id) # 204
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Verifying a delivery
|
|
419
|
+
|
|
420
|
+
Every delivery carries `X-Rail0-Topic`, `X-Rail0-Timestamp` and
|
|
421
|
+
`X-Rail0-Signature` — a hex HMAC-SHA256 over `"{timestamp}.{body}"` keyed by the
|
|
422
|
+
webhook's `shared_secret`.
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
# Rack / Rails controller
|
|
426
|
+
def receive
|
|
427
|
+
raw = request.body.read
|
|
428
|
+
|
|
429
|
+
unless Rail0::WebhookSignature.verify(
|
|
430
|
+
body: raw, # the RAW body, not a re-serialised hash
|
|
431
|
+
signature: request.headers["X-Rail0-Signature"],
|
|
432
|
+
timestamp: request.headers["X-Rail0-Timestamp"],
|
|
433
|
+
secret: ENV.fetch("RAIL0_WEBHOOK_SECRET")
|
|
434
|
+
)
|
|
435
|
+
return head :unauthorized
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
handle(JSON.parse(raw, symbolize_names: true))
|
|
439
|
+
head :ok
|
|
440
|
+
end
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
The timestamp is inside the signed string on purpose, and `verify` rejects one
|
|
444
|
+
outside ±300s (`tolerance:` to change it) **even when the digest matches** — that
|
|
445
|
+
window is what bounds a replay of a captured delivery. Pass the body exactly as
|
|
446
|
+
received: re-serialising a parsed hash changes key order and whitespace, and the
|
|
447
|
+
digest with it. Comparison is constant-time.
|
|
448
|
+
|
|
449
|
+
## Account (JWT)
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
# The caller's OWN profile. The gateway guards /accounts/:id with an ownership check
|
|
453
|
+
# (a JWT whose account matches the path), so there is no way to read another
|
|
454
|
+
# merchant's account — and an id that is not an account answers 404 exactly as
|
|
455
|
+
# another account's id does, so the pair says nothing about existence.
|
|
456
|
+
client.accounts.get(account_id)
|
|
457
|
+
# => { id: "019f…", name: "Test Merchant", email: "merchant@rail0.test",
|
|
458
|
+
# created_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-20T00:00:00Z" }
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
`email` is in the response because the holder is this endpoint's only possible caller.
|
|
462
|
+
The account's wallets are a collection under the same path (`client.wallets`), and
|
|
463
|
+
buyer-facing discovery is `client.payment_methods`.
|
|
464
|
+
|
|
465
|
+
## Analytics (JWT)
|
|
466
|
+
|
|
467
|
+
Account-scoped: the numbers are the merchant's own, so a buyer's account-less token is
|
|
468
|
+
refused with `account_required`.
|
|
469
|
+
|
|
470
|
+
These methods return the parsed JSON as-is, so what follows is the contract — there is no
|
|
471
|
+
typed wrapper between your code and the gateway.
|
|
472
|
+
|
|
473
|
+
```ruby
|
|
474
|
+
kpis = client.analytics.summary(status: "captured")
|
|
475
|
+
# => { orders: 42, disputed: 1, refund_rate: 0.07, dispute_rate: 0.02,
|
|
476
|
+
# failed_rate: 0.05, by_status: { captured: 40, refunded: 2 },
|
|
477
|
+
# volume: [...], gas: [...], gas_by_status: [...], gas_by_operation: [...] }
|
|
478
|
+
|
|
479
|
+
# Volume is per (token, chain) and always present — amounts in different tokens (or the
|
|
480
|
+
# same symbol on different chains) are different units, so they are grouped rather than
|
|
481
|
+
# added. `settled`/`escrowed` say where the money IS (the on-chain residuals the indexer
|
|
482
|
+
# mirrors); `captured`/`refunded` say what HAPPENED (the confirmed transactions). All are
|
|
483
|
+
# base-unit integer strings — format with the row's `decimals`.
|
|
484
|
+
kpis[:volume].first
|
|
485
|
+
# => { chain_id: 84532, chain_name: "Base Sepolia", token: "0x…", symbol: "USDC",
|
|
486
|
+
# decimals: 6, orders: 12, gross: "12000000", settled: "9000000",
|
|
487
|
+
# escrowed: "0", captured: "12000000", refunded: "3000000" }
|
|
488
|
+
|
|
489
|
+
# Gas is per CHAIN and in that chain's NATIVE token (decimals 18, never the payment
|
|
490
|
+
# token), so it is never summed across chains: Base ETH and Polygon POL are different
|
|
491
|
+
# currencies. `wasted` is gas an on-chain revert burned — money spent for nothing — and
|
|
492
|
+
# `orders` is the denominator for the average cost of an order.
|
|
493
|
+
kpis[:gas].first
|
|
494
|
+
# => { chain_id: 84532, chain_name: "Base Sepolia", symbol: "ETH", decimals: 18,
|
|
495
|
+
# orders: 12, spent: "72000", wasted: "10000", confirmed: 14, failed: 2,
|
|
496
|
+
# confirmation_secs: 45 }
|
|
497
|
+
# confirmation_secs is the mean broadcast->confirmation for THAT chain (weighted by its
|
|
498
|
+
# confirmations), nil when none confirmed — not 0, which would read as instant.
|
|
499
|
+
|
|
500
|
+
# Why the failures failed, commonest first. failed_rate says how much fails; this says what
|
|
501
|
+
# to act on: a revert is a state problem, a rejection that never reached the chain is a
|
|
502
|
+
# wallet problem.
|
|
503
|
+
kpis[:failures]
|
|
504
|
+
# => [{ code: "insufficient_gas_funds", transactions: 2 }, { code: "nonce_too_low", … }]
|
|
505
|
+
|
|
506
|
+
# The same rows regrouped, each with the `key` naming the cut; every cut adds back up to
|
|
507
|
+
# its chain's `gas` row. `orders` is nil on the operation cut — one order spans several
|
|
508
|
+
# operations — and the status cut is a SNAPSHOT: an authorize's gas sits under
|
|
509
|
+
# "authorized" until you capture, then under "captured".
|
|
510
|
+
kpis[:gas_by_status].first # => { key: "captured", spent: "62000", orders: 2, … }
|
|
511
|
+
kpis[:gas_by_operation].first # => { key: "capture", spent: "20000", orders: nil, … }
|
|
512
|
+
|
|
513
|
+
# `failed_rate` is per resolved TRANSACTION, not per order: one order can carry several
|
|
514
|
+
# attempts, and a retried capture that eventually confirms is what it exists to surface.
|
|
515
|
+
|
|
516
|
+
client.analytics.timeseries(interval: "day", from: "2026-07-01T00:00:00Z")
|
|
517
|
+
# => [{ bucket: "2026-07-01T00:00:00Z", orders: 3, volume: nil }, …] oldest first
|
|
518
|
+
# A bucket is ONE number, so it can carry volume only when both token and chain_id are
|
|
519
|
+
# filtered; otherwise `volume` is nil. "day" (default), "week" or "month" — no hourly.
|
|
520
|
+
|
|
521
|
+
client.analytics.breakdown(by: "chain")
|
|
522
|
+
# => [{ key: "Base Sepolia", chain_id: 84532, orders: 9 }, …]
|
|
523
|
+
# by: token | chain | mode | status | operation
|
|
524
|
+
|
|
525
|
+
# "operation" is the one dimension that groups the merchant's own CONFIRMED transactions
|
|
526
|
+
# rather than payments, so it reports both counts: `transactions` is how often it ran (a
|
|
527
|
+
# partial capture runs several times on one order), `orders` how many it touched.
|
|
528
|
+
client.analytics.breakdown(by: "operation")
|
|
529
|
+
# => [{ key: "capture", orders: 1, transactions: 2 }, …]
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
All three take the same filters — `mode`, `status`, `token`, `chain_id`, `from`, `to` — so
|
|
533
|
+
the same question can be asked at three resolutions: one total, a series over time, a split
|
|
534
|
+
by dimension. `from`/`to` filter the payment's CREATION date, which matters when reading
|
|
535
|
+
gas: a payment created in one period and captured in the next books its capture gas in the
|
|
536
|
+
first.
|
|
537
|
+
|
|
538
|
+
## Signing helpers (`Rail0::Signing`)
|
|
539
|
+
|
|
540
|
+
Requires the `eth` gem. No private key ever leaves your process.
|
|
541
|
+
|
|
542
|
+
| Method | Use |
|
|
543
|
+
|--------|-----|
|
|
544
|
+
| `sign_payload(signer, signing_payload)` | Sign the EIP-3009 payload from a create/refund response — the recommended path, and the only one that follows the gateway across contract versions |
|
|
545
|
+
| `sign_transaction(unsigned_transaction, key)` | Sign a prepare step's unsigned EIP-1559 transaction; returns the 0x raw tx |
|
|
546
|
+
| `sign_transfer_with_authorization(signer, domain, params)` | Raw EIP-3009 `TransferWithAuthorization` signer, for talking to a token directly |
|
|
547
|
+
|
|
548
|
+
```ruby
|
|
549
|
+
require "rail0/signing"
|
|
550
|
+
sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
|
|
551
|
+
client.payments.sign(rail0_id, { signature: sig.to_hex })
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
**The gateway builds the payload; you sign it verbatim.** `sign_payload` takes the
|
|
555
|
+
typehash from the payload's `primaryType` and every field from its `message` — only
|
|
556
|
+
the gateway knows which contract version a payment lives on, and the version selects
|
|
557
|
+
the typehash, the EIP-712 domain and the field layout. An unrecognised `primaryType`
|
|
558
|
+
raises rather than falling back, because a guessed typehash yields a *valid*
|
|
559
|
+
signature over the wrong digest, which fails only on-chain, after gas.
|
|
560
|
+
|
|
561
|
+
`sign_authorize` / `sign_charge` were removed for that reason — they rebuilt the
|
|
562
|
+
digest from a payment record. They raise with a pointer here.
|
|
563
|
+
|
|
564
|
+
### Signing without a raw key
|
|
565
|
+
|
|
566
|
+
`sign_payload` and `sign_transfer_with_authorization` accept a private-key String,
|
|
567
|
+
an `Eth::Key`, or **any object responding to `#sign(digest)`** that returns a
|
|
568
|
+
65-byte hex signature — a KMS or HSM client, a remote signer, a hardware-wallet
|
|
569
|
+
bridge. The SDK builds the EIP-712 digest and hands only that over, so the secret
|
|
570
|
+
need never be materialised as a String in your process:
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
class KmsSigner
|
|
574
|
+
def sign(digest) = MyKms.sign(key_id: KEY_ID, digest: digest) # -> "0x…" (65 bytes)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
sig = Rail0::Signing.sign_payload(KmsSigner.new, payment[:signing_payload])
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
`sign_transaction` is narrower on purpose: `Eth::Tx#sign` derives an EIP-155 `v`
|
|
581
|
+
from the chain id, so it needs a full `Eth::Key` (a String or an `Eth::Key`), not a
|
|
582
|
+
bare digest signer.
|
|
583
|
+
|
|
584
|
+
## Logging
|
|
585
|
+
|
|
586
|
+
Pass any callable as `logger` to receive a `Rail0::LogEntry` per request attempt.
|
|
587
|
+
|
|
588
|
+
```ruby
|
|
589
|
+
client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)
|
|
590
|
+
# D, [...] DEBUG -- : [rail0] GET 200 https://.../payments/0x… 87ms
|
|
591
|
+
|
|
592
|
+
# Rail0::DefaultLogger is a Logger subclass, so it takes any Logger.new argument:
|
|
593
|
+
client = Rail0::Client.new(
|
|
594
|
+
base_url: "https://api.rail0.xyz",
|
|
595
|
+
logger: Rail0::DefaultLogger.new("rail0.log", level: Logger::WARN)
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
# Or route into your own logger:
|
|
599
|
+
log = Logger.new($stdout)
|
|
600
|
+
client = Rail0::Client.new(
|
|
601
|
+
base_url: "https://api.rail0.xyz",
|
|
602
|
+
logger: ->(e) { e.error ? log.error("rail0: #{e.error}") : log.debug("rail0: #{e.method} #{e.status} #{e.duration_ms}ms") }
|
|
603
|
+
)
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
## Error handling
|
|
607
|
+
|
|
608
|
+
Non-2xx responses raise `Rail0::ApiError`, carrying the gateway's code/title/detail
|
|
609
|
+
triple:
|
|
610
|
+
|
|
611
|
+
```ruby
|
|
612
|
+
begin
|
|
613
|
+
client.payments.capture(rail0_id, { signed_transaction: raw })
|
|
614
|
+
rescue Rail0::ApiError => e
|
|
615
|
+
e.status # 422
|
|
616
|
+
e.error # "insufficient_token_balance" — branch on this, and only this
|
|
617
|
+
e.title # "Not enough balance" — a heading
|
|
618
|
+
e.detail # a sentence you can show a user verbatim (also e.message)
|
|
619
|
+
e.hint # this SDK's own extra advice, nil when it has none
|
|
620
|
+
end
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
**`error` is the only field to branch on.** It is the specific condition, read from the
|
|
624
|
+
gateway's `code` and falling back to the older `error` sub-code and then to `status` (the
|
|
625
|
+
wider family), so an older gateway still yields the most specific value it sent.
|
|
626
|
+
|
|
627
|
+
`title` and `detail` come from the gateway's error catalogue, so the same condition always
|
|
628
|
+
reads the same way whichever endpoint surfaced it; `detail` is written to be shown to a
|
|
629
|
+
user as-is.
|
|
630
|
+
|
|
631
|
+
`hint` (or `Rail0.describe_error(code)`) is this SDK's own advice — a *supplement* to
|
|
632
|
+
`detail`, present only for codes with a next step worth adding. The codes span four
|
|
633
|
+
families, and the last two are the ones most requests actually hit, neither raised by
|
|
634
|
+
RAIL0 itself:
|
|
635
|
+
|
|
636
|
+
| Family | Examples |
|
|
637
|
+
| --- | --- |
|
|
638
|
+
| Request & state guards | `not_capturable`, `amount_exceeds_refundable`, `not_the_payee` |
|
|
639
|
+
| RAIL0 custom errors | `not_payee`, `already_captured`, `refund_expired` |
|
|
640
|
+
| Token reverts | `insufficient_token_balance`, `invalid_token_signature`, `authorization_already_used` |
|
|
641
|
+
| Broadcast rejections | `insufficient_gas_funds`, `nonce_too_low`, `replacement_underpriced` |
|
|
642
|
+
|
|
643
|
+
A failed transaction carries the same triple as `error_code`, `error_title` and
|
|
644
|
+
`error_detail`, whether it reverted on-chain or was refused before broadcast.
|
|
645
|
+
|
|
646
|
+
## Configuration
|
|
647
|
+
|
|
648
|
+
```ruby
|
|
649
|
+
Rail0::Client.new(
|
|
650
|
+
base_url: "https://api.rail0.xyz",
|
|
651
|
+
headers: { "Authorization" => "Bearer …" }, # for JWT-protected endpoints
|
|
652
|
+
timeout: 30, # seconds (default 30)
|
|
653
|
+
max_retries: 0, # network-error retries (default 0)
|
|
654
|
+
retry_delay: 0.2, # base delay, doubles each attempt
|
|
655
|
+
retry_on_429: false, # retry a rate limit (default false)
|
|
656
|
+
retry_after_cap: 60, # longest Retry-After to honour, seconds
|
|
657
|
+
logger: Rail0::DEFAULT_LOGGER # optional
|
|
658
|
+
)
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
### Rate limits
|
|
662
|
+
|
|
663
|
+
The gateway throttles two surfaces independently: the public, unauthenticated one **per
|
|
664
|
+
IP** (100 requests / 60s by default — SIWE nonce + verify, `/payment_methods`, the
|
|
665
|
+
catalog reads, `/health`) and everything authenticated **per session**, keyed on the
|
|
666
|
+
JWT's subject (300 / 60s). Over budget it answers **429** with `code: "rate_limited"` and
|
|
667
|
+
a `Retry-After`.
|
|
668
|
+
|
|
669
|
+
`Rail0::ApiError#retry_after` carries that header as whole seconds — nil on every other
|
|
670
|
+
error, and nil when the header is absent or unusable. Read it rather than guessing:
|
|
671
|
+
|
|
672
|
+
```ruby
|
|
673
|
+
begin
|
|
674
|
+
client.payments.list
|
|
675
|
+
rescue Rail0::ApiError => e
|
|
676
|
+
raise unless e.error == "rate_limited"
|
|
677
|
+
sleep(e.retry_after || 5) # the gateway's own pacing
|
|
678
|
+
end
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
Note what the number means: the gateway sends **the whole throttle period**, not the time
|
|
682
|
+
left in the current window, so it is an upper bound on the wait rather than a measurement.
|
|
683
|
+
|
|
684
|
+
`retry_on_429: true` makes the SDK do that waiting for you — Retry-After, clamped to
|
|
685
|
+
`retry_after_cap`, plus a little jitter (see `Rail0::Backoff`; callers sharing one session
|
|
686
|
+
are told the same number and would otherwise wake in lockstep). The jitter never shortens
|
|
687
|
+
a wait below what it is for: additive on the server's own number, and equal jitter — half
|
|
688
|
+
fixed, half random — on a guessed one. It is **off by default**
|
|
689
|
+
on purpose: an automatic sleep hides back-pressure from the process that could react to
|
|
690
|
+
it, and in a request/response app it turns a rate limit into a stalled page. Turn it on in
|
|
691
|
+
a job — and note it sleeps the **calling thread**. It also works on its own: you do not
|
|
692
|
+
need to set `max_retries` as well (that pairing would make the flag a silent no-op).
|
|
693
|
+
|
|
694
|
+
Only network errors, timeouts and — when opted in — a 429 are retried; no other HTTP
|
|
695
|
+
error is. The 429 is safe to retry on **any** method, `POST` included, because the
|
|
696
|
+
gateway rejects it in middleware before the request reaches the application: nothing ran,
|
|
697
|
+
so nothing can run twice. That is not true of a 502 or a timeout on a capture, where the
|
|
698
|
+
broadcast may already be in flight.
|
|
699
|
+
|
|
700
|
+
## Project structure
|
|
701
|
+
|
|
702
|
+
```text
|
|
703
|
+
gen/generate.rb regenerates lib/rail0/types.rb from the gateway OpenAPI schema
|
|
704
|
+
|
|
705
|
+
lib/rail0/
|
|
706
|
+
client.rb Rail0::Client — entry point
|
|
707
|
+
http_client.rb thin per-verb facade (get/post/put/patch/delete) over Request
|
|
708
|
+
request.rb Rail0::Request — one HTTP call: retry, pagination, error mapping, logging
|
|
709
|
+
default_logger.rb Rail0::LogEntry + Rail0::DefaultLogger (Logger subclass) for `logger:`
|
|
710
|
+
api_error.rb Rail0::ApiError (code/title/detail + #hint)
|
|
711
|
+
error_hints.rb Rail0.describe_error — per-code next steps, shared with the other SDKs
|
|
712
|
+
signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
|
|
713
|
+
stablecoins.rb stablecoin address registry
|
|
714
|
+
types.rb generated Struct docs of the gateway schema (reference only)
|
|
715
|
+
version.rb Rail0::VERSION
|
|
716
|
+
resources/
|
|
717
|
+
auth.rb SIWE authentication
|
|
718
|
+
chains.rb public blockchain catalog
|
|
719
|
+
tokens.rb public token catalog
|
|
720
|
+
health.rb gateway health check
|
|
721
|
+
payment_methods.rb public payment-method discovery
|
|
722
|
+
wallets.rb account-scoped wallet management (JWT)
|
|
723
|
+
payments.rb payment lifecycle + disputes
|
|
724
|
+
webhooks.rb webhook subscription management (JWT)
|
|
725
|
+
analytics.rb account-scoped payment analytics (JWT)
|
|
726
|
+
query.rb shared query-string helper
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
## Development
|
|
730
|
+
|
|
731
|
+
```bash
|
|
732
|
+
bundle install
|
|
733
|
+
bundle exec rake # rubocop, then the specs — the same gate CI runs
|
|
734
|
+
|
|
735
|
+
bundle exec rubocop # style only
|
|
736
|
+
bundle exec rubocop -a # and fix what is safely fixable
|
|
737
|
+
bundle exec rspec # specs only
|
|
738
|
+
|
|
739
|
+
# Regenerate lib/rail0/types.rb after a gateway schema change:
|
|
740
|
+
# defaults to ../rail0-gateway/docs/openapi.json, or set RAIL0_SCHEMA_PATH.
|
|
741
|
+
ruby gen/generate.rb
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
`.rubocop.yml` is calibrated to the code that already exists rather than to rubocop's
|
|
745
|
+
defaults, and every relaxation in it carries the reason — double quotes because the project
|
|
746
|
+
uses them everywhere, table-aligned hashes because some literals are tables, and
|
|
747
|
+
`Naming/VariableNumber` off because the numbers in these names are protocol identifiers
|
|
748
|
+
(`retry_on_429`, `eip712`, `secp256k1`) that each spell themselves their own way. A linter
|
|
749
|
+
that argues with the codebase teaches people to ignore it.
|
|
750
|
+
|
|
751
|
+
## License
|
|
752
|
+
|
|
753
|
+
[MIT](LICENSE)
|