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,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rail0
|
|
4
|
+
# Raised for non-2xx responses from the RAIL0 gateway, mirroring the code/title/detail
|
|
5
|
+
# triple the gateway answers with. `detail` is written to be shown to a user; `hint` is
|
|
6
|
+
# this SDK's own advice, present only for codes worth adding a next step to.
|
|
7
|
+
class ApiError < StandardError
|
|
8
|
+
# @!attribute [r] status
|
|
9
|
+
# @return [Integer] HTTP status code (e.g. 404, 409, 422).
|
|
10
|
+
# @!attribute [r] error
|
|
11
|
+
# @return [String] The specific condition, and the only field to branch on — e.g.
|
|
12
|
+
# "not_capturable", "insufficient_token_balance", "insufficient_gas_funds".
|
|
13
|
+
# @!attribute [r] title
|
|
14
|
+
# @return [String, nil] Short label for the failure, e.g. "Not enough balance".
|
|
15
|
+
# @!attribute [r] detail
|
|
16
|
+
# @return [String, nil] One or two sentences fit to show a user verbatim. Also this
|
|
17
|
+
# exception's message.
|
|
18
|
+
# @!attribute [r] retry_after
|
|
19
|
+
# @return [Integer, nil] Seconds the gateway asked the caller to wait, from the
|
|
20
|
+
# `Retry-After` header — present on a 429 (`error == "rate_limited"`) and nil
|
|
21
|
+
# otherwise. Surfaced because the alternative is a caller guessing: the SDK used
|
|
22
|
+
# to drop the header, so "rate limited" arrived with no idea of for how long.
|
|
23
|
+
#
|
|
24
|
+
# Note it is the WHOLE window the gateway throttles over, not the time left in it
|
|
25
|
+
# — the limiter sends its period verbatim — so it is an upper bound on the wait,
|
|
26
|
+
# not a measurement. Rail0::Backoff clamps it for that reason.
|
|
27
|
+
attr_reader :status, :error, :title, :detail, :retry_after
|
|
28
|
+
|
|
29
|
+
# @param status [Integer]
|
|
30
|
+
# @param error [String]
|
|
31
|
+
# @param message [String] The detail; kept positional for compatibility.
|
|
32
|
+
# @param title [String, nil]
|
|
33
|
+
# @param retry_after [Integer, nil]
|
|
34
|
+
def initialize(status, error, message, title: nil, retry_after: nil)
|
|
35
|
+
super(message)
|
|
36
|
+
@status = status
|
|
37
|
+
@error = error
|
|
38
|
+
@title = title
|
|
39
|
+
@detail = message
|
|
40
|
+
@retry_after = retry_after
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# This SDK's actionable next step for the error, or nil when it has none.
|
|
45
|
+
# @return [String, nil]
|
|
46
|
+
def hint
|
|
47
|
+
Rail0.describe_error(error)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rail0
|
|
4
|
+
# How long to wait before retrying a request the gateway rate-limited.
|
|
5
|
+
#
|
|
6
|
+
# Pure, and its own module, because the two interesting decisions here are easy to get
|
|
7
|
+
# backwards and impossible to notice once they are wrong — a client that waits too
|
|
8
|
+
# little walks straight back into the limiter, and one that waits too long looks hung.
|
|
9
|
+
#
|
|
10
|
+
# 1. JITTER NEVER SHORTENS THE WAIT BELOW WHAT IT IS FOR.
|
|
11
|
+
# On a server-instructed wait it is ADDITIVE: scaling a Retry-After DOWN means retrying
|
|
12
|
+
# before the window the server named has passed, which is a second 429 by construction.
|
|
13
|
+
# So the instruction is honoured in full and a small random tail is added.
|
|
14
|
+
#
|
|
15
|
+
# On a guessed wait it is EQUAL jitter — half the delay fixed, half random — not the
|
|
16
|
+
# textbook "full jitter" that multiplies the whole delay by rand(). Full jitter can
|
|
17
|
+
# land arbitrarily close to zero, which makes a real pause indistinguishable from the
|
|
18
|
+
# bug where a Retry-After of "0" is honoured as a duration and the retry fires
|
|
19
|
+
# immediately. A floor spreads the herd just as well and leaves "did we actually wait"
|
|
20
|
+
# observable.
|
|
21
|
+
#
|
|
22
|
+
# Why any jitter at all when the server told us the time: because callers align on
|
|
23
|
+
# it. rail0-admin proxies every merchant over ONE session, so they share the
|
|
24
|
+
# per-session bucket and would all be told the same Retry-After, wake together, and
|
|
25
|
+
# recreate the burst the limiter just rejected.
|
|
26
|
+
#
|
|
27
|
+
# 2. THE CAP IS NOT PARANOIA. The gateway sends the WHOLE period as Retry-After
|
|
28
|
+
# (rack_attack.rb: `headers["retry-after"] = match_data[:period].to_s`), not the time
|
|
29
|
+
# remaining in the window — so hitting the limit one second in is told to wait the
|
|
30
|
+
# full 60. Capping bounds both that over-wait and a hostile or misconfigured value
|
|
31
|
+
# from anything between the client and the gateway.
|
|
32
|
+
module Backoff
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# @param retry_after [Integer, Float, nil] the server's Retry-After, in seconds.
|
|
36
|
+
# Absent, unparseable, zero or negative all mean "no instruction" — and zero is the
|
|
37
|
+
# trap: it is a valid duration, so treating it as one produces a burst of
|
|
38
|
+
# back-to-back requests against the very limiter that asked for a pause.
|
|
39
|
+
# @param attempt [Integer] 1 for the first retry, 2 for the second, …
|
|
40
|
+
# @param base [Float] the exponential backoff's first delay, in seconds.
|
|
41
|
+
# @param cap [Float] the longest wait to allow, in seconds.
|
|
42
|
+
# @param jitter [Float, nil] randomness in [0,1); injected only by tests. Nil draws it.
|
|
43
|
+
# @return [Float] seconds to sleep.
|
|
44
|
+
def throttle_delay(retry_after:, attempt:, base:, cap:, jitter: nil)
|
|
45
|
+
random = jitter || Kernel.rand
|
|
46
|
+
instructed = positive_number(retry_after)
|
|
47
|
+
|
|
48
|
+
if instructed
|
|
49
|
+
# Honour it in full (clamped), plus a fraction of one base delay so aligned
|
|
50
|
+
# callers do not wake in lockstep.
|
|
51
|
+
[instructed, cap].min + (random * base)
|
|
52
|
+
else
|
|
53
|
+
# No instruction: exponential from `base`, EQUAL jitter (half fixed, half random),
|
|
54
|
+
# clamped.
|
|
55
|
+
full = base * (2**(attempt - 1))
|
|
56
|
+
[(full / 2.0) + ((full / 2.0) * random), cap].min
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Float, nil] the value when it is a positive number, else nil.
|
|
61
|
+
def positive_number(value)
|
|
62
|
+
number = Float(value, exception: false)
|
|
63
|
+
number&.positive? ? number : nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/rail0/client.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "http_client"
|
|
4
|
+
require_relative "resources/auth"
|
|
5
|
+
require_relative "resources/chains"
|
|
6
|
+
require_relative "resources/tokens"
|
|
7
|
+
require_relative "resources/health"
|
|
8
|
+
require_relative "resources/payment_methods"
|
|
9
|
+
require_relative "resources/accounts"
|
|
10
|
+
require_relative "resources/wallets"
|
|
11
|
+
require_relative "resources/payments"
|
|
12
|
+
require_relative "resources/disputes"
|
|
13
|
+
require_relative "resources/webhooks"
|
|
14
|
+
require_relative "resources/analytics"
|
|
15
|
+
|
|
16
|
+
module Rail0
|
|
17
|
+
# Entry point for the RAIL0 SDK.
|
|
18
|
+
#
|
|
19
|
+
# client = Rail0::Client.new(base_url: "https://api.rail0.xyz")
|
|
20
|
+
# resp = client.auth.login(private_key: "0x...", domain: "api.rail0.xyz")
|
|
21
|
+
# resp = client.payments.create(chain_id: 84532, mode: "authorize", amount: "100.00", token: "0x...", payer: "0x...", payee: "0x...")
|
|
22
|
+
#
|
|
23
|
+
# Most of this API is authenticated: the ENTIRE payments sub-tree (create, sign,
|
|
24
|
+
# every prepare/submit, reads and list) plus wallets, webhooks, disputes and
|
|
25
|
+
# analytics. Only chains, tokens, health and payment_methods are public. The token
|
|
26
|
+
# is supplied via +headers+: pass +{ "Authorization" => "Bearer <jwt>" }+ (obtained
|
|
27
|
+
# from +auth.login+). The SDK does not persist the token for you.
|
|
28
|
+
class Client
|
|
29
|
+
# @!attribute [r] auth
|
|
30
|
+
# @return [Resources::Auth] SIWE authentication operations.
|
|
31
|
+
# @!attribute [r] chains
|
|
32
|
+
# @return [Resources::Chains] Public blockchain catalog.
|
|
33
|
+
# @!attribute [r] tokens
|
|
34
|
+
# @return [Resources::Tokens] Public token catalog.
|
|
35
|
+
# @!attribute [r] health
|
|
36
|
+
# @return [Resources::Health] Gateway liveness/readiness check.
|
|
37
|
+
# @!attribute [r] payment_methods
|
|
38
|
+
# @return [Resources::PaymentMethods] Public buyer-facing payment-method discovery.
|
|
39
|
+
# @!attribute [r] accounts
|
|
40
|
+
# @return [Resources::Accounts] The account's own profile (JWT, holder only).
|
|
41
|
+
# @!attribute [r] wallets
|
|
42
|
+
# @return [Resources::Wallets] Account-scoped wallet management (JWT).
|
|
43
|
+
# @!attribute [r] payments
|
|
44
|
+
# @return [Resources::Payments] Payment lifecycle operations.
|
|
45
|
+
# @!attribute [r] disputes
|
|
46
|
+
# @return [Resources::Disputes] Account-level dispute list (JWT).
|
|
47
|
+
# @!attribute [r] webhooks
|
|
48
|
+
# @return [Resources::Webhooks] Webhook subscription management (JWT).
|
|
49
|
+
# @!attribute [r] analytics
|
|
50
|
+
# @return [Resources::Analytics] Account-scoped payment analytics (JWT).
|
|
51
|
+
attr_reader :auth, :chains, :tokens, :health, :payment_methods,
|
|
52
|
+
:accounts, :wallets, :payments, :disputes, :webhooks, :analytics
|
|
53
|
+
|
|
54
|
+
# @param base_url [String] Base URL of the RAIL0 API, e.g. "https://api.rail0.xyz".
|
|
55
|
+
# @param headers [Hash] Default headers merged into every request (e.g. Authorization).
|
|
56
|
+
# @param token [String, #call, nil] Bearer token, or a callable resolved per request
|
|
57
|
+
# (e.g. +token: -> { current_jwt }+) so one shared client survives a token
|
|
58
|
+
# refresh. An explicit Authorization in +headers+ takes precedence.
|
|
59
|
+
# @param timeout [Numeric] Timeout in seconds. Default: 30.
|
|
60
|
+
# @param logger [#call, nil] Optional logger. Pass Rail0::DEFAULT_LOGGER for built-in output.
|
|
61
|
+
# @param max_retries [Integer] Extra attempts after a network failure. Default: 0.
|
|
62
|
+
# @param retry_delay [Numeric] Base delay in seconds between retries (exponential backoff). Default: 0.2.
|
|
63
|
+
# @param retry_on_429 [Boolean] Retry a rate-limited request, waiting the gateway's
|
|
64
|
+
# Retry-After. Default: false — an automatic sleep hides back-pressure from the
|
|
65
|
+
# process that could react to it, and stalls a request/response app. Turn it on in a
|
|
66
|
+
# job. It works on its own: no need to set +max_retries+ as well.
|
|
67
|
+
# @param retry_after_cap [Numeric] Longest Retry-After to honour, in seconds. Default: 60.
|
|
68
|
+
def initialize(base_url:, headers: {}, token: nil, timeout: 30, logger: nil,
|
|
69
|
+
max_retries: 0, retry_delay: 0.2, retry_on_429: false, retry_after_cap: 60)
|
|
70
|
+
http = HttpClient.new(
|
|
71
|
+
base_url: base_url, headers: headers, token: token, timeout: timeout,
|
|
72
|
+
logger: logger, max_retries: max_retries, retry_delay: retry_delay,
|
|
73
|
+
retry_on_429: retry_on_429, retry_after_cap: retry_after_cap
|
|
74
|
+
)
|
|
75
|
+
@auth = Resources::Auth.new(http)
|
|
76
|
+
@chains = Resources::Chains.new(http)
|
|
77
|
+
@tokens = Resources::Tokens.new(http)
|
|
78
|
+
@health = Resources::Health.new(http)
|
|
79
|
+
@payment_methods = Resources::PaymentMethods.new(http)
|
|
80
|
+
@accounts = Resources::Accounts.new(http)
|
|
81
|
+
@wallets = Resources::Wallets.new(http)
|
|
82
|
+
@payments = Resources::Payments.new(http)
|
|
83
|
+
@disputes = Resources::Disputes.new(http)
|
|
84
|
+
@webhooks = Resources::Webhooks.new(http)
|
|
85
|
+
@analytics = Resources::Analytics.new(http)
|
|
86
|
+
freeze
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Rail0
|
|
6
|
+
# One log record emitted per request attempt.
|
|
7
|
+
# rubocop:disable Lint/StructNewOverride -- `method` is the HTTP method, which is the right
|
|
8
|
+
# name for it on a log record. It shadows Struct#method (reflection), which nothing here
|
|
9
|
+
# uses; renaming the field to avoid that would make every log line read worse.
|
|
10
|
+
LogEntry = Struct.new(
|
|
11
|
+
:method, :url, :duration_ms, :request_body,
|
|
12
|
+
:status, :response_body, :error, :attempt, :will_retry,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
)
|
|
15
|
+
# rubocop:enable Lint/StructNewOverride
|
|
16
|
+
|
|
17
|
+
# Default logger for Rail0::Client's `logger:` option. A Logger subclass:
|
|
18
|
+
# formats a Rail0::LogEntry into a one-line summary and logs it through the
|
|
19
|
+
# standard Logger machinery, so #level, #formatter, and any IO/file logdev
|
|
20
|
+
# all work normally instead of being reimplemented.
|
|
21
|
+
#
|
|
22
|
+
# client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)
|
|
23
|
+
# # [rail0] GET 200 https://.../payments/0x… 87ms
|
|
24
|
+
#
|
|
25
|
+
# # Write to a file, only warnings and above:
|
|
26
|
+
# client = Rail0::Client.new(
|
|
27
|
+
# base_url: "https://api.rail0.xyz",
|
|
28
|
+
# logger: Rail0::DefaultLogger.new("rail0.log", level: Logger::WARN)
|
|
29
|
+
# )
|
|
30
|
+
class DefaultLogger < Logger
|
|
31
|
+
def initialize(logdev = $stdout, *args, **kwargs)
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The callable interface HttpClient expects: one Rail0::LogEntry per
|
|
36
|
+
# request attempt. Routes to #error on failure so raising the level past
|
|
37
|
+
# DEBUG still surfaces failed requests, and to #debug otherwise.
|
|
38
|
+
def call(entry)
|
|
39
|
+
entry.error ? error(message_for(entry)) : debug(message_for(entry))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def message_for(entry)
|
|
45
|
+
flag = entry.error ? " ERROR" : ""
|
|
46
|
+
status_part = entry.status ? " #{entry.status}" : ""
|
|
47
|
+
attempt_part =
|
|
48
|
+
if entry.attempt && (entry.attempt > 1 || entry.will_retry)
|
|
49
|
+
retry_part = entry.will_retry ? ", retrying" : ""
|
|
50
|
+
" [attempt #{entry.attempt}#{retry_part}]"
|
|
51
|
+
else
|
|
52
|
+
""
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
parts = ["[rail0]#{flag}#{attempt_part} #{entry.method}#{status_part} #{entry.url} #{entry.duration_ms.round}ms"]
|
|
56
|
+
parts << "-> #{entry.request_body.inspect}" if entry.request_body
|
|
57
|
+
parts << "<- #{entry.response_body.inspect}" if entry.response_body
|
|
58
|
+
parts << "! #{entry.error}" if entry.error
|
|
59
|
+
parts.join(" ")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Ready-to-use DefaultLogger writing to $stdout at the default level, shared
|
|
64
|
+
# so callers who just want the built-in output don't need to instantiate
|
|
65
|
+
# their own. Frozen -- it can't be reconfigured (no #level=, no #formatter=)
|
|
66
|
+
# since that would leak across every Client sharing it; anyone wanting a
|
|
67
|
+
# different IO/level should build their own via DefaultLogger.new(...).
|
|
68
|
+
#
|
|
69
|
+
# client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)
|
|
70
|
+
DEFAULT_LOGGER = DefaultLogger.new.freeze
|
|
71
|
+
|
|
72
|
+
# The `logger:` used when the caller doesn't pass one. Discards every entry,
|
|
73
|
+
# so HttpClient can always unconditionally call `logger.call(entry)` without
|
|
74
|
+
# ever checking whether logging is actually configured.
|
|
75
|
+
class NullLogger
|
|
76
|
+
def call(_entry); end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# NullLogger is stateless, so every HttpClient built without an explicit
|
|
80
|
+
# `logger:` shares this one frozen instance instead of allocating its own.
|
|
81
|
+
NULL_LOGGER = NullLogger.new.freeze
|
|
82
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rail0
|
|
4
|
+
# Actionable next steps per error code, supplementing the gateway's own `detail`.
|
|
5
|
+
# Shared source with the Go SDK (rail0.DescribeError), the TS SDK (describeError) and
|
|
6
|
+
# the CLI's hints — keep the four in step.
|
|
7
|
+
ERROR_HINTS = {
|
|
8
|
+
"amount_exceeds_capturable" => "amount is above the capturable balance — check capturableAmount on the payment",
|
|
9
|
+
"amount_exceeds_refundable" => "amount is above the refundable balance — check refundableAmount on the payment",
|
|
10
|
+
"not_capturable" => "the payment must be 'authorized' or 'partially_captured' to capture",
|
|
11
|
+
"not_voidable" => "void is only allowed while 'authorized' with nothing captured — use release for the remainder after a capture",
|
|
12
|
+
"not_releasable" => "release opens only after authorizationExpiry",
|
|
13
|
+
"not_refundable" => "nothing is refundable — the payment must be charged/captured and within the refund window",
|
|
14
|
+
"not_signable" => "the payment must be 'unsigned' to sign",
|
|
15
|
+
"already_signed" => "the payer signature is already stored — the payee can act now",
|
|
16
|
+
"no_signature" => "the payer has not signed yet",
|
|
17
|
+
"wrong_mode" => "this operation doesn't match the payment's mode (authorize vs charge)",
|
|
18
|
+
"already_disputed" => "a dispute is already open — close it first",
|
|
19
|
+
"not_disputed" => "there is no open dispute to close",
|
|
20
|
+
"nothing_to_dispute" => "a dispute needs a merchant-held (refundable) balance",
|
|
21
|
+
"transaction_not_overwritable" => "a transaction for this operation is already in flight — wait for it to settle",
|
|
22
|
+
"signer_mismatch" => "the signing key doesn't match the payment's payer/payee",
|
|
23
|
+
# The SIWE BINDING failures, split out of signer_mismatch so a failed login says
|
|
24
|
+
# WHICH part of the proof did not bind (#216). None names the server's own
|
|
25
|
+
# expectation: that endpoint is unauthenticated, so echoing the allow-list or the
|
|
26
|
+
# expected chain id would turn every hint into a probe.
|
|
27
|
+
"siwe_domain_not_allowed" => "sign with the origin the front-end is served from, and have it added to the gateway's SIWE domain allow-list",
|
|
28
|
+
"siwe_uri_mismatch" => "the message's uri host must equal its own domain",
|
|
29
|
+
"siwe_chain_mismatch" => "use the chain id the client library sends - this login is off-chain and nominal",
|
|
30
|
+
"siwe_proof_expired" => "get a fresh nonce and sign again",
|
|
31
|
+
# Address-wide, not one token: "sign in again", not "that token is dead".
|
|
32
|
+
"sessions_revoked" => "every session issued before this address's revoke-all cutoff is refused - sign in again",
|
|
33
|
+
"config_hash_mismatch" => "the payment record and its on-chain deployment disagree — the payment cannot be operated as recorded",
|
|
34
|
+
"payment_not_on_chain" => "the contract has no record of this payment — its opening transaction may never have confirmed",
|
|
35
|
+
"unsupported_contract_version" => "the payment's RAIL0 deployment is newer or older than this gateway supports — upgrade the gateway",
|
|
36
|
+
"insufficient_token_balance" => "the paying wallet does not hold enough of the token — top it up and retry",
|
|
37
|
+
"invalid_token_signature" => "the EIP-3009 authorization did not recover to the paying wallet — wrong key, chain, token or amount",
|
|
38
|
+
"authorization_already_used" => "that EIP-3009 authorization was already spent or cancelled — each is single-use, create a fresh payment",
|
|
39
|
+
"authorization_not_yet_valid" => "the authorization's validAfter is still in the future",
|
|
40
|
+
"token_account_blocked" => "the token issuer has blocklisted one of the wallets in this transfer",
|
|
41
|
+
"token_paused" => "the token contract is paused by its issuer — no transfer can settle right now",
|
|
42
|
+
"insufficient_gas_funds" => "the sending wallet cannot cover gas — fund it with the chain's native token",
|
|
43
|
+
"nonce_too_low" => "a transaction with that nonce is already on-chain — re-prepare the operation",
|
|
44
|
+
"replacement_underpriced" => "another transaction with that nonce is pending and this one does not pay enough to replace it",
|
|
45
|
+
"gas_price_too_low" => "the fee is below what the node accepts — re-prepare to pick up current fees",
|
|
46
|
+
"already_known" => "the node already has this exact transaction — wait for it to confirm rather than resending",
|
|
47
|
+
"rpc_unavailable" => "no configured RPC endpoint answered — the transaction was not submitted",
|
|
48
|
+
"not_the_payee" => "only the payment's payee can do this — sign in with the merchant's wallet",
|
|
49
|
+
"not_the_payer" => "only the payment's payer can do this — sign in with the buyer's wallet",
|
|
50
|
+
"not_a_participant" => "only the payer and the payee can see or act on a payment",
|
|
51
|
+
"not_payee" => "only the merchant (payee) may do this",
|
|
52
|
+
"not_payer" => "only the buyer (payer) may do this",
|
|
53
|
+
"not_payer_or_payee" => "only the payer or the payee may do this",
|
|
54
|
+
"refund_expired" => "the refund window has closed (refundExpiry passed) — refund/dispute is no longer possible",
|
|
55
|
+
"authorization_not_expired" => "release opens only after authorizationExpiry — wait until it passes",
|
|
56
|
+
"already_captured" => "already (partially) captured — use release for the remainder, not void",
|
|
57
|
+
"token_not_accepted" => "the token isn't in this deployment's allowlist",
|
|
58
|
+
"payment_already_exists" => "a payment with this id already exists on-chain",
|
|
59
|
+
# Ported from rail0-go, which had drifted five codes ahead of this table (#13).
|
|
60
|
+
"unsupported_payment_method" => "the payee (merchant) doesn't accept this token/chain — check the merchant's payment methods",
|
|
61
|
+
"idempotency_key_reused" => "that Idempotency-Key was already used for a payment with different terms — reuse it only to retry the same request, or pick a new key",
|
|
62
|
+
"unknown_token" => "the token isn't configured on this chain",
|
|
63
|
+
"no_active_contract" => "no active RAIL0 contract on that chain",
|
|
64
|
+
"missing_param" => "a required parameter is missing from the request",
|
|
65
|
+
# A BARE forbidden is not a party mismatch: the gateway split those into codes of
|
|
66
|
+
# their own (not_the_payee, not_the_payer, wallet_deactivated, not_your_account)
|
|
67
|
+
# because they need different fixes. This entry kept describing one of them long
|
|
68
|
+
# after the split — and the rule it named no longer exists in the gateway at all.
|
|
69
|
+
"forbidden" => "not permitted for this session — typically the operator grant, a resource owned by another account, or a transaction signed by the wrong wallet"
|
|
70
|
+
}.freeze
|
|
71
|
+
|
|
72
|
+
# An actionable hint for a rail0 error code, or nil when the code is unknown.
|
|
73
|
+
# @param code [String, nil]
|
|
74
|
+
# @return [String, nil]
|
|
75
|
+
def self.describe_error(code)
|
|
76
|
+
return nil if code.nil? || code.to_s.empty?
|
|
77
|
+
|
|
78
|
+
ERROR_HINTS[code.to_s]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "default_logger"
|
|
4
|
+
require_relative "request"
|
|
5
|
+
|
|
6
|
+
module Rail0
|
|
7
|
+
# @!visibility private
|
|
8
|
+
class HttpClient
|
|
9
|
+
attr_reader :base_url, :timeout, :logger, :max_retries, :retry_delay,
|
|
10
|
+
:retry_on_429, :retry_after_cap
|
|
11
|
+
|
|
12
|
+
# @param retry_on_429 [Boolean] retry a rate-limited request, waiting the gateway's
|
|
13
|
+
# Retry-After (see Rail0::Backoff). OFF by default, deliberately: an automatic
|
|
14
|
+
# sleep hides back-pressure from the one process that could react to it, and in a
|
|
15
|
+
# request/response app it turns a 429 into a stalled page. Turn it on for a job.
|
|
16
|
+
#
|
|
17
|
+
# It does NOT need `max_retries` to be set as well. That pairing is a footgun —
|
|
18
|
+
# the flag would silently do nothing — so on its own it allows one retry.
|
|
19
|
+
# @param retry_after_cap [Numeric] longest wait to honour, in seconds. The gateway
|
|
20
|
+
# sends its whole throttle period as Retry-After rather than the time left in it,
|
|
21
|
+
# so this bounds both the over-wait and any hostile value from in between.
|
|
22
|
+
def initialize(base_url:, headers: {}, token: nil, timeout: 30, logger: nil,
|
|
23
|
+
max_retries: 0, retry_delay: 0.2, retry_on_429: false,
|
|
24
|
+
retry_after_cap: 60)
|
|
25
|
+
@base_url = base_url.chomp("/")
|
|
26
|
+
@static_headers = { "Content-Type" => "application/json" }.merge(headers)
|
|
27
|
+
@token = token
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
@logger = logger || NULL_LOGGER
|
|
30
|
+
@max_retries = max_retries
|
|
31
|
+
@retry_delay = retry_delay
|
|
32
|
+
@retry_on_429 = retry_on_429
|
|
33
|
+
@retry_after_cap = retry_after_cap
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Headers for one request.
|
|
38
|
+
#
|
|
39
|
+
# A `token` is resolved HERE, per request, rather than baked in at construction:
|
|
40
|
+
# you need a client to call auth.login, and the login's JWT to build the client
|
|
41
|
+
# you actually use, so a header fixed at construction forced a whole new client
|
|
42
|
+
# (and its ten resource objects) on every sign-in and every refresh. Passing a
|
|
43
|
+
# callable — `token: -> { current_jwt }` — keeps one shared client valid across
|
|
44
|
+
# token rotations, which is what a long-lived process needs. A String token is
|
|
45
|
+
# accepted for the simple case, and an explicit Authorization in `headers`
|
|
46
|
+
# still wins so nothing existing changes. (#11)
|
|
47
|
+
#
|
|
48
|
+
# The object stays frozen: what varies is the proc's answer, not this object.
|
|
49
|
+
def headers
|
|
50
|
+
return @static_headers if @token.nil? || @static_headers.key?("Authorization")
|
|
51
|
+
|
|
52
|
+
resolved = @token.respond_to?(:call) ? @token.call : @token
|
|
53
|
+
return @static_headers if resolved.nil? || resolved.to_s.empty?
|
|
54
|
+
|
|
55
|
+
@static_headers.merge("Authorization" => "Bearer #{resolved}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get(path)
|
|
59
|
+
request(:get, path)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# GET a paginated collection endpoint. The gateway returns a bare JSON array
|
|
63
|
+
# with pagination carried in the X-Total-Count / X-Page / X-Per-Page response
|
|
64
|
+
# headers (not a {data, meta} envelope), so this reads the meta back from the
|
|
65
|
+
# headers and wraps the array. Non-paginated array endpoints (blockchains,
|
|
66
|
+
# tokens, payment_methods) use plain #get instead.
|
|
67
|
+
# @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
|
|
68
|
+
def get_list(path)
|
|
69
|
+
request(:get, path, nil, paginated: true)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param headers [Hash] extra headers merged over the client defaults for this
|
|
73
|
+
# request only (e.g. { "Idempotency-Key" => "..." }).
|
|
74
|
+
def post(path, body = nil, headers: {})
|
|
75
|
+
request(:post, path, body, headers: headers)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def put(path, body = nil)
|
|
79
|
+
request(:put, path, body)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def patch(path, body = nil)
|
|
83
|
+
request(:patch, path, body)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def delete(path)
|
|
87
|
+
request(:delete, path)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def request(method, path, body = nil, paginated: false, headers: {})
|
|
93
|
+
Request.new(
|
|
94
|
+
client: self, method: method, path: path, body: body,
|
|
95
|
+
paginated: paginated, extra_headers: headers
|
|
96
|
+
).call
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|