rail0-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Code generated by gen/generate.rb — DO NOT EDIT.
4
+ # Re-run `ruby gen/generate.rb` after updating the gateway OpenAPI schema.
5
+ #
6
+ # Source: rail0-gateway/docs/openapi.json
7
+ #
8
+ # These Structs document the gateway's request/response shapes. They are
9
+ # reference only — SDK methods return plain Hashes with symbol keys.
10
+
11
+ module Rail0
12
+ module Types
13
+ # A wallet's on-chain balances, one entry per chain.
14
+ WalletBalances = Struct.new(
15
+ :wallet_id, # String
16
+ :address, # String
17
+ :balances, # Array
18
+ keyword_init: true
19
+ )
20
+
21
+ # Balances on one chain. `native`/`tokens` are null when `error` is present; `error` is null on
22
+ # success.
23
+ ChainBalance = Struct.new(
24
+ :chain_id, # Integer
25
+ :network_type, # String
26
+ :native, # AssetBalance
27
+ :tokens, # Array
28
+ :error, # BalanceError
29
+ keyword_init: true
30
+ )
31
+
32
+ # A single balance line — the native gas token or one ERC-20.
33
+ AssetBalance = Struct.new(
34
+ :symbol, # String
35
+ :address, # Token contract address; null for the native balance.
36
+ :decimals, # Integer
37
+ :raw, # String — Balance in base units (exact).
38
+ :amount, # String — Human-decimal balance.
39
+ keyword_init: true
40
+ )
41
+
42
+ # Why a chain's balances couldn't be read.
43
+ BalanceError = Struct.new(
44
+ :code, # String
45
+ :message, # String
46
+ keyword_init: true
47
+ )
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`).
57
+ 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`.
63
+ keyword_init: true
64
+ )
65
+
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).
70
+ Health = Struct.new(
71
+ :status, # String
72
+ :api_version, # String
73
+ :contract_version, # String
74
+ :db, # String
75
+ :sidekiq, # Hash — Worker fleet health (does not gate liveness).
76
+ :active_chains, # Integer
77
+ :active_contracts, # Integer
78
+ :timestamp, # String
79
+ keyword_init: true
80
+ )
81
+
82
+ # A single-use SIWE nonce to embed in the sign-in message.
83
+ Nonce = Struct.new(
84
+ :nonce, # String — The nonce value to place in the SIWE message's `Nonce:` field.
85
+ :expires_at, # String — When the nonce expires and can no longer be used.
86
+ keyword_init: true
87
+ )
88
+
89
+ # Issued after a successful SIWE verification. SIWE alone proves control of the address, so a token
90
+ # is issued even when the address is not registered to any account; in that case `account_id` and
91
+ # `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.
93
+ Session = Struct.new(
94
+ :token, # String — JWT bearer token.
95
+ :address, # String — Resolved wallet address.
96
+ :account_id, # String — The account owning the signed-in wallet, or null for an account-less (e.g. buyer) session.
97
+ :name, # String — The account's human-readable name, or null for an account-less session.
98
+ :expires_at, # String
99
+ keyword_init: true
100
+ )
101
+
102
+ # 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
+ 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
116
+ keyword_init: true
117
+ )
118
+
119
+ # Public accepted-token view. The listing is not implicitly active-only (a payment references its
120
+ # token address forever, so a retired token must stay resolvable), so `active` tells a usable token
121
+ # from a retired one.
122
+ Token = Struct.new(
123
+ :chain_id, # Integer
124
+ :symbol, # String
125
+ :address, # String
126
+ :decimals, # Integer
127
+ :active, # Boolean — False for a retired token: still resolvable for historical payments, but not usable for a new one.
128
+ keyword_init: true
129
+ )
130
+
131
+ # Public-safe wallet view (the reduced set a buyer needs to discover a merchant's payment methods).
132
+ Wallet = Struct.new(
133
+ :id, # String
134
+ :address, # String
135
+ :label,
136
+ :active, # Boolean
137
+ keyword_init: true
138
+ )
139
+
140
+ # A wallet's token holding, with the wallet, token, and blockchain nested via their own schemas.
141
+ WalletToken = Struct.new(
142
+ :default, # Boolean
143
+ :active, # Boolean
144
+ :wallet, # Wallet
145
+ :token, # Token
146
+ :blockchain, # Blockchain
147
+ keyword_init: true
148
+ )
149
+
150
+ # A wallet's token holding as nested under its wallet (GET /accounts/:id/wallets): the token plus
151
+ # this wallet's per-token flags, without re-nesting the wallet.
152
+ WalletTokenHolding = Struct.new(
153
+ :token, # Token
154
+ :active, # Boolean
155
+ :default, # Boolean
156
+ keyword_init: true
157
+ )
158
+
159
+ # A wallet with its token holdings nested inline. `tokens` is empty when the wallet has none — the
160
+ # wallet is still returned, never omitted.
161
+ WalletWithTokens = Struct.new(
162
+ :id, # String
163
+ :address, # String
164
+ :label,
165
+ :active, # Boolean
166
+ :tokens, # Array
167
+ keyword_init: true
168
+ )
169
+
170
+ # Base persisted payment fields.
171
+ Payment = Struct.new(
172
+ :id, # String
173
+ :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.
176
+ :rail0_id, # String — Protocol-level identifier (66-char hex).
177
+ :status, # String
178
+ :mode, # String
179
+ :amount, # String
180
+ :capturable_amount, # String — Mirrors on-chain capturableAmount (escrow still held); base units.
181
+ :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
+ :config_hash, # String
187
+ :payer, # String
188
+ :payee, # String
189
+ :token, # String
190
+ :authorization_expiry, # Integer
191
+ :refund_expiry, # Integer
192
+ :disputed, # Boolean — True while an open dispute exists.
193
+ :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
+ :last_error_message, # Human-readable form of last_error_code.
195
+ :description,
196
+ :metadata,
197
+ :signed_at, # When the payer signature was stored (null while unsigned).
198
+ :created_at, # String
199
+ :updated_at, # String
200
+ keyword_init: true
201
+ )
202
+
203
+ # Buyer-driven, signal-only dispute lifecycle (no fund effect).
204
+ Dispute = Struct.new(
205
+ :id, # String
206
+ :payment_id, # String
207
+ :status, # String
208
+ :reason, # String — On-chain bytes32 reason code (hex).
209
+ :opened_block,
210
+ :opened_at, # String
211
+ :closed_by,
212
+ :close_reason,
213
+ :closed_block,
214
+ :closed_at,
215
+ keyword_init: true
216
+ )
217
+
218
+ Transaction = Struct.new(
219
+ :id, # String
220
+ :payment_id, # String
221
+ :operation, # String
222
+ :status, # String
223
+ :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
+ :error_title, # String — Short label for `error_code`; null unless failed.
225
+ :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
+ :unsigned_transaction,
228
+ :transaction_hash,
229
+ :amount,
230
+ :block_number,
231
+ :gas_used, # Gas units used, mirrored from the indexer on confirm.
232
+ :gas_limit, # Gas limit, mirrored from the indexer on confirm.
233
+ :effective_gas_price, # Effective gas price in wei, mirrored from the indexer on confirm.
234
+ :base_fee_per_gas, # Block base fee per gas in wei, mirrored from the indexer on confirm.
235
+ :gas_cost, # Total gas cost in wei (gas_used * effective_gas_price); derived, null until confirmed.
236
+ :pending_at,
237
+ :submitted_at,
238
+ :confirmed_at,
239
+ :created_at, # String
240
+ :updated_at, # String
241
+ keyword_init: true
242
+ )
243
+
244
+ Webhook = Struct.new(
245
+ :id, # String
246
+ :name, # String
247
+ :callback_url, # String
248
+ :topics, # Array<WebhookTopic> — every event this subscription delivers
249
+ :active, # Boolean
250
+ :circuit_state, # String
251
+ :circuit_failure_count, # Integer
252
+ :created_at, # String
253
+ :updated_at, # String
254
+ keyword_init: true
255
+ )
256
+
257
+ EventCallback = Struct.new(
258
+ :id, # String
259
+ :webhook_id, # String
260
+ :payment_id, # String
261
+ :topic, # String
262
+ :callback_url, # String
263
+ :response_code,
264
+ :response_message,
265
+ :error_reason,
266
+ :status, # String
267
+ :created_at, # String
268
+ :payload, # The JSON request body POSTed to the callback URL (decompressed).
269
+ keyword_init: true
270
+ )
271
+
272
+ # Sweeper view of a stale submitted transaction.
273
+ SyncTransaction = Struct.new(
274
+ :transaction_hash, # String
275
+ :operation, # String
276
+ :payment_id, # String — Protocol-level rail0_id.
277
+ :chain_id, # Integer
278
+ keyword_init: true
279
+ )
280
+
281
+ # Per-chain indexer config.
282
+ SyncBlockchain = Struct.new(
283
+ :chain_id, # Integer
284
+ :start_block, # Integer
285
+ :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.
288
+ :explorer_url, # Block explorer base URL; null when the chain has none.
289
+ :network_type, # String — "testnet" or "mainnet"; selects which chains a deployment indexes.
290
+ :rpc_urls, # Array — Ordered list of public RPC endpoints tried in turn (serial fallback).
291
+ :contracts, # Array — Active RAIL0 contract addresses on the chain (all watched versions).
292
+ keyword_init: true
293
+ )
294
+ end
295
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rail0
4
+ # 1.0.0 is where the PUBLISHED history starts. The gem reached 1.2.0 while it was
5
+ # consumed by path from a sibling checkout, where the number bought nothing: no registry
6
+ # ever served a 1.x, no tag was ever cut, and the one consumer resolves the working tree.
7
+ # Publishing from 1.2.0 would have implied two releases nobody can install.
8
+ VERSION = "1.0.0"
9
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Rail0
6
+ # Verification for the signature the gateway puts on every webhook delivery.
7
+ #
8
+ # The SDK used to hand out `shared_secret` on create/rotate, tell the reader to
9
+ # verify deliveries with it, and provide nothing to verify with — so every
10
+ # consumer had to reverse-engineer the scheme, and getting it wrong is a security
11
+ # bug in THEIR app, not a failed request. (#9)
12
+ #
13
+ # A delivery carries:
14
+ #
15
+ # X-Rail0-Topic the topic, e.g. "payments.captured"
16
+ # X-Rail0-Timestamp unix seconds, as a string
17
+ # X-Rail0-Signature hex HMAC-SHA256 over "{timestamp}.{body}", keyed by the secret
18
+ #
19
+ # The timestamp is inside the signed string on purpose: without it a captured
20
+ # delivery is replayable forever, because the body alone stays valid indefinitely.
21
+ # That is why {verify} rejects a stale timestamp even when the digest matches —
22
+ # checking the digest and ignoring the clock leaves the replay window wide open.
23
+ module WebhookSignature
24
+ # Same window the gateway's own /sync channel uses, symmetric so a consumer
25
+ # whose clock runs fast doesn't reject live deliveries.
26
+ DEFAULT_TOLERANCE_SECONDS = 300
27
+
28
+ class << self
29
+ # Verify a delivery. Returns true / false — nothing here raises on a bad
30
+ # signature, since a spoofed request is an expected condition on a public
31
+ # endpoint, not an exception.
32
+ #
33
+ # @param body [String] the RAW request body, exactly as received. Re-serialising
34
+ # a parsed hash changes key order and whitespace, and the digest with it.
35
+ # @param signature [String] the X-Rail0-Signature header.
36
+ # @param timestamp [String, Integer] the X-Rail0-Timestamp header.
37
+ # @param secret [String] the webhook's shared_secret.
38
+ # @param tolerance [Integer] accepted clock skew in seconds, either direction.
39
+ # @param now [Time] injectable clock, for tests.
40
+ # @return [Boolean]
41
+ # `verify`, not `verify?`: it is the SDK's public API and reads as the action it
42
+ # performs (see Naming/PredicateMethod in .rubocop-shared.yml).
43
+ def verify(body:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS, now: Time.now)
44
+ return false if body.nil? || secret.to_s.empty?
45
+ return false unless fresh?(timestamp, tolerance, now)
46
+
47
+ secure_equal?(signature.to_s, expected_signature(body, timestamp, secret))
48
+ end
49
+
50
+ # The signature the gateway would send for this (timestamp, body). Exposed so a
51
+ # consumer can log or diff the two sides when a delivery is being rejected —
52
+ # comparing digests by eye is otherwise the only way to debug it.
53
+ #
54
+ # @return [String] hex HMAC-SHA256 over "{timestamp}.{body}"
55
+ def expected_signature(body, timestamp, secret)
56
+ OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{body}")
57
+ end
58
+
59
+ private
60
+
61
+ def fresh?(timestamp, tolerance, now)
62
+ seconds = Integer(timestamp.to_s, 10)
63
+ (now.to_i - seconds).abs <= tolerance
64
+ rescue ArgumentError, TypeError
65
+ # A missing or non-numeric timestamp can't be inside any window.
66
+ false
67
+ end
68
+
69
+ # Constant-time compare: a byte-by-byte early return leaks how much of the
70
+ # digest matched, which is enough to forge one byte at a time.
71
+ # OpenSSL.secure_compare needs equal lengths to be meaningful, and returns
72
+ # false rather than raising on a mismatch, so the length check is explicit.
73
+ def secure_equal?(given, expected)
74
+ return false unless given.bytesize == expected.bytesize
75
+
76
+ OpenSSL.secure_compare(given, expected)
77
+ end
78
+ end
79
+ end
80
+ end
data/lib/rail0-sdk.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is "rail0-sdk"; its entry point is `rail0`.
4
+ #
5
+ # This file is not cosmetic. Bundler requires a gem by its OWN name by default, so
6
+ # `gem "rail0-sdk"` in a Gemfile with Bundler.require raises LoadError without it. It also
7
+ # gives anyone who reaches for the name they installed the file they expect.
8
+ #
9
+ # The canonical path stays `rail0`, unchanged: it is what every existing caller, example and
10
+ # integration test requires, and what the Rail0 module is named after.
11
+ require_relative "rail0"
data/lib/rail0.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point. Everything below is loaded with require_relative on purpose: the
4
+ # gem used to unshift lib/rail0 and lib/rail0/resources onto the GLOBAL $LOAD_PATH
5
+ # so its files could require each other by bare name ("request", "query", …).
6
+ # That put ~20 generic basenames at the FRONT of the search path for the whole
7
+ # process, so any gem loaded afterwards doing a bare `require "request"` silently
8
+ # got ours — order-dependent and effectively undiagnosable. require_relative needs
9
+ # no path at all. (#5)
10
+
11
+ require_relative "rail0/version"
12
+ require_relative "rail0/error_hints"
13
+ require_relative "rail0/api_error"
14
+ require_relative "rail0/backoff"
15
+ require_relative "rail0/default_logger"
16
+ require_relative "rail0/request"
17
+ require_relative "rail0/http_client"
18
+ require_relative "rail0/client"
19
+ require_relative "rail0/webhook_signature"
20
+ require_relative "rail0/stablecoins"
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rail0-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Commerce Layer
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: logger
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.6'
27
+ description: |
28
+ REST client for the RAIL0 stablecoin payment API. Wraps the authorize →
29
+ capture → refund lifecycle with full type documentation, retry support,
30
+ pluggable logging, and optional off-chain EIP-3009 signing.
31
+ email:
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE
37
+ - README.md
38
+ - lib/rail0-sdk.rb
39
+ - lib/rail0.rb
40
+ - lib/rail0/api_error.rb
41
+ - lib/rail0/backoff.rb
42
+ - lib/rail0/client.rb
43
+ - lib/rail0/default_logger.rb
44
+ - lib/rail0/error_hints.rb
45
+ - lib/rail0/http_client.rb
46
+ - lib/rail0/request.rb
47
+ - lib/rail0/resources/accounts.rb
48
+ - lib/rail0/resources/analytics.rb
49
+ - lib/rail0/resources/auth.rb
50
+ - lib/rail0/resources/chains.rb
51
+ - lib/rail0/resources/disputes.rb
52
+ - lib/rail0/resources/health.rb
53
+ - lib/rail0/resources/payment_methods.rb
54
+ - lib/rail0/resources/payments.rb
55
+ - lib/rail0/resources/query.rb
56
+ - lib/rail0/resources/tokens.rb
57
+ - lib/rail0/resources/wallets.rb
58
+ - lib/rail0/resources/webhooks.rb
59
+ - lib/rail0/signing.rb
60
+ - lib/rail0/stablecoins.rb
61
+ - lib/rail0/types.rb
62
+ - lib/rail0/version.rb
63
+ - lib/rail0/webhook_signature.rb
64
+ homepage: https://github.com/commercelayer/rail0-ruby
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ source_code_uri: https://github.com/commercelayer/rail0-ruby
69
+ bug_tracker_uri: https://github.com/commercelayer/rail0-ruby/issues
70
+ changelog_uri: https://github.com/commercelayer/rail0-ruby/releases
71
+ rubygems_mfa_required: 'true'
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.0'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.5.11
88
+ signing_key:
89
+ specification_version: 4
90
+ summary: Ruby SDK for the RAIL0 stablecoin payment protocol
91
+ test_files: []