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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "forwardable"
7
+ require_relative "api_error"
8
+ require_relative "backoff"
9
+ require_relative "default_logger"
10
+
11
+ module Rail0
12
+ # @!visibility private
13
+ # Executes one logical call against the gateway: builds the URL, retries on
14
+ # network errors per the client's max_retries/retry_delay, translates non-2xx
15
+ # responses into Rail0::ApiError, parses the body (including the paginated
16
+ # {data, meta} envelope), and logs every attempt. One instance per call.
17
+ class Request
18
+ extend Forwardable
19
+
20
+ ERRORS = [
21
+ SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT,
22
+ Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
23
+ ].freeze
24
+
25
+ TYPES = { get: Net::HTTP::Get, post: Net::HTTP::Post,
26
+ put: Net::HTTP::Put, patch: Net::HTTP::Patch,
27
+ delete: Net::HTTP::Delete }.freeze
28
+
29
+ def_delegators :client, :base_url, :headers, :timeout, :logger, :max_retries, :retry_delay,
30
+ :retry_on_429, :retry_after_cap
31
+
32
+ attr_reader :client, :method, :path, :body, :paginated, :extra_headers
33
+
34
+ def initialize(client:, method:, path:, body:, paginated:, extra_headers:)
35
+ @client = client
36
+ @method = method
37
+ @path = path
38
+ @body = body
39
+ @paginated = paginated
40
+ @extra_headers = extra_headers
41
+ freeze
42
+ end
43
+
44
+ def call
45
+ url = "#{base_url}#{path}"
46
+ response, duration_ms, attempt = with_retries(url) { perform(url) }
47
+
48
+ unless response.is_a?(Net::HTTPSuccess)
49
+ error_body = parse_error_body(response)
50
+ api_error = ApiError.new(response.code.to_i, error_code(error_body),
51
+ error_message(error_body, response), title: error_body[:title],
52
+ retry_after: retry_after_seconds(response))
53
+ logger.call(LogEntry.new(
54
+ method: method.to_s.upcase, url: url, duration_ms: duration_ms, attempt: attempt,
55
+ request_body: body, status: response.code.to_i, response_body: error_body, error: api_error
56
+ ))
57
+ raise api_error
58
+ end
59
+
60
+ body_data = parse_body(response)
61
+ result = paginated ? { data: body_data, meta: page_meta(response) } : body_data
62
+ logger.call(LogEntry.new(
63
+ method: method.to_s.upcase, url: url, duration_ms: duration_ms, attempt: attempt,
64
+ request_body: body, status: response.code.to_i, response_body: result
65
+ ))
66
+ result
67
+ end
68
+
69
+ private
70
+
71
+ # One loop for the two things worth retrying, which fail in different ways: a network
72
+ # error raises, a rate limit comes back as a perfectly good 429 response.
73
+ #
74
+ # A 429 is the one status this SDK retries, and the reason is not that it is common.
75
+ # The gateway rejects it in middleware (Rack::Attack), BEFORE the request reaches the
76
+ # application — so nothing was executed, and retrying carries no risk of doing the
77
+ # work twice. That is not true of a 502 or a timeout on, say, a capture, where the
78
+ # broadcast may already be in flight. Which is why the method does not matter here and
79
+ # a POST is retried like a GET.
80
+ #
81
+ # The sleep is on the CALLING thread. There is no thread pool in this SDK and no
82
+ # promise to wait on: a job that turns retry_on_429 on is choosing to block.
83
+ def with_retries(url)
84
+ attempt = 1
85
+ loop do
86
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
87
+ begin
88
+ response = yield
89
+ rescue *ERRORS => e
90
+ will_retry = attempt <= max_retries
91
+ logger.call(LogEntry.new(
92
+ method: method.to_s.upcase, url: url, duration_ms: elapsed_ms(start),
93
+ attempt: attempt, request_body: body, error: e, will_retry: will_retry
94
+ ))
95
+ raise unless will_retry
96
+
97
+ attempt += 1
98
+ sleep(retry_delay * (2**(attempt - 2)))
99
+ next
100
+ end
101
+
102
+ return [response, elapsed_ms(start), attempt] unless retry_throttled?(response, attempt)
103
+
104
+ delay = Backoff.throttle_delay(
105
+ retry_after: response["retry-after"], attempt: attempt,
106
+ base: retry_delay, cap: retry_after_cap
107
+ )
108
+ logger.call(LogEntry.new(
109
+ method: method.to_s.upcase, url: url, duration_ms: elapsed_ms(start), attempt: attempt,
110
+ request_body: body, status: response.code.to_i,
111
+ response_body: parse_error_body(response), will_retry: true
112
+ ))
113
+ attempt += 1
114
+ sleep(delay)
115
+ end
116
+ end
117
+
118
+ # Whether this response is a rate limit the client opted into retrying, and whether
119
+ # there is budget left.
120
+ #
121
+ # `max_retries` is what bounds it, except that its default is 0 — so requiring both
122
+ # flags would make retry_on_429 a silent no-op. One retry is the floor when the
123
+ # caller asked for the behaviour at all.
124
+ def retry_throttled?(response, attempt)
125
+ return false unless retry_on_429 && response.code.to_i == 429
126
+
127
+ attempt <= [max_retries, 1].max
128
+ end
129
+
130
+ # @return [Integer, nil] the Retry-After header as whole seconds, when it is a
131
+ # positive number. HTTP-date form is not parsed: the gateway never sends one, and
132
+ # guessing at a date would be worse than admitting we have no instruction.
133
+ def retry_after_seconds(response)
134
+ seconds = Backoff.positive_number(response["retry-after"])
135
+ seconds&.round
136
+ end
137
+
138
+ def parse_body(response)
139
+ raw = response.body
140
+ return nil if raw.nil? || raw.strip.empty?
141
+
142
+ JSON.parse(raw, symbolize_names: true)
143
+ end
144
+
145
+ def page_meta(response)
146
+ {
147
+ page: response["x-page"].to_i,
148
+ per_page: response["x-per-page"].to_i,
149
+ total: response["x-total-count"].to_i,
150
+ # Zero for an empty collection: "no pages" is what there are, so a pager
151
+ # rendered off this renders none. (rail0-gateway#242)
152
+ total_pages: response["x-total-pages"].to_i,
153
+ links: page_links(response["link"])
154
+ }.freeze
155
+ end
156
+
157
+ # RFC 8288 Link into { first:, prev:, next:, last: }. `first`/`last` are always
158
+ # sent, `prev`/`next` only where they exist, and the header is ABSENT entirely on
159
+ # an empty collection — so an empty hash here means "no pages", not "unparsed".
160
+ #
161
+ # The URIs are RELATIVE (path + query) and resolve against the URL you requested:
162
+ # the gateway emits them that way so they cannot advertise the wrong scheme
163
+ # through a TLS-terminating proxy.
164
+ def page_links(raw)
165
+ return {}.freeze if raw.nil? || raw.empty?
166
+
167
+ raw.scan(/<([^>]+)>\s*;\s*rel="([^"]+)"/)
168
+ .each_with_object({}) { |(uri, rel), acc| acc[rel.to_sym] = uri }
169
+ .freeze
170
+ end
171
+
172
+ def perform(url)
173
+ uri = URI.parse(url)
174
+ http = Net::HTTP.new(uri.host, uri.port)
175
+ http.use_ssl = uri.scheme == "https"
176
+ http.open_timeout = timeout
177
+ http.read_timeout = timeout
178
+ http.write_timeout = timeout
179
+
180
+ req_class = TYPES.fetch(method, Net::HTTP::Post)
181
+ req = req_class.new(uri.request_uri)
182
+ headers.merge(extra_headers).each { |k, v| req[k] = v }
183
+ req.body = body.to_json if body && %i[post put patch].include?(method)
184
+
185
+ http.request(req)
186
+ end
187
+
188
+ def parse_error_body(response)
189
+ JSON.parse(response.body, symbolize_names: true)
190
+ rescue JSON::ParserError, TypeError
191
+ {}
192
+ end
193
+
194
+ # The gateway answers exactly code/title/detail, having DELETED the older aliases
195
+ # rather than dual-sending them (#252) — `status` (the wider family), `message`
196
+ # (equal to detail) and Grape's `error`. The chains that read them could only ever
197
+ # find absent keys, so each collapses to the one field there is. The bare HTTP status
198
+ # stays as the last resort for a body with no text at all.
199
+ def error_code(body)
200
+ body[:code]
201
+ end
202
+
203
+ def error_message(body, response = nil)
204
+ body[:detail] || (response && "HTTP #{response.code}")
205
+ end
206
+
207
+ def elapsed_ms(start)
208
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rail0
4
+ module Resources
5
+ # The merchant account itself (requires JWT).
6
+ #
7
+ # One method, and that is the whole surface by design: the gateway guards
8
+ # `/accounts/:account_id` with an ownership check — a JWT whose account matches the
9
+ # path — so a caller can only ever read its OWN account. There is no endpoint for
10
+ # reading another merchant's, and an id that is not an account answers 404 exactly as
11
+ # another account's id does, so the pair cannot be used to learn whether an account
12
+ # exists.
13
+ #
14
+ # The account's wallets are a collection under the same path and live on
15
+ # {Resources::Wallets}; buyer-facing discovery of what a merchant accepts lives on
16
+ # {Resources::PaymentMethods}.
17
+ class Accounts
18
+ attr_reader :http
19
+
20
+ def initialize(http)
21
+ @http = http
22
+ freeze
23
+ end
24
+
25
+ # The account's own profile.
26
+ # @param account_id [String] The account UUID — must be the one this JWT belongs to.
27
+ # @return [Hash] `id`, `name`, `email`, `created_at`, `updated_at`. `email` is part of
28
+ # the response because the holder is this endpoint's only possible caller: it is a
29
+ # merchant reading its own contact address, never another's.
30
+ def get(account_id)
31
+ http.get("/accounts/#{account_id}")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query"
4
+
5
+ module Rail0
6
+ module Resources
7
+ # Account-scoped payment analytics (requires JWT — a buyer's account-less token is
8
+ # refused with `account_required`). Every endpoint takes the same filters, so the
9
+ # three views answer one question at different resolutions: a total, a series over
10
+ # time, a split by dimension.
11
+ #
12
+ # Money is never summed across units, and each view obeys that differently: `summary`
13
+ # and `breakdown` GROUP by (token, chain) and always report volume, while `timeseries`
14
+ # can only carry volume when a query pins both `token` and `chain_id` — a bucket is one
15
+ # number, so it has nowhere to keep two tokens apart. Gas follows the same rule per
16
+ # chain: it is denominated in the chain's native token, so it is reported per chain and
17
+ # never totalled.
18
+ #
19
+ # These methods return the parsed JSON as-is, so the keys documented below are the
20
+ # contract this SDK offers — there is no typed wrapper standing between them and the
21
+ # gateway.
22
+ class Analytics
23
+ include Query
24
+
25
+ FILTERS = %i[mode status token chain_id from to].freeze
26
+
27
+ attr_reader :http
28
+
29
+ def initialize(http)
30
+ @http = http
31
+ freeze
32
+ end
33
+
34
+ # Totals for the account's payments.
35
+ # @param mode [String, nil] "authorize" or "charge".
36
+ # @param status [String, nil] A payment status, e.g. "captured".
37
+ # @param token [String, nil] Token address (0x…).
38
+ # @param chain_id [Integer, nil] Chain ID. Pass nil or 0 for all chains.
39
+ # @param from [String, nil] ISO-8601 — payments created at/after this time.
40
+ # @param to [String, nil] ISO-8601 — payments created at/before this time.
41
+ # @return [Hash] the headline KPIs:
42
+ # * `orders`, `disputed` — counts.
43
+ # * `refund_rate`, `dispute_rate` — fractions in [0,1], per ORDER.
44
+ # * `failed_rate` — per resolved TRANSACTION, not per order: one order can carry
45
+ # several attempts, and a retried capture that eventually confirms is what this
46
+ # surfaces.
47
+ # * `by_status` — status => count, for the statuses present.
48
+ # * `volume` — one row per (token, chain) with base-unit integer strings: `gross`
49
+ # authorized, `settled` held by the payee net of refunds, `escrowed` still in
50
+ # escrow, and gross `captured`/`refunded` from the confirmed transactions. The
51
+ # first two say where the money IS, the last two what HAPPENED.
52
+ # * `failures` — why the merchant's transactions failed, commonest first: one row of
53
+ # `code` (the same catalogue an error body uses, or "unknown") and `transactions`.
54
+ # `failed_rate` says how much fails; this says what to act on — a revert is a state
55
+ # problem (an amount above the residual, a closed window) while a rejection that
56
+ # never reached the chain is a wallet problem (no gas money, a used nonce).
57
+ # * `gas` — one row per CHAIN, in that chain's NATIVE token (`decimals` 18, never
58
+ # the payment token): `spent` on confirmed transactions, `wasted` burned by
59
+ # on-chain reverts, `confirmed`/`failed` counts, and `orders` — the payments
60
+ # behind the figures, which is the denominator for the average cost of an order.
61
+ # Never sum across chains: Base ETH and Polygon POL are different currencies. Each
62
+ # row also carries `confirmation_secs`: mean seconds from broadcast to confirmation
63
+ # on that chain, weighted by its confirmations, and nil when none confirmed — not
64
+ # 0, which would read as instant. Per chain, because a chain that wants 60
65
+ # confirmations and one that wants 4 are not comparable.
66
+ # * `gas_by_status`, `gas_by_operation` — those same rows regrouped, each carrying
67
+ # a `key` (the status / the operation). Every cut adds back up to its chain's
68
+ # `gas` row. `orders` is null on the operation cut, since one order spans several
69
+ # operations. The status cut is a SNAPSHOT: status moves, so an authorize's gas
70
+ # sits under "authorized" until the payment is captured and then under
71
+ # "captured".
72
+ #
73
+ # Gas covers only the operations the merchant broadcasts — dispute/close_dispute
74
+ # are the buyer's cost on-chain and release records no sender, so both are out.
75
+ def summary(**filters)
76
+ http.get("/analytics/summary#{build_query(**only_filters(filters))}")
77
+ end
78
+
79
+ # The account's payment count per time bucket, oldest first.
80
+ # @param interval [String, nil] "day" (default), "week" or "month". Anything else is
81
+ # rejected by the gateway — there is no hourly bucket.
82
+ # @return [Array<Hash>] `bucket` (ISO-8601 start), `orders`, and `volume` — a
83
+ # base-unit string only when BOTH token and chain_id are filtered, else null.
84
+ def timeseries(interval: nil, **filters)
85
+ http.get("/analytics/timeseries#{build_query(**only_filters(filters), interval: interval)}")
86
+ end
87
+
88
+ # The account's payments aggregated by one dimension.
89
+ # @param by [String] Required — "token", "chain", "mode", "status" or "operation".
90
+ # @return [Array<Hash>] one row per group: `key` (the dimension value) and `orders`.
91
+ # token/chain rows also carry `token`, `chain_id`, `decimals` and `volume`;
92
+ # mode/status rows leave those null, since summing money across tokens would add
93
+ # different currencies. An `operation` row groups the merchant's own CONFIRMED
94
+ # transactions rather than payments, so it reports both counts: `transactions` is how
95
+ # often the operation ran — a partial capture runs several times on one order — and
96
+ # `orders` is how many orders it touched.
97
+ def breakdown(by:, **filters)
98
+ raise ArgumentError, "by is required (token, chain, mode or status)" if by.nil? || by.to_s.empty?
99
+
100
+ http.get("/analytics/breakdown#{build_query(**only_filters(filters), by: by)}")
101
+ end
102
+
103
+ private
104
+
105
+ def only_filters(filters)
106
+ picked = filters.slice(*FILTERS)
107
+ picked[:chain_id] = nil if picked[:chain_id] == 0
108
+ picked
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rail0
4
+ module Resources
5
+ # SIWE (Sign-In With Ethereum) authentication.
6
+ #
7
+ # {nonce} and {verify} are plain HTTP calls with no extra dependencies.
8
+ # {login} runs the full handshake and needs the optional signing gems
9
+ # ('eth' and 'siwe-rb'), which are required lazily so `require "rail0"`
10
+ # works without them.
11
+ #
12
+ # The JWT returned by {verify}/{login} is NOT stored on the client — pass it
13
+ # yourself on subsequent requests via the client's +headers+:
14
+ # auth = client.auth.login(private_key: "0x...", domain: "api.rail0.xyz")
15
+ # client = Rail0::Client.new(base_url: BASE, headers: { "Authorization" => "Bearer #{auth[:token]}" })
16
+ class Auth
17
+ # The SIWE statement for signing in — POST /auth. Kept identical to
18
+ # rail0-go's and rail0-ts's, so every SDK puts the same text in front of the
19
+ # user for the same handshake.
20
+ LOGIN_STATEMENT = "Sign in to RAIL0"
21
+
22
+ # The SIWE statement for proving ownership of a wallet being registered —
23
+ # POST /accounts/:id/wallets.
24
+ #
25
+ # A SEPARATE statement, and the separation is the security property rather
26
+ # than cosmetic: the gateway binds each endpoint to exactly one of these
27
+ # (Policy::SIWE_LOGIN_STATEMENT / SIWE_WALLET_LINK_STATEMENT) and refuses the
28
+ # other with 422 siwe_purpose_mismatch. A login signature is handed out on
29
+ # every sign-in, so a wallet-link endpoint that also accepted one would let
30
+ # anyone holding a captured login proof bind that address to their OWN
31
+ # account. Never collapse the two into a single constant.
32
+ WALLET_LINK_STATEMENT = "Add this wallet to your RAIL0 account"
33
+
34
+ attr_reader :http
35
+
36
+ def initialize(http)
37
+ @http = http
38
+ freeze
39
+ end
40
+
41
+ # Fetch a single-use SIWE nonce from the API (POST /auth/nonces).
42
+ # @return [Hash] { nonce:, expires_at: }
43
+ def nonce
44
+ http.post("/auth/nonces", {})
45
+ end
46
+
47
+ # Submit a pre-built SIWE message and its signature, returning a JWT.
48
+ # @param message [String] EIP-4361 formatted message string.
49
+ # @param signature [String] 0x-prefixed hex signature.
50
+ # @return [Hash] { token:, address:, account_id:, name:, expires_at: }
51
+ def verify(message:, signature:)
52
+ http.post("/auth", { message: message, signature: signature })
53
+ end
54
+
55
+ # End the session whose token this client carries.
56
+ #
57
+ # Per TOKEN, not per address: signing out one process leaves the others signed in.
58
+ # Requires the session it revokes, so the client must be holding one — a client
59
+ # built without a token gets a 401 rather than a silent no-op.
60
+ #
61
+ # `revoked` is the OUTCOME, not a formality, and the reason this returns the body
62
+ # instead of nil. The gateway's denylist fails open by design — a store outage must
63
+ # not sign out the whole platform — so `false` means the token is STILL USABLE until
64
+ # its own expiry, and a caller should treat its copy as compromised rather than
65
+ # assume the session is gone. rail0-go and rail0-ts have had this; Ruby was the one
66
+ # SDK where a long-lived process could not hand a session back. (#19)
67
+ #
68
+ # @return [Hash] { revoked: true|false }
69
+ def logout
70
+ http.post("/auth/logout", {})
71
+ end
72
+
73
+ # End EVERY session of the calling address (POST /auth/revoke_all).
74
+ #
75
+ # The answer to a key you no longer trust, and {#logout} cannot be that answer: it
76
+ # is per TOKEN, so an address with five live sessions needs five tokens the caller
77
+ # does not have. This is per ADDRESS and reaches the ones it never saw — including
78
+ # any an attacker is holding.
79
+ #
80
+ # 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.
83
+ #
84
+ # `cutoff` is the field worth logging. It says exactly which sessions died, which
85
+ # `revoked: true` cannot.
86
+ #
87
+ # @return [Hash] { revoked: true|false, cutoff: "2026-08-27T21:00:00Z" }
88
+ def revoke_all
89
+ http.post("/auth/revoke_all", {})
90
+ end
91
+
92
+ # Perform the full SIWE authentication flow:
93
+ # 1. Fetch a nonce
94
+ # 2. Build an EIP-4361 message via siwe-rb
95
+ # 3. Sign it with personal_sign (EIP-191)
96
+ # 4. Verify with the API and return a JWT
97
+ #
98
+ # Requires the optional 'eth' and 'siwe-rb' gems.
99
+ #
100
+ # @param private_key [String] 0x-prefixed hex private key of the account wallet.
101
+ # @param domain [String] Host of the API server (e.g. "api.rail0.xyz").
102
+ # @param chain_id [Integer] Chain ID to embed in the SIWE message. Must match
103
+ # the gateway's SIWE_CHAIN_ID policy (default 1); override only when the
104
+ # gateway is configured with a different login chain.
105
+ # @return [Hash] { token:, address:, account_id:, name:, expires_at: }
106
+ def login(private_key:, domain:, chain_id: 1)
107
+ message, signature = sign_proof(private_key, domain, chain_id, LOGIN_STATEMENT)
108
+ verify(message: message, signature: signature)
109
+ end
110
+
111
+ # SIWE proof-of-ownership of the address controlled by +private_key+, to hand
112
+ # to Wallets#create as its +message+ and +signature+.
113
+ #
114
+ # The same handshake as #login — fetch a single-use nonce, build an EIP-4361
115
+ # message, sign it with EIP-191 personal_sign — but it stops short of POST
116
+ # /auth: it does NOT authenticate the client. Registering a wallet proves
117
+ # control of the ADDED address, while the request itself is authorized by the
118
+ # caller's existing session, and the two addresses may differ (a merchant may
119
+ # register several payee wallets under one account).
120
+ #
121
+ # +private_key+ must therefore be the key OF the address being added, not the
122
+ # session key — the gateway rejects a signature that does not recover to the
123
+ # submitted address with 422.
124
+ #
125
+ # Requires the optional 'eth' and 'siwe-rb' gems.
126
+ #
127
+ # @param private_key [String] 0x-prefixed hex private key of the address being added.
128
+ # @param domain [String] Host of the API server (e.g. "api.rail0.xyz").
129
+ # @param chain_id [Integer] Chain ID to embed; same meaning and default as #login.
130
+ # @return [Hash] { message:, signature: } — pass straight to Wallets#create.
131
+ def prove_address(private_key:, domain:, chain_id: 1)
132
+ message, signature = sign_proof(private_key, domain, chain_id, WALLET_LINK_STATEMENT)
133
+ { message: message, signature: signature }
134
+ end
135
+
136
+ private
137
+
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.
141
+ def sign_proof(private_key, domain, chain_id, statement)
142
+ ensure_signing_deps!
143
+
144
+ nonce_resp = nonce
145
+ key = build_eth_key(private_key)
146
+
147
+ msg = Siwe::Message.new(
148
+ domain: domain,
149
+ address: key.address.to_s,
150
+ uri: "https://#{domain}",
151
+ chain_id: chain_id,
152
+ nonce: nonce_resp[:nonce] || nonce_resp["nonce"],
153
+ statement: statement
154
+ )
155
+ message_str = msg.prepare_message
156
+ [message_str, personal_sign(key, message_str)]
157
+ end
158
+
159
+ def ensure_signing_deps!
160
+ original_verbose = $VERBOSE
161
+ $VERBOSE = nil
162
+ require "eth"
163
+ require "siwe"
164
+ # rubocop:disable Lint/Void -- referencing these constants IS the point: it forces the
165
+ # autoloaded parser/message files to load here, while warnings are muted, instead of
166
+ # at the first login where the gem's own warnings would reach the caller's output.
167
+ Siwe::Parser
168
+ Siwe::Message
169
+ # rubocop:enable Lint/Void
170
+ rescue LoadError => e
171
+ raise e,
172
+ "client.auth.login requires the 'eth' and 'siwe-rb' gems. " \
173
+ "Add them to your Gemfile: gem 'eth', '~> 0.5'; gem 'siwe-rb', '~> 0.2'"
174
+ ensure
175
+ $VERBOSE = original_verbose
176
+ end
177
+
178
+ def build_eth_key(private_key)
179
+ hex = private_key.start_with?("0x") ? private_key[2..] : private_key
180
+ Eth::Key.new(priv: hex)
181
+ end
182
+
183
+ def personal_sign(key, message)
184
+ prefixed = "\x19Ethereum Signed Message:\n#{message.bytesize}#{message}"
185
+ digest = Eth::Util.keccak256(prefixed)
186
+ sig = key.sign(digest)
187
+ sig_bytes = [sig].pack("H*")
188
+ "0x#{sig_bytes.unpack1('H*')}"
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query"
4
+
5
+ module Rail0
6
+ module Resources
7
+ # Public blockchain catalog (GET /blockchains, no auth).
8
+ class Chains
9
+ include Query
10
+
11
+ attr_reader :http
12
+
13
+ def initialize(http)
14
+ @http = http
15
+ freeze
16
+ end
17
+
18
+ # List active blockchains supported by RAIL0.
19
+ # @param network_type [String, nil] Filter by "testnet" or "mainnet".
20
+ # @param symbol [String, nil] Filter by native symbol (case-insensitive, e.g. "ETH").
21
+ # @return [Array<Hash>] chain_id, name, native_symbol, network_type, explorer_url,
22
+ # required_confirmations, finality_tag (the settlement rule: the tag where the
23
+ # chain serves one, the count only where it does not)
24
+ def list(network_type: nil, symbol: nil)
25
+ http.get("/blockchains#{build_query(network_type: network_type, symbol: symbol)}")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query"
4
+
5
+ module Rail0
6
+ module Resources
7
+ # Account-level dispute list (requires JWT). Complements
8
+ # {Payments#disputes} (one payment's open/close history): this surfaces every
9
+ # dispute — open AND closed — across the authenticated wallet's payments (as
10
+ # payer or payee), each with its parent payment embedded. A closed dispute
11
+ # drops out of the payments `disputed` filter (current-state) but still
12
+ # appears here.
13
+ class Disputes
14
+ include Query
15
+
16
+ attr_reader :http
17
+
18
+ def initialize(http)
19
+ @http = http
20
+ freeze
21
+ end
22
+
23
+ # List the account's disputes.
24
+ # @param status [String, nil] Filter by "open" or "closed".
25
+ # @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
26
+ # @param page [Integer, nil] Page number (1-based).
27
+ # @param per_page [Integer, nil] Items per page (max 100).
28
+ # @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
29
+ def list(status: nil, sort: nil, page: nil, per_page: nil)
30
+ query = build_query(status: status, sort: sort, page: page, per_page: per_page)
31
+ http.get_list("/disputes#{query}")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rail0
4
+ module Resources
5
+ # Gateway liveness/readiness check (GET /health, no auth).
6
+ class Health
7
+ attr_reader :http
8
+
9
+ def initialize(http)
10
+ @http = http
11
+ freeze
12
+ end
13
+
14
+ # Report gateway health, including database connectivity. The gateway
15
+ # returns HTTP 503 (raised as Rail0::ApiError) when the database is
16
+ # unreachable.
17
+ # @return [Hash] status, api_version, contract_version, db, active_chains, active_contracts, timestamp
18
+ def get
19
+ http.get("/health")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query"
4
+
5
+ module Rail0
6
+ module Resources
7
+ # Public, buyer-facing payment-method discovery (GET /payment_methods, no JWT).
8
+ #
9
+ # A payer that only knows the merchant — by account id, or by one of the
10
+ # merchant's wallet addresses — can list the active wallet/token combinations
11
+ # the merchant accepts, without holding the merchant's session. This is the
12
+ # public counterpart to the SIWE-gated {Wallets} resource: it exposes only the
13
+ # active wallets and their active token holdings, never operational fields.
14
+ class PaymentMethods
15
+ include Query
16
+
17
+ attr_reader :http
18
+
19
+ def initialize(http)
20
+ @http = http
21
+ freeze
22
+ end
23
+
24
+ # List a merchant's active payment methods. Provide EXACTLY ONE handle:
25
+ # +account_id+ returns all the merchant's active wallets; +address+ returns
26
+ # just that one wallet. Passing both (or neither) is rejected by the gateway
27
+ # with HTTP 400. An unknown account/address yields an empty array.
28
+ #
29
+ # @param account_id [String, nil] Merchant account UUID.
30
+ # @param address [String, nil] A single merchant wallet address (0x).
31
+ # @return [Array<Hash>] wallets, each with nested active tokens.
32
+ def list(account_id: nil, address: nil)
33
+ http.get("/payment_methods#{build_query(account_id: account_id, address: address)}")
34
+ end
35
+ end
36
+ end
37
+ end