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
|
@@ -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
|