From b011630afbd73aaa9e292372f4710426c8df0210 Mon Sep 17 00:00:00 2001 From: mkcosta Date: Tue, 28 Jul 2026 10:26:03 +0200 Subject: [PATCH 1/5] Mike - general refactoring - sign_charge now delegates to sign_authorize instead of duplicating its body; Stablecoins.eip3009_tokens/eip2612_tokens share a private tokens_supporting helper. - HttpClient#request: retry loop rewritten as an explicit while loop, the triplicated conditional log-field splat collapsed into one log() method, and the Net::HTTP request-class lookup hoisted into a frozen constant. - Eip3009Signature#to_hex now raises on malformed (non-0x-prefixed) r/s instead of silently producing a bad signature. - Added missing frozen_string_literal magic comments for consistency. - Stripped inline implementation comments, section dividers, and docs on private methods; public class/module/constant docs and YARD @param/@return blocks are untouched. --- .rspec | 2 +- .ruby-version | 1 + CLAUDE.md | 17 +++ Gemfile | 1 + Gemfile.lock | 5 +- README.md | 20 +++- Rakefile | 7 ++ examples/01_authorize_and_capture.rb | 2 +- examples/02_charge.rb | 2 +- examples/03_refund.rb | 2 +- examples/04_dispute.rb | 2 +- lib/rail0.rb | 19 ++- lib/rail0/api_error.rb | 13 +- lib/rail0/client.rb | 63 +++++----- lib/rail0/default_logger.rb | 78 ++++++++++++ lib/rail0/http_client.rb | 157 ++----------------------- lib/rail0/request.rb | 132 +++++++++++++++++++++ lib/rail0/resources/auth.rb | 22 ++-- lib/rail0/resources/chains.rb | 7 +- lib/rail0/resources/disputes.rb | 7 +- lib/rail0/resources/health.rb | 5 +- lib/rail0/resources/payment_methods.rb | 7 +- lib/rail0/resources/payments.rb | 42 +++---- lib/rail0/resources/query.rb | 2 - lib/rail0/resources/tokens.rb | 7 +- lib/rail0/resources/wallets.rb | 17 +-- lib/rail0/resources/webhooks.rb | 25 ++-- lib/rail0/signing.rb | 47 ++------ lib/rail0/stablecoins.rb | 15 ++- lib/rail0/version.rb | 4 +- rail0.gemspec | 2 +- spec/client_spec.rb | 8 +- spec/default_logger_spec.rb | 115 ++++++++++++++++++ spec/stablecoins_spec.rb | 12 ++ 34 files changed, 560 insertions(+), 307 deletions(-) create mode 100644 .ruby-version create mode 100644 Rakefile create mode 100644 lib/rail0/default_logger.rb create mode 100644 lib/rail0/request.rb create mode 100644 spec/default_logger_spec.rb diff --git a/.rspec b/.rspec index 7a2cc1a..86b6d59 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,3 @@ --require spec_helper ---format documentation +--format progress --color diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..a0891f5 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.4 diff --git a/CLAUDE.md b/CLAUDE.md index fa977bd..ea9bb79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,3 +58,20 @@ When a change in one repo affects the contract, the indexer, or any SDK, flag it - **Track the gateway's public surface.** Client methods, request/response shapes, and the README mirror the gateway's public API. Operational/admin-only fields the gateway hides must not be exposed by the SDK; request inputs (signatures, signed transactions) stay as inputs. - **One client, per-resource methods.** Keep new endpoints in the matching resource grouping rather than introducing a parallel client. - **Validation before commit.** The gem's test suite (and linter, if configured) must pass. + +## Commands + +- `bundle install` — install deps +- `bundle exec rake` — run the full test suite (default task, wraps rspec) +- `bundle exec rspec spec/client_spec.rb` — run one spec file +- `bundle exec rspec spec/client_spec.rb:42` — run a single example by line +- `ruby gen/generate.rb` — regenerate `lib/rail0/types.rb` from the gateway OpenAPI schema (defaults to `../rail0-gateway/docs/openapi.json`, override with `RAIL0_SCHEMA_PATH`) + +## Architecture + +- `Rail0::Client` (`lib/rail0/client.rb`) builds one `HttpClient` and hands it to one instance per resource (`auth`, `chains`, `tokens`, `health`, `payment_methods`, `wallets`, `payments`, `disputes`, `webhooks`) under `lib/rail0/resources/`. New endpoints go on the matching resource, never a new client. +- `HttpClient` (`lib/rail0/http_client.rb`) is a thin per-verb facade (`get`/`post`/`put`/`patch`/`delete`) that builds a `Rail0::Request` per call. `Request` (`lib/rail0/request.rb`) is the sole Net::HTTP touchpoint: retries (network errors only, not HTTP error responses), pagination envelope, error mapping, and logging all live there. Resource classes never call Net::HTTP directly. +- On-chain ops follow a two-phase `prepare`/`submit` pattern implemented in `resources/payments.rb`: `_prepare` returns an `unsigned_transaction`, the caller signs it via `Rail0::Signing`, then `` submits the signed tx (HTTP 202, confirms async — poll `payments.get`). Every named wrapper (`authorize`, `capture`, `void`, `release`, `refund`, `dispute`, `close_dispute`) is a thin delegate over the generic `prepare`/`submit`/`submit_by_hash` — add new on-chain ops the same way rather than as a parallel path. +- `Rail0::Signing` (`lib/rail0/signing.rb`) and `siwe-rb` (used by `auth.login`) are lazy-loaded — `require "rail0"` must keep working without the `eth`/`siwe-rb` gems present. +- `lib/rail0/types.rb` is generated, reference-only documentation (Structs) — never hand-edit it; regenerate via `gen/generate.rb`. +- Non-2xx responses raise `Rail0::ApiError` (`status`/`error`/`message`) from `request.rb`; resource methods let it propagate rather than rescuing it. diff --git a/Gemfile b/Gemfile index 0c35d3c..6ae43dd 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,7 @@ source "https://rubygems.org" gemspec group :development do + gem "rake", "~> 13.0" gem "rspec", "~> 3.13" gem "webmock", "~> 3.23" gem "eth", "~> 0.5" diff --git a/Gemfile.lock b/Gemfile.lock index 6043003..b65fba0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rail0 (1.1.0) + rail0 (1.2.0) GEM remote: https://rubygems.org/ @@ -84,6 +84,7 @@ PLATFORMS DEPENDENCIES eth (~> 0.5) rail0! + rake (~> 13.0) rspec (~> 3.13) siwe-rb (~> 0.2) webmock (~> 3.23) @@ -112,7 +113,7 @@ CHECKSUMS openssl (3.3.3) sha256=d46902138f2987c13122fab826030a11c2bb9b8a16394215cbfc5062c5e2d335 pkg-config (1.6.5) sha256=33f9f81c5322983d22b439b8b672f27777b406fea23bfec74ff14bbeb42ec733 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 - rail0 (1.1.0) + rail0 (1.2.0) rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 rbsecp256k1 (6.0.0) sha256=d38f563bfc6bcf3ce20c336a4798cc6990427f291e74c6817360363a72f11669 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 diff --git a/README.md b/README.md index de3114b..32aa3e1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ lifecycle plus account, wallet, catalog, and webhook management. It mirrors the ## Requirements -- Ruby ≥ 2.6 +- Ruby ≥ 3.0 - For SIWE login and off-chain signing: `eth` (`~> 0.5`) and `siwe-rb` (`~> 0.2`) The core HTTP client has **no runtime dependencies** (Ruby stdlib only). The `eth` @@ -278,8 +278,14 @@ client.payments.sign(rail0_id, { signature: sig.to_hex }) Pass any callable as `logger` to receive a `Rail0::LogEntry` per request attempt. ```ruby -client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER) -# [rail0] GET 200 https://.../payments/0x… 87ms +client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER) +# D, [...] DEBUG -- : [rail0] GET 200 https://.../payments/0x… 87ms + +# Rail0::DefaultLogger is a Logger subclass, so it takes any Logger.new argument: +client = Rail0::Client.new( + base_url: "https://api.rail0.xyz", + logger: Rail0::DefaultLogger.new("rail0.log", level: Logger::WARN) +) # Or route into your own logger: log = Logger.new($stdout) @@ -312,7 +318,7 @@ Rail0::Client.new( timeout: 30, # seconds (default 30) max_retries: 0, # network-error retries (default 0) retry_delay: 0.2, # base delay, doubles each attempt - logger: Rail0::DEBUG_LOGGER # optional + logger: Rail0::DEFAULT_LOGGER # optional ) ``` @@ -325,7 +331,9 @@ gen/generate.rb regenerates lib/rail0/types.rb from the gateway OpenAPI s lib/rail0/ client.rb Rail0::Client — entry point - http_client.rb internal HTTP client (Net::HTTP, retry, pagination, logging) + http_client.rb thin per-verb facade (get/post/put/patch/delete) over Request + request.rb Rail0::Request — one HTTP call: retry, pagination, error mapping, logging + default_logger.rb Rail0::LogEntry + Rail0::DefaultLogger (Logger subclass) for `logger:` api_error.rb Rail0::ApiError signing.rb EIP-3009 + EIP-1559 signing (requires 'eth') stablecoins.rb stablecoin address registry @@ -347,7 +355,7 @@ lib/rail0/ ```bash bundle install -bundle exec rspec # run the test suite +bundle exec rake # run the test suite (default task) # Regenerate lib/rail0/types.rb after a gateway schema change: # defaults to ../rail0-gateway/docs/openapi.json, or set RAIL0_SCHEMA_PATH. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..932c257 --- /dev/null +++ b/Rakefile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/examples/01_authorize_and_capture.rb b/examples/01_authorize_and_capture.rb index 72639b8..0c5b384 100644 --- a/examples/01_authorize_and_capture.rb +++ b/examples/01_authorize_and_capture.rb @@ -15,7 +15,7 @@ ACCOUNT_ID = ENV.fetch("RAIL0_ACCOUNT_ID") # merchant account UUID BUYER = ENV.fetch("PAYER_ADDRESS") # payer's 0x address -client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER) +client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER) # ── Step 0 — discover the merchant's accepted payment methods (public) ──────── wallets = client.payment_methods.list(account_id: ACCOUNT_ID) diff --git a/examples/02_charge.rb b/examples/02_charge.rb index 4cbe74e..d633135 100644 --- a/examples/02_charge.rb +++ b/examples/02_charge.rb @@ -15,7 +15,7 @@ ACCOUNT_ID = ENV.fetch("RAIL0_ACCOUNT_ID") BUYER = ENV.fetch("PAYER_ADDRESS") -client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER) +client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER) # ── Step 0 — discover a merchant USDC wallet (public) ───────────────────────── wallet = client.payment_methods.list(account_id: ACCOUNT_ID) diff --git a/examples/03_refund.rb b/examples/03_refund.rb index 2021ee7..5511794 100644 --- a/examples/03_refund.rb +++ b/examples/03_refund.rb @@ -14,7 +14,7 @@ PAYEE_KEY = ENV.fetch("PAYEE_PRIVATE_KEY") PAYMENT_ID = ENV.fetch("RAIL0_PAYMENT_ID") # UUID or 0x-prefixed rail0_id -client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER) +client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER) # Authenticate the payee (refund is a payee/JWT operation). auth = client.auth.login(private_key: PAYEE_KEY, domain: "api.rail0.xyz") diff --git a/examples/04_dispute.rb b/examples/04_dispute.rb index 4f08c6b..ff49de5 100644 --- a/examples/04_dispute.rb +++ b/examples/04_dispute.rb @@ -11,7 +11,7 @@ PAYER_KEY = ENV.fetch("PAYER_PRIVATE_KEY") # the payer signs dispute txs PAYMENT_ID = ENV.fetch("RAIL0_PAYMENT_ID") # UUID or 0x-prefixed rail0_id -client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER) +client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER) # ── Open a dispute ──────────────────────────────────────────────────────────── # reason is an optional bytes32 code; omit it to default to zero server-side. diff --git a/lib/rail0.rb b/lib/rail0.rb index 3ad2375..9533091 100644 --- a/lib/rail0.rb +++ b/lib/rail0.rb @@ -1,5 +1,14 @@ -require_relative "rail0/version" -require_relative "rail0/api_error" -require_relative "rail0/http_client" -require_relative "rail0/client" -require_relative "rail0/stablecoins" +rail0_dir = File.join(__dir__, "rail0") +resources_dir = File.join(rail0_dir, "resources") + +[__dir__, rail0_dir, resources_dir].each do |dir| + $LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir) +end + +require "rail0/version" +require "rail0/api_error" +require "rail0/default_logger" +require "rail0/request" +require "rail0/http_client" +require "rail0/client" +require "rail0/stablecoins" diff --git a/lib/rail0/api_error.rb b/lib/rail0/api_error.rb index cccc89b..2b57c9d 100644 --- a/lib/rail0/api_error.rb +++ b/lib/rail0/api_error.rb @@ -1,11 +1,13 @@ +# frozen_string_literal: true + module Rail0 # Raised for non-2xx responses from the RAIL0 API. class ApiError < StandardError - # @return [Integer] HTTP status code (e.g. 404, 409, 422). - attr_reader :status - - # @return [String] Machine-readable error identifier (e.g. "PaymentNotFound"). - attr_reader :error + # @!attribute [r] status + # @return [Integer] HTTP status code (e.g. 404, 409, 422). + # @!attribute [r] error + # @return [String] Machine-readable error identifier (e.g. "PaymentNotFound"). + attr_reader :status, :error # @param status [Integer] # @param error [String] @@ -14,6 +16,7 @@ def initialize(status, error, message) super(message) @status = status @error = error + freeze end end end diff --git a/lib/rail0/client.rb b/lib/rail0/client.rb index 6f88f66..91396e9 100644 --- a/lib/rail0/client.rb +++ b/lib/rail0/client.rb @@ -1,13 +1,15 @@ -require_relative "http_client" -require_relative "resources/auth" -require_relative "resources/chains" -require_relative "resources/tokens" -require_relative "resources/health" -require_relative "resources/payment_methods" -require_relative "resources/wallets" -require_relative "resources/payments" -require_relative "resources/disputes" -require_relative "resources/webhooks" +# frozen_string_literal: true + +require "http_client" +require "resources/auth" +require "resources/chains" +require "resources/tokens" +require "resources/health" +require "resources/payment_methods" +require "resources/wallets" +require "resources/payments" +require "resources/disputes" +require "resources/webhooks" module Rail0 # Entry point for the RAIL0 SDK. @@ -20,29 +22,31 @@ module Rail0 # be supplied via +headers+: pass +{ "Authorization" => "Bearer " }+ (the # JWT is obtained from +auth.login+). The SDK does not persist the token for you. class Client - # @return [Resources::Auth] SIWE authentication operations. - attr_reader :auth - # @return [Resources::Chains] Public blockchain catalog. - attr_reader :chains - # @return [Resources::Tokens] Public token catalog. - attr_reader :tokens - # @return [Resources::Health] Gateway liveness/readiness check. - attr_reader :health - # @return [Resources::PaymentMethods] Public buyer-facing payment-method discovery. - attr_reader :payment_methods - # @return [Resources::Wallets] Account-scoped wallet management (JWT). - attr_reader :wallets - # @return [Resources::Payments] Payment lifecycle operations. - attr_reader :payments - # @return [Resources::Disputes] Account-level dispute list (JWT). - attr_reader :disputes - # @return [Resources::Webhooks] Webhook subscription management (JWT). - attr_reader :webhooks + # @!attribute [r] auth + # @return [Resources::Auth] SIWE authentication operations. + # @!attribute [r] chains + # @return [Resources::Chains] Public blockchain catalog. + # @!attribute [r] tokens + # @return [Resources::Tokens] Public token catalog. + # @!attribute [r] health + # @return [Resources::Health] Gateway liveness/readiness check. + # @!attribute [r] payment_methods + # @return [Resources::PaymentMethods] Public buyer-facing payment-method discovery. + # @!attribute [r] wallets + # @return [Resources::Wallets] Account-scoped wallet management (JWT). + # @!attribute [r] payments + # @return [Resources::Payments] Payment lifecycle operations. + # @!attribute [r] disputes + # @return [Resources::Disputes] Account-level dispute list (JWT). + # @!attribute [r] webhooks + # @return [Resources::Webhooks] Webhook subscription management (JWT). + attr_reader :auth, :chains, :tokens, :health, :payment_methods, + :wallets, :payments, :disputes, :webhooks # @param base_url [String] Base URL of the RAIL0 API, e.g. "https://api.rail0.xyz". # @param headers [Hash] Default headers merged into every request (e.g. Authorization). # @param timeout [Numeric] Timeout in seconds. Default: 30. - # @param logger [#call, nil] Optional logger. Pass Rail0::DEBUG_LOGGER for built-in output. + # @param logger [#call, nil] Optional logger. Pass Rail0::DEFAULT_LOGGER for built-in output. # @param max_retries [Integer] Extra attempts after a network failure. Default: 0. # @param retry_delay [Numeric] Base delay in seconds between retries (exponential backoff). Default: 0.2. def initialize(base_url:, headers: {}, timeout: 30, logger: nil, max_retries: 0, retry_delay: 0.2) @@ -59,6 +63,7 @@ def initialize(base_url:, headers: {}, timeout: 30, logger: nil, max_retries: 0, @payments = Resources::Payments.new(http) @disputes = Resources::Disputes.new(http) @webhooks = Resources::Webhooks.new(http) + freeze end end end diff --git a/lib/rail0/default_logger.rb b/lib/rail0/default_logger.rb new file mode 100644 index 0000000..0a216d9 --- /dev/null +++ b/lib/rail0/default_logger.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "logger" + +module Rail0 + # One log record emitted per request attempt. + LogEntry = Struct.new( + :method, :url, :duration_ms, :request_body, + :status, :response_body, :error, :attempt, :will_retry, + keyword_init: true + ) + + # Default logger for Rail0::Client's `logger:` option. A Logger subclass: + # formats a Rail0::LogEntry into a one-line summary and logs it through the + # standard Logger machinery, so #level, #formatter, and any IO/file logdev + # all work normally instead of being reimplemented. + # + # client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER) + # # [rail0] GET 200 https://.../payments/0x… 87ms + # + # # Write to a file, only warnings and above: + # client = Rail0::Client.new( + # base_url: "https://api.rail0.xyz", + # logger: Rail0::DefaultLogger.new("rail0.log", level: Logger::WARN) + # ) + class DefaultLogger < Logger + def initialize(logdev = $stdout, *args, **kwargs) + super + end + + # The callable interface HttpClient expects: one Rail0::LogEntry per + # request attempt. Routes to #error on failure so raising the level past + # DEBUG still surfaces failed requests, and to #debug otherwise. + def call(entry) + entry.error ? error(message_for(entry)) : debug(message_for(entry)) + end + + private + + def message_for(entry) + flag = entry.error ? " ERROR" : "" + status_part = entry.status ? " #{entry.status}" : "" + attempt_part = + if entry.attempt && (entry.attempt > 1 || entry.will_retry) + retry_part = entry.will_retry ? ", retrying" : "" + " [attempt #{entry.attempt}#{retry_part}]" + else + "" + end + + parts = ["[rail0]#{flag}#{attempt_part} #{entry.method}#{status_part} #{entry.url} #{entry.duration_ms.round}ms"] + parts << "-> #{entry.request_body.inspect}" if entry.request_body + parts << "<- #{entry.response_body.inspect}" if entry.response_body + parts << "! #{entry.error}" if entry.error + parts.join(" ") + end + end + + # Ready-to-use DefaultLogger writing to $stdout at the default level, shared + # so callers who just want the built-in output don't need to instantiate + # their own. Frozen -- it can't be reconfigured (no #level=, no #formatter=) + # since that would leak across every Client sharing it; anyone wanting a + # different IO/level should build their own via DefaultLogger.new(...). + # + # client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER) + DEFAULT_LOGGER = DefaultLogger.new.freeze + + # The `logger:` used when the caller doesn't pass one. Discards every entry, + # so HttpClient can always unconditionally call `logger.call(entry)` without + # ever checking whether logging is actually configured. + class NullLogger + def call(_entry); end + end + + # NullLogger is stateless, so every HttpClient built without an explicit + # `logger:` shares this one frozen instance instead of allocating its own. + NULL_LOGGER = NullLogger.new.freeze +end diff --git a/lib/rail0/http_client.rb b/lib/rail0/http_client.rb index a39f696..245cd2c 100644 --- a/lib/rail0/http_client.rb +++ b/lib/rail0/http_client.rb @@ -1,45 +1,21 @@ -require "net/http" -require "json" -require "uri" +# frozen_string_literal: true -module Rail0 - # One log record emitted per request attempt. - LogEntry = Struct.new( - :method, :url, :duration_ms, :request_body, - :status, :response_body, :error, :attempt, :will_retry, - keyword_init: true - ) - - # Built-in logger that writes a one-line summary to $stdout. - # - # client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER) - DEBUG_LOGGER = lambda do |entry| - flag = entry.error ? " ERROR" : "" - status_part = entry.status ? " #{entry.status}" : "" - attempt_part = - if entry.attempt - retry_part = entry.will_retry ? ", retrying" : "" - " [attempt #{entry.attempt}#{retry_part}]" - else - "" - end - - parts = ["[rail0]#{flag}#{attempt_part} #{entry.method}#{status_part} #{entry.url} #{entry.duration_ms.round}ms"] - parts << "-> #{entry.request_body.inspect}" if entry.request_body - parts << "<- #{entry.response_body.inspect}" if entry.response_body - parts << "! #{entry.error}" if entry.error - $stdout.puts parts.join(" ") - end +require "default_logger" +require "request" +module Rail0 # @!visibility private class HttpClient + attr_reader :base_url, :headers, :timeout, :logger, :max_retries, :retry_delay + def initialize(base_url:, headers: {}, timeout: 30, logger: nil, max_retries: 0, retry_delay: 0.2) @base_url = base_url.chomp("/") @headers = { "Content-Type" => "application/json" }.merge(headers) @timeout = timeout - @logger = logger + @logger = logger || NULL_LOGGER @max_retries = max_retries @retry_delay = retry_delay + freeze end def get(path) @@ -77,119 +53,10 @@ def delete(path) private def request(method, path, body = nil, paginated: false, headers: {}) - url = "#{@base_url}#{path}" - max_attempts = @max_retries + 1 - track = @max_retries > 0 - - (1..max_attempts).each do |attempt| - sleep(@retry_delay * (2**(attempt - 2))) if attempt > 1 - - start = Process.clock_gettime(Process::CLOCK_MONOTONIC) - - begin - response = do_request(method, url, body, headers) - rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, - Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e - duration_ms = elapsed_ms(start) - will_retry = attempt < max_attempts - log(LogEntry.new( - method: method.to_s.upcase, url: url, duration_ms: duration_ms, - request_body: body, error: e, - **(track ? { attempt: attempt, will_retry: will_retry } : {}) - )) - next if will_retry - raise - end - - duration_ms = elapsed_ms(start) - - unless response.is_a?(Net::HTTPSuccess) - error_body = parse_error_body(response) - api_error = ApiError.new(response.code.to_i, error_code(error_body), error_message(error_body, response)) - log(LogEntry.new( - method: method.to_s.upcase, url: url, duration_ms: duration_ms, - request_body: body, status: response.code.to_i, - response_body: error_body, error: api_error, - **(track ? { attempt: attempt } : {}) - )) - raise api_error - end - - body_data = parse_body(response) - result = paginated ? { data: body_data, meta: page_meta(response) } : body_data - log(LogEntry.new( - method: method.to_s.upcase, url: url, duration_ms: duration_ms, - request_body: body, status: response.code.to_i, response_body: result, - **(track ? { attempt: attempt } : {}) - )) - return result - end - end - - # Parse a successful response body, tolerating the empty body returned by - # 204 No Content (DELETE) and other bodyless 2xx responses. - def parse_body(response) - raw = response.body - return nil if raw.nil? || raw.strip.empty? - - JSON.parse(raw, symbolize_names: true) - end - - # Reconstruct pagination metadata from the response headers the gateway sets - # on collection endpoints. Header lookup is case-insensitive on Net::HTTP. - def page_meta(response) - { - page: response["x-page"].to_i, - per_page: response["x-per-page"].to_i, - total: response["x-total-count"].to_i - } - end - - def do_request(method, url, body, extra_headers = {}) - uri = URI.parse(url) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = uri.scheme == "https" - http.open_timeout = @timeout - http.read_timeout = @timeout - http.write_timeout = @timeout - - req_class = { get: Net::HTTP::Get, post: Net::HTTP::Post, - put: Net::HTTP::Put, patch: Net::HTTP::Patch, - delete: Net::HTTP::Delete } - .fetch(method, Net::HTTP::Post) - req = req_class.new(uri.request_uri) - @headers.merge(extra_headers).each { |k, v| req[k] = v } - req.body = body.to_json if body && %i[post put patch].include?(method) - - http.request(req) - end - - def parse_error_body(response) - JSON.parse(response.body, symbolize_names: true) - rescue JSON::ParserError, TypeError - {} - end - - # Machine-readable error code. The gateway carries it in `status` (domain - # errors) or occasionally `code`; Grape validation errors carry only `error` - # (the human text). Prefer status → code → error so a useful identifier is - # surfaced whichever shape the body took. - def error_code(body) - body[:status] || body[:code] || body[:error] - end - - # Human-readable error message. Prefer `message`, fall back to Grape's `error`, - # and finally the bare HTTP status when the body carried neither. - def error_message(body, response = nil) - body[:message] || body[:error] || (response && "HTTP #{response.code}") - end - - def log(entry) - @logger&.call(entry) - end - - def elapsed_ms(start) - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000 + Request.new( + client: self, method: method, path: path, body: body, + paginated: paginated, extra_headers: headers + ).call end end end diff --git a/lib/rail0/request.rb b/lib/rail0/request.rb new file mode 100644 index 0000000..65c3a80 --- /dev/null +++ b/lib/rail0/request.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "net/http" +require "json" +require "uri" +require "forwardable" +require "api_error" +require "default_logger" + +module Rail0 + # @!visibility private + # Executes one logical call against the gateway: builds the URL, retries on + # network errors per the client's max_retries/retry_delay, translates non-2xx + # responses into Rail0::ApiError, parses the body (including the paginated + # {data, meta} envelope), and logs every attempt. One instance per call. + class Request + extend Forwardable + + ERRORS = [ + SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, + Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError + ].freeze + + TYPES = { get: Net::HTTP::Get, post: Net::HTTP::Post, + put: Net::HTTP::Put, patch: Net::HTTP::Patch, + delete: Net::HTTP::Delete }.freeze + + def_delegators :client, :base_url, :headers, :timeout, :logger, :max_retries, :retry_delay + + attr_reader :client, :method, :path, :body, :paginated, :extra_headers + + def initialize(client:, method:, path:, body:, paginated:, extra_headers:) + @client = client + @method = method + @path = path + @body = body + @paginated = paginated + @extra_headers = extra_headers + freeze + end + + def call + url = "#{base_url}#{path}" + response, duration_ms, attempt = with_retries(url) { perform(url) } + + unless response.is_a?(Net::HTTPSuccess) + error_body = parse_error_body(response) + api_error = ApiError.new(response.code.to_i, error_code(error_body), error_message(error_body, response)) + logger.call(LogEntry.new( + method: method.to_s.upcase, url: url, duration_ms: duration_ms, attempt: attempt, + request_body: body, status: response.code.to_i, response_body: error_body, error: api_error + )) + raise api_error + end + + body_data = parse_body(response) + result = paginated ? { data: body_data, meta: page_meta(response) } : body_data + logger.call(LogEntry.new( + method: method.to_s.upcase, url: url, duration_ms: duration_ms, attempt: attempt, + request_body: body, status: response.code.to_i, response_body: result + )) + result + end + + private + + def with_retries(url) + attempt = 1 + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = yield + [result, elapsed_ms(start), attempt] + rescue *ERRORS => e + will_retry = attempt <= max_retries + logger.call(LogEntry.new( + method: method.to_s.upcase, url: url, duration_ms: elapsed_ms(start), attempt: attempt, + request_body: body, error: e, will_retry: will_retry + )) + raise unless will_retry + attempt += 1 + sleep(retry_delay * (2**(attempt - 2))) + retry + end + + def parse_body(response) + raw = response.body + return nil if raw.nil? || raw.strip.empty? + JSON.parse(raw, symbolize_names: true) + end + + def page_meta(response) + { + page: response["x-page"].to_i, + per_page: response["x-per-page"].to_i, + total: response["x-total-count"].to_i + }.freeze + end + + def perform(url) + uri = URI.parse(url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = timeout + http.read_timeout = timeout + http.write_timeout = timeout + + req_class = TYPES.fetch(method, Net::HTTP::Post) + req = req_class.new(uri.request_uri) + headers.merge(extra_headers).each { |k, v| req[k] = v } + req.body = body.to_json if body && %i[post put patch].include?(method) + + http.request(req) + end + + def parse_error_body(response) + JSON.parse(response.body, symbolize_names: true) + rescue JSON::ParserError, TypeError + {} + end + + def error_code(body) + body[:status] || body[:code] || body[:error] + end + + def error_message(body, response = nil) + body[:message] || body[:error] || (response && "HTTP #{response.code}") + end + + def elapsed_ms(start) + (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000 + end + end +end diff --git a/lib/rail0/resources/auth.rb b/lib/rail0/resources/auth.rb index d109919..32f4e2a 100644 --- a/lib/rail0/resources/auth.rb +++ b/lib/rail0/resources/auth.rb @@ -14,14 +14,17 @@ module Resources # auth = client.auth.login(private_key: "0x...", domain: "api.rail0.xyz") # client = Rail0::Client.new(base_url: BASE, headers: { "Authorization" => "Bearer #{auth[:token]}" }) class Auth + attr_reader :http + def initialize(http) @http = http + freeze end # Fetch a single-use SIWE nonce from the API (POST /auth/nonces). # @return [Hash] { nonce:, expires_at: } def nonce - @http.post("/auth/nonces", {}) + http.post("/auth/nonces", {}) end # Submit a pre-built SIWE message and its signature, returning a JWT. @@ -29,7 +32,7 @@ def nonce # @param signature [String] 0x-prefixed hex signature. # @return [Hash] { token:, address:, account_id:, name:, expires_at: } def verify(message:, signature:) - @http.post("/auth", { message: message, signature: signature }) + http.post("/auth", { message: message, signature: signature }) end # Perform the full SIWE authentication flow: @@ -69,16 +72,19 @@ def login(private_key:, domain:, chain_id: 1) private - # Lazily load the optional signing dependencies, raising a helpful error if - # they are absent (they are not required for {nonce}/{verify} or the rest of - # the SDK, so `require "rail0"` never pulls them in). def ensure_signing_deps! + original_verbose = $VERBOSE + $VERBOSE = nil require "eth" require "siwe" - rescue LoadError - raise LoadError, + Siwe::Parser # force the autoloaded parser/message files to load now, while warnings are muted + Siwe::Message + rescue LoadError => e + raise e, "client.auth.login requires the 'eth' and 'siwe-rb' gems. " \ "Add them to your Gemfile: gem 'eth', '~> 0.5'; gem 'siwe-rb', '~> 0.2'" + ensure + $VERBOSE = original_verbose end def build_eth_key(private_key) @@ -86,8 +92,6 @@ def build_eth_key(private_key) Eth::Key.new(priv: hex) end - # EIP-191 personal_sign: sign "\x19Ethereum Signed Message:\n". - # Returns a 0x-prefixed 65-byte hex string (r ++ s ++ v). def personal_sign(key, message) prefixed = "\x19Ethereum Signed Message:\n#{message.bytesize}#{message}" digest = Eth::Util.keccak256(prefixed) diff --git a/lib/rail0/resources/chains.rb b/lib/rail0/resources/chains.rb index 2fa33c7..51caae1 100644 --- a/lib/rail0/resources/chains.rb +++ b/lib/rail0/resources/chains.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "query" +require "query" module Rail0 module Resources @@ -8,8 +8,11 @@ module Resources class Chains include Query + attr_reader :http + def initialize(http) @http = http + freeze end # List active blockchains supported by RAIL0. @@ -17,7 +20,7 @@ def initialize(http) # @param symbol [String, nil] Filter by native symbol (case-insensitive, e.g. "ETH"). # @return [Array] chain_id, name, native_symbol, network_type, explorer_url def list(network_type: nil, symbol: nil) - @http.get("/blockchains#{build_query(network_type: network_type, symbol: symbol)}") + http.get("/blockchains#{build_query(network_type: network_type, symbol: symbol)}") end end end diff --git a/lib/rail0/resources/disputes.rb b/lib/rail0/resources/disputes.rb index 1bf4ef6..3eb5acf 100644 --- a/lib/rail0/resources/disputes.rb +++ b/lib/rail0/resources/disputes.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "query" +require "query" module Rail0 module Resources @@ -13,8 +13,11 @@ module Resources class Disputes include Query + attr_reader :http + def initialize(http) @http = http + freeze end # List the account's disputes. @@ -25,7 +28,7 @@ def initialize(http) # @return [Hash] { data: Array, meta: { page:, per_page:, total: } } def list(status: nil, sort: nil, page: nil, per_page: nil) query = build_query(status: status, sort: sort, page: page, per_page: per_page) - @http.get_list("/disputes#{query}") + http.get_list("/disputes#{query}") end end end diff --git a/lib/rail0/resources/health.rb b/lib/rail0/resources/health.rb index 9d454b6..974daea 100644 --- a/lib/rail0/resources/health.rb +++ b/lib/rail0/resources/health.rb @@ -4,8 +4,11 @@ module Rail0 module Resources # Gateway liveness/readiness check (GET /health, no auth). class Health + attr_reader :http + def initialize(http) @http = http + freeze end # Report gateway health, including database connectivity. The gateway @@ -13,7 +16,7 @@ def initialize(http) # unreachable. # @return [Hash] status, api_version, contract_version, db, active_chains, active_contracts, timestamp def get - @http.get("/health") + http.get("/health") end end end diff --git a/lib/rail0/resources/payment_methods.rb b/lib/rail0/resources/payment_methods.rb index 60671e7..6c4c8b5 100644 --- a/lib/rail0/resources/payment_methods.rb +++ b/lib/rail0/resources/payment_methods.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "query" +require "query" module Rail0 module Resources @@ -14,8 +14,11 @@ module Resources class PaymentMethods include Query + attr_reader :http + def initialize(http) @http = http + freeze end # List a merchant's active payment methods. Provide EXACTLY ONE handle: @@ -27,7 +30,7 @@ def initialize(http) # @param address [String, nil] A single merchant wallet address (0x). # @return [Array] wallets, each with nested active tokens. def list(account_id: nil, address: nil) - @http.get("/payment_methods#{build_query(account_id: account_id, address: address)}") + http.get("/payment_methods#{build_query(account_id: account_id, address: address)}") end end end diff --git a/lib/rail0/resources/payments.rb b/lib/rail0/resources/payments.rb index 780d25c..617bedd 100644 --- a/lib/rail0/resources/payments.rb +++ b/lib/rail0/resources/payments.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "query" +require "query" module Rail0 module Resources @@ -18,8 +18,11 @@ class Payments # Operations accepted by the prepare/submit endpoints. OPERATIONS = %w[authorize capture charge void release refund].freeze + attr_reader :http + def initialize(http) @http = http + freeze end # List payments for the authenticated wallet (requires JWT). Returns @@ -48,7 +51,7 @@ def list(status: nil, mode: nil, payer: nil, payee: nil, token: nil, rail0_id: n min_amount: min_amount, max_amount: max_amount, created_from: created_from, created_to: created_to, sort: sort, page: page, per_page: per_page) - @http.get_list("/payments#{query}") + http.get_list("/payments#{query}") end # Create a payment. Returns the record — when still unsigned it embeds the @@ -69,14 +72,14 @@ def list(status: nil, mode: nil, payer: nil, payee: nil, token: nil, rail0_id: n def create(params = nil, idempotency_key: nil, **fields) body = params || fields headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {} - @http.post("/payments", body, headers: headers) + http.post("/payments", body, headers: headers) end # Fetch current payment state (DB status + live on-chain amounts + transactions). # @param id [String] Payment UUID or rail0_id. # @return [Hash] def get(id) - @http.get("/payments/#{id}") + http.get("/payments/#{id}") end # List on-chain transactions for a payment. @@ -89,7 +92,7 @@ def get(id) # @return [Hash] { data: Array, meta: { page:, per_page:, total: } } def transactions(id, operation: nil, status: nil, sort: nil, page: nil, per_page: nil) query = build_query(operation: operation, status: status, sort: sort, page: page, per_page: per_page) - @http.get_list("/payments/#{id}/transactions#{query}") + http.get_list("/payments/#{id}/transactions#{query}") end # Submit the payer's EIP-712 signature (PUT /payments/{id}/sign). @@ -97,7 +100,7 @@ def transactions(id, operation: nil, status: nil, sort: nil, page: nil, per_page # @param params [Hash] { signature: "0x…" } (65-byte 0x-prefixed hex). # @return [Hash] def sign(id, params) - @http.put("/payments/#{id}/sign", params) + http.put("/payments/#{id}/sign", params) end # List a payment's dispute open/close history. @@ -109,11 +112,9 @@ def sign(id, params) # @return [Hash] { data: Array, meta: { page:, per_page:, total: } } def disputes(id, status: nil, sort: nil, page: nil, per_page: nil) query = build_query(status: status, sort: sort, page: page, per_page: per_page) - @http.get_list("/payments/#{id}/disputes#{query}") + http.get_list("/payments/#{id}/disputes#{query}") end - # ── Generic prepare / submit ───────────────────────────────────────────── - # Build the unsigned transaction for an operation # (POST /payments/{id}/{op}/prepare). +body+ carries operation-specific # fields: amount (capture, refund), signature (refund phase-2), from @@ -124,7 +125,7 @@ def disputes(id, status: nil, sort: nil, page: nil, per_page: nil) # @param body [Hash, nil] Operation-specific fields. # @return [Hash] def prepare(id, operation, body = nil) - @http.post("/payments/#{id}/#{operation}/prepare", body) + http.post("/payments/#{id}/#{operation}/prepare", body) end # Broadcast a signed transaction for an operation (POST /payments/{id}/{op}); HTTP 202. @@ -133,7 +134,7 @@ def prepare(id, operation, body = nil) # @param params [Hash] { signed_transaction: "0x…" }. # @return [Hash] def submit(id, operation, params) - @http.post("/payments/#{id}/#{operation}", params) + http.post("/payments/#{id}/#{operation}", params) end # Record a transaction the caller broadcast themselves (MetaMask/wallet flow) @@ -143,11 +144,9 @@ def submit(id, operation, params) # @param params [Hash] { transaction_hash: "0x…" }. # @return [Hash] def submit_by_hash(id, operation, params) - @http.post("/payments/#{id}/#{operation}/submitted", params) + http.post("/payments/#{id}/#{operation}/submitted", params) end - # ── Per-operation convenience wrappers ─────────────────────────────────── - # Phase 1 — build the unsigned authorize() transaction (escrow hold). def authorize_prepare(id) prepare(id, "authorize") @@ -220,13 +219,6 @@ def refund(id, params) submit(id, "refund", params) end - # ── Disputes (payer-driven, signal-only) ───────────────────────────────── - # - # Disputes follow the same prepare → submit lifecycle, but are payer-driven - # and authorized on-chain (no JWT): prepare returns the unsigned tx, the payer - # signs and submits the signed raw tx, and the on-chain event flips the - # payment's +disputed+ flag. - # Phase 1 — build the unsigned dispute() transaction (payer only). # @param id [String] Payment UUID or rail0_id. # @param reason [String, nil] Optional bytes32 code (0x…); defaults to zero server-side. @@ -240,7 +232,7 @@ def dispute_prepare(id, reason: nil) # @param params [Hash] { signed_transaction: "0x…" }. # @return [Hash] def dispute(id, params) - @http.post("/payments/#{id}/dispute", params) + http.post("/payments/#{id}/dispute", params) end # Phase 1 — build the unsigned closeDispute() transaction (payer only). @@ -256,16 +248,14 @@ def close_dispute_prepare(id, reason: nil) # @param params [Hash] { signed_transaction: "0x…" }. # @return [Hash] def close_dispute(id, params) - @http.post("/payments/#{id}/dispute/close", params) + http.post("/payments/#{id}/dispute/close", params) end private - # POST a dispute prepare with the optional {reason} body shared by the open - # and close prepares. def prepare_dispute(path, id, reason) body = reason ? { reason: reason } : {} - @http.post("/payments/#{id}/#{path}", body) + http.post("/payments/#{id}/#{path}", body) end end end diff --git a/lib/rail0/resources/query.rb b/lib/rail0/resources/query.rb index 990387a..f4bcb76 100644 --- a/lib/rail0/resources/query.rb +++ b/lib/rail0/resources/query.rb @@ -10,8 +10,6 @@ module Resources module Query private - # @param params [Hash] filter name => value (nil values are omitted). - # @return [String] "" when empty, otherwise "?k=v&k2=v2" (URL-escaped). def build_query(**params) pairs = params.compact.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" } pairs.empty? ? "" : "?#{pairs.join('&')}" diff --git a/lib/rail0/resources/tokens.rb b/lib/rail0/resources/tokens.rb index 298f7e4..95b8300 100644 --- a/lib/rail0/resources/tokens.rb +++ b/lib/rail0/resources/tokens.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "query" +require "query" module Rail0 module Resources @@ -8,8 +8,11 @@ module Resources class Tokens include Query + attr_reader :http + def initialize(http) @http = http + freeze end # List active tokens, optionally filtered by chain and/or symbol. @@ -18,7 +21,7 @@ def initialize(http) # @return [Array] chain_id, symbol, address, decimals def list(chain_id: nil, symbol: nil) chain_id = nil if chain_id == 0 - @http.get("/tokens#{build_query(chain_id: chain_id, symbol: symbol)}") + http.get("/tokens#{build_query(chain_id: chain_id, symbol: symbol)}") end end end diff --git a/lib/rail0/resources/wallets.rb b/lib/rail0/resources/wallets.rb index 4434481..086fd68 100644 --- a/lib/rail0/resources/wallets.rb +++ b/lib/rail0/resources/wallets.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "query" +require "query" module Rail0 module Resources @@ -14,8 +14,11 @@ module Resources class Wallets include Query + attr_reader :http + def initialize(http) @http = http + freeze end # List the account's wallets, each with nested token holdings. @@ -32,7 +35,7 @@ def list(account_id, chain_id: nil, token_symbol: nil, active: nil, default: nil sort: nil, page: nil, per_page: nil) query = build_query(chain_id: chain_id, token_symbol: token_symbol, active: active, default: default, sort: sort, page: page, per_page: per_page) - @http.get_list("/accounts/#{account_id}/wallets#{query}") + http.get_list("/accounts/#{account_id}/wallets#{query}") end # Fetch a single wallet by its id or 0x address. @@ -40,7 +43,7 @@ def list(account_id, chain_id: nil, token_symbol: nil, active: nil, default: nil # @param id_or_address [String] Wallet UUID or 0x address. # @return [Hash] id, address, label, active def get(account_id, id_or_address) - @http.get("/accounts/#{account_id}/wallets/#{id_or_address}") + http.get("/accounts/#{account_id}/wallets/#{id_or_address}") end # Add a wallet to the account. @@ -51,7 +54,7 @@ def get(account_id, id_or_address) def create(account_id, address:, label: nil) body = { address: address } body[:label] = label unless label.nil? - @http.post("/accounts/#{account_id}/wallets", body) + http.post("/accounts/#{account_id}/wallets", body) end # Update a wallet's label and/or active status. @@ -64,7 +67,7 @@ def update(account_id, id_or_address, label: nil, active: nil) body = {} body[:label] = label unless label.nil? body[:active] = active unless active.nil? - @http.patch("/accounts/#{account_id}/wallets/#{id_or_address}", body) + http.patch("/accounts/#{account_id}/wallets/#{id_or_address}", body) end # Soft-delete (deactivate) a wallet. Returns HTTP 204. @@ -72,7 +75,7 @@ def update(account_id, id_or_address, label: nil, active: nil) # @param id_or_address [String] Wallet UUID or 0x address. # @return [nil] def delete(account_id, id_or_address) - @http.delete("/accounts/#{account_id}/wallets/#{id_or_address}") + http.delete("/accounts/#{account_id}/wallets/#{id_or_address}") end # Read a wallet's live on-chain balances across the configured chains. Each @@ -86,7 +89,7 @@ def delete(account_id, id_or_address) # @return [Hash] wallet_id, address, balances def balances(account_id, id_or_address, chain_id: nil, token_symbol: nil) query = build_query(chain_id: chain_id, token_symbol: token_symbol) - @http.get("/accounts/#{account_id}/wallets/#{id_or_address}/balances#{query}") + http.get("/accounts/#{account_id}/wallets/#{id_or_address}/balances#{query}") end end end diff --git a/lib/rail0/resources/webhooks.rb b/lib/rail0/resources/webhooks.rb index 125fdf2..6a3399d 100644 --- a/lib/rail0/resources/webhooks.rb +++ b/lib/rail0/resources/webhooks.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "query" +require "query" module Rail0 module Resources @@ -24,8 +24,11 @@ class Webhooks payments.dispute_closed ].freeze + attr_reader :http + def initialize(http) @http = http + freeze end # List the account's webhooks. @@ -39,7 +42,7 @@ def initialize(http) def list(topic: nil, active: nil, circuit_state: nil, sort: nil, page: nil, per_page: nil) query = build_query(topic: topic, active: active, circuit_state: circuit_state, sort: sort, page: page, per_page: per_page) - @http.get_list("/webhooks#{query}") + http.get_list("/webhooks#{query}") end # Register a new webhook. The response includes the one-time shared_secret @@ -49,14 +52,14 @@ def list(topic: nil, active: nil, circuit_state: nil, sort: nil, page: nil, per_ # @param topic [String] One of {TOPICS}. # @return [Hash] webhook record including shared_secret def create(name:, callback_url:, topic:) - @http.post("/webhooks", { name: name, callback_url: callback_url, topic: topic }) + http.post("/webhooks", { name: name, callback_url: callback_url, topic: topic }) end # Fetch a single webhook. # @param id [String] Webhook UUID. # @return [Hash] def get(id) - @http.get("/webhooks/#{id}") + http.get("/webhooks/#{id}") end # Update a webhook's name, callback_url, and/or topic. @@ -70,35 +73,35 @@ def update(id, name: nil, callback_url: nil, topic: nil) body[:name] = name unless name.nil? body[:callback_url] = callback_url unless callback_url.nil? body[:topic] = topic unless topic.nil? - @http.patch("/webhooks/#{id}", body) + http.patch("/webhooks/#{id}", body) end # Re-enable a disabled webhook. # @param id [String] Webhook UUID. # @return [Hash] def enable(id) - @http.put("/webhooks/#{id}/enable") + http.put("/webhooks/#{id}/enable") end # Disable a webhook (stops deliveries without deleting it). # @param id [String] Webhook UUID. # @return [Hash] def disable(id) - @http.put("/webhooks/#{id}/disable") + http.put("/webhooks/#{id}/disable") end # Generate a new shared secret, returned once on the response. # @param id [String] Webhook UUID. # @return [Hash] webhook record including the new shared_secret def rotate_secret(id) - @http.put("/webhooks/#{id}/rotate_secret") + http.put("/webhooks/#{id}/rotate_secret") end # Reset the delivery circuit breaker and re-enable the webhook. # @param id [String] Webhook UUID. # @return [Hash] def reset_circuit(id) - @http.put("/webhooks/#{id}/reset_circuit") + http.put("/webhooks/#{id}/reset_circuit") end # List delivery attempts for a webhook. @@ -116,14 +119,14 @@ def event_callbacks(id, status: nil, topic: nil, payment_id: nil, since: nil, until_time: nil, sort: nil, page: nil, per_page: nil) query = build_query(status: status, topic: topic, payment_id: payment_id, since: since, until: until_time, sort: sort, page: page, per_page: per_page) - @http.get_list("/webhooks/#{id}/event_callbacks#{query}") + http.get_list("/webhooks/#{id}/event_callbacks#{query}") end # Delete a webhook. Returns HTTP 204. # @param id [String] Webhook UUID. # @return [nil] def delete(id) - @http.delete("/webhooks/#{id}") + http.delete("/webhooks/#{id}") end end end diff --git a/lib/rail0/signing.rb b/lib/rail0/signing.rb index 89aa2ae..62b6ffe 100644 --- a/lib/rail0/signing.rb +++ b/lib/rail0/signing.rb @@ -3,10 +3,14 @@ require "json" begin + original_verbose = $VERBOSE + $VERBOSE = nil require "eth" -rescue LoadError - raise LoadError, +rescue LoadError => e + raise e, "Rail0::Signing requires the 'eth' gem. Add `gem 'eth', '~> 0.5'` to your Gemfile." +ensure + $VERBOSE = original_verbose end module Rail0 @@ -36,6 +40,10 @@ module Signing # # @return [String] "0x" + r (32 bytes) + s (32 bytes) + v (1 byte), 132 chars total. def to_hex + unless r.start_with?("0x") && s.start_with?("0x") + raise ArgumentError, "r and s must be 0x-prefixed hex strings" + end + "0x#{r[2..]}#{s[2..]}#{v.to_s(16).rjust(2, '0')}" end end @@ -64,10 +72,6 @@ def initialize(**) keyword_init: true ) - # ================================================================ - # EIP-712 type strings - # ================================================================ - DOMAIN_TYPE = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" TRANSFER_TYPE = "TransferWithAuthorization(address from,address to,uint256 value," \ "uint256 validAfter,uint256 validBefore,bytes32 nonce)" @@ -81,10 +85,6 @@ def initialize(**) private_constant :DOMAIN_TYPE, :TRANSFER_TYPE, :RECEIVE_TYPE, :DOMAIN_TYPEHASH, :TRANSFER_TYPEHASH, :RECEIVE_TYPEHASH - # ================================================================ - # ABI encoding helpers - # ================================================================ - def self.hex_to_bytes(hex) h = hex.start_with?("0x") ? hex[2..] : hex [h].pack("H*") @@ -94,7 +94,6 @@ def self.abi_address(address) "\x00" * 12 + hex_to_bytes(address) end - # Pack an arbitrary-precision non-negative integer into 32 big-endian bytes. def self.uint256_to_bytes32(value) hex = Integer(value).to_s(16).rjust(64, "0") [hex].pack("H*") @@ -106,10 +105,6 @@ def self.bytes_to_hex(bytes) private_class_method :hex_to_bytes, :abi_address, :uint256_to_bytes32, :bytes_to_hex - # ================================================================ - # EIP-712 digest construction - # ================================================================ - def self.hash_domain(domain) Eth::Util.keccak256( DOMAIN_TYPEHASH + @@ -143,20 +138,14 @@ def self.build_digest(domain, from:, to:, value:, valid_after:, valid_before:, n private_class_method :hash_domain, :hash_struct, :build_digest - # ================================================================ - # Internal sign helper - # ================================================================ - def self.do_sign(private_key, domain, from:, to:, value:, valid_after:, valid_before:, nonce:, typehash: TRANSFER_TYPEHASH) key_hex = private_key.start_with?("0x") ? private_key[2..] : private_key key = Eth::Key.new(priv: key_hex) digest = build_digest(domain, from: from, to: to, value: value, valid_after: valid_after, valid_before: valid_before, nonce: nonce, typehash: typehash) - # eth 0.5.17+: Eth::Key#sign(blob) — pass the digest directly; the gem does not re-hash it. sig = key.sign(digest) sig_bytes = [sig].pack("H*") - # Ethereum compact signature layout: r (32 bytes) | s (32 bytes) | v (1 byte, 27 or 28). Eip3009Signature.new( v: sig_bytes.getbyte(64), r: bytes_to_hex(sig_bytes[0, 32]), @@ -166,10 +155,6 @@ def self.do_sign(private_key, domain, from:, to:, value:, valid_after:, valid_be private_class_method :do_sign - # ================================================================ - # Public API - # ================================================================ - # Build and sign the EIP-1559 (type-2) transaction described by a prepare # step's +unsigned_transaction+ and return the signed raw transaction as a # 0x-prefixed hex string, ready for the matching submit call. @@ -234,8 +219,6 @@ def self.sign_payload(private_key, signing_payload) verifying_contract: d[:verifyingContract] ) - # Use ReceiveWithAuthorization typehash when primaryType indicates a - # receiveWithAuthorization call (e.g. refund). Default: TransferWithAuthorization. th = signing_payload[:primaryType] == "ReceiveWithAuthorization" ? RECEIVE_TYPEHASH : TRANSFER_TYPEHASH do_sign( @@ -316,15 +299,7 @@ def self.sign_authorize(params) # @param params [SignPaymentParams] # @return [Eip3009Signature] def self.sign_charge(params) - do_sign( - params.private_key, params.token_domain, - from: params.payment[:payer], - to: params.contract_address, - value: params.payment[:amount].to_i, - valid_after: 0, - valid_before: params.payment[:authorization_expiry], - nonce: params.nonce - ) + sign_authorize(params) end end end diff --git a/lib/rail0/stablecoins.rb b/lib/rail0/stablecoins.rb index 89b8343..2ccc26c 100644 --- a/lib/rail0/stablecoins.rb +++ b/lib/rail0/stablecoins.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Rail0 # Stablecoin addresses and capabilities for supported EVM networks. # @@ -96,10 +98,7 @@ def self.chain_info(chain) # @param chain [String] # @return [Array] def self.eip3009_tokens(chain) - c = REGISTRY[chain] or return [] - c.tokens.each_with_object([]) do |(symbol, info), arr| - arr << StablecoinToken.new(symbol: symbol, address: info.address, decimals: info.decimals) if info.eip3009 - end + tokens_supporting(chain, :eip3009) end # Returns all tokens on a chain that support EIP-2612 (permit). @@ -107,10 +106,16 @@ def self.eip3009_tokens(chain) # @param chain [String] # @return [Array] def self.eip2612_tokens(chain) + tokens_supporting(chain, :eip2612) + end + + def self.tokens_supporting(chain, capability) c = REGISTRY[chain] or return [] c.tokens.each_with_object([]) do |(symbol, info), arr| - arr << StablecoinToken.new(symbol: symbol, address: info.address, decimals: info.decimals) if info.eip2612 + arr << StablecoinToken.new(symbol: symbol, address: info.address, decimals: info.decimals) if info.public_send(capability) end end + + private_class_method :tokens_supporting end end diff --git a/lib/rail0/version.rb b/lib/rail0/version.rb index 7d227ba..ce912cd 100644 --- a/lib/rail0/version.rb +++ b/lib/rail0/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Rail0 - VERSION = "1.1.0" + VERSION = "1.2.0" end diff --git a/rail0.gemspec b/rail0.gemspec index 5327ae8..9de2dec 100644 --- a/rail0.gemspec +++ b/rail0.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.authors = ["RAIL0"] spec.license = "MIT" - spec.required_ruby_version = ">= 2.6" + spec.required_ruby_version = ">= 3.0" spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE"] spec.require_paths = ["lib"] diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 1fee52f..04b3a3c 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -509,10 +509,12 @@ def stub_patch(path, body, status: 200) expect(entries.first.status).to eq(200) end - it "DEBUG_LOGGER writes to stdout" do + it "DefaultLogger writes to stdout" do stub_get("/payments/#{PAYMENT_ID}", PAYMENT_DETAIL) - logged = Rail0::Client.new(base_url: BASE_URL, logger: Rail0::DEBUG_LOGGER) - expect { logged.payments.get(PAYMENT_ID) }.to output(/\[rail0\]/).to_stdout + expect do + logged = Rail0::Client.new(base_url: BASE_URL, logger: Rail0::DefaultLogger.new) + logged.payments.get(PAYMENT_ID) + end.to output(/\[rail0\]/).to_stdout end end diff --git a/spec/default_logger_spec.rb b/spec/default_logger_spec.rb new file mode 100644 index 0000000..e711c02 --- /dev/null +++ b/spec/default_logger_spec.rb @@ -0,0 +1,115 @@ +require "stringio" + +RSpec.describe Rail0::DefaultLogger do + def entry(**overrides) + Rail0::LogEntry.new( + method: "GET", url: "https://api.rail0.xyz/health", duration_ms: 12.3, + status: 200, **overrides + ) + end + + it "is a Logger subclass" do + expect(described_class.ancestors).to include(Logger) + end + + it "defaults to logging on $stdout" do + expect { described_class.new.call(entry) }.to output(/\[rail0\]/).to_stdout + end + + it "accepts any Logger.new argument, e.g. an explicit IO" do + io = StringIO.new + described_class.new(io).call(entry) + expect(io.string).to include("[rail0]") + end + + it "logs a successful attempt at :debug" do + io = StringIO.new + logger = described_class.new(io, level: Logger::DEBUG) + logger.call(entry) + expect(io.string).to include("DEBUG") + expect(io.string).to include("GET") + expect(io.string).to include("200") + end + + it "logs a failed attempt at :error" do + io = StringIO.new + logger = described_class.new(io, level: Logger::ERROR) + logger.call(entry(status: nil, error: RuntimeError.new("boom"))) + expect(io.string).to include("ERROR") + expect(io.string).to include("boom") + end + + it "suppresses successful attempts when the level is raised above :debug" do + io = StringIO.new + logger = described_class.new(io, level: Logger::ERROR) + logger.call(entry) + expect(io.string).to be_empty + end + + it "includes the retry marker when will_retry is set" do + io = StringIO.new + described_class.new(io).call(entry(attempt: 1, will_retry: true, error: RuntimeError.new("timeout"))) + expect(io.string).to include("[attempt 1, retrying]") + end + + it "omits the attempt bracket for a plain single, non-retried attempt" do + io = StringIO.new + described_class.new(io).call(entry(attempt: 1, will_retry: false)) + expect(io.string).not_to include("[attempt") + end + + it "shows the attempt number (without a retrying suffix) once a later attempt succeeds" do + io = StringIO.new + described_class.new(io).call(entry(attempt: 2)) + expect(io.string).to include("[attempt 2]") + expect(io.string).not_to include("retrying") + end +end + +RSpec.describe "Rail0::DEFAULT_LOGGER" do + it "is a frozen DefaultLogger instance" do + expect(Rail0::DEFAULT_LOGGER).to be_a(Rail0::DefaultLogger) + expect(Rail0::DEFAULT_LOGGER).to be_frozen + end + + it "can still log despite being frozen" do + # DEFAULT_LOGGER is bound to the $stdout object at library-load time, not + # per-call, so RSpec's output().to_stdout (which swaps the $stdout global + # per-example) can't intercept it -- it would actually print to the real + # terminal. Reopen that same IO object's file descriptor to /dev/null for + # the duration of the call instead, then restore it. + entry = Rail0::LogEntry.new(method: "GET", url: "https://api.rail0.xyz/health", duration_ms: 1.0, status: 200) + original_stdout = $stdout.dup + $stdout.reopen(File::NULL, "w") + begin + expect { Rail0::DEFAULT_LOGGER.call(entry) }.not_to raise_error + ensure + $stdout.reopen(original_stdout) + original_stdout.close + end + end + + it "rejects reconfiguration since it is shared and frozen" do + expect { Rail0::DEFAULT_LOGGER.level = Logger::WARN }.to raise_error(FrozenError) + end +end + +RSpec.describe Rail0::NullLogger do + it "silently discards whatever it's called with" do + expect(described_class.new.call(Rail0::LogEntry.new(method: "GET"))).to be_nil + end +end + +RSpec.describe "Rail0::NULL_LOGGER" do + it "is a frozen, shared NullLogger instance" do + expect(Rail0::NULL_LOGGER).to be_a(Rail0::NullLogger) + expect(Rail0::NULL_LOGGER).to be_frozen + end + + it "is reused by every HttpClient built without an explicit logger" do + a = Rail0::HttpClient.new(base_url: "https://api.rail0.xyz") + b = Rail0::HttpClient.new(base_url: "https://api.rail0.xyz") + expect(a.logger).to equal(Rail0::NULL_LOGGER) + expect(a.logger).to equal(b.logger) + end +end diff --git a/spec/stablecoins_spec.rb b/spec/stablecoins_spec.rb index fd4ed80..64231e2 100644 --- a/spec/stablecoins_spec.rb +++ b/spec/stablecoins_spec.rb @@ -50,4 +50,16 @@ ) end end + + describe "StablecoinInfo#bridged" do + it "is true for a known bridge-wrapped token" do + info = Rail0::Stablecoins.chain_info("base").tokens["USDbC"] + expect(info.bridged).to be true + end + + it "is falsy for a non-bridged token" do + info = Rail0::Stablecoins.chain_info("base").tokens["USDC"] + expect(info.bridged).to be_falsy + end + end end From 404de9725f87c5c40a3dd57a586facacf5820409 Mon Sep 17 00:00:00 2001 From: mkcosta Date: Tue, 28 Jul 2026 14:56:59 +0200 Subject: [PATCH 2/5] Mike - added core-api gap analysis --- docs/gap_analysis.md | 118 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/gap_analysis.md diff --git a/docs/gap_analysis.md b/docs/gap_analysis.md new file mode 100644 index 0000000..7076779 --- /dev/null +++ b/docs/gap_analysis.md @@ -0,0 +1,118 @@ +# Gap Analysis: Integrating `rail0-ruby` into core-api's Payment Gateway Framework + +## Context + +core-api's `PaymentSetting`/`Payment::*` framework was designed against card/redirect +gateways (Stripe, Adyen, Braintree): one HTTP call → a synchronous, gateway-final result → +translate status → drive an AASM transaction. RAIL0 is a settlement protocol with multiple +independent signers and asynchronous on-chain confirmation. This document maps where the +existing framework transfers directly onto RAIL0, and where it genuinely doesn't fit. + +This is an analysis, not an implementation plan — no code has been written. Findings are +based on direct reads of both `rail0-ruby` (`Rail0::Client`, `Rail0::Signing`, +`lib/rail0/resources/*`) and core-api (`PaymentSetting` and its Stripe/Adyen subclasses, +`Payment::Client`/`Session`/`Wallet`/`Payload`/`EventHandler` base classes, `db/schema.rb`, +`Gemfile`). + +## Where the framework aligns cleanly + +| Item | Info | | +|---|---|---| +| STI `PaymentSetting` subclass, secrets in `credentials`/config in `options` | Direct fit — `private_key`, `base_url`, `webhook_shared_secret` as credentials; `chain_id`/`token_contract`/`token_decimals` as options, same shape as `PaymentSettingExternal`. | ✅ | +| `ERROR_CLASSES` consumed by inherited `rescue_and_log` | Direct fit — `Rail0::ApiError` is a single, well-formed exception class (`status`/`error`/`message`), simpler than Stripe/Adyen's SDK-specific hierarchies. | ✅ | +| `PaymentSetting::TYPES` registration | Confirmed by direct code read: referenced in exactly one other place app-wide (`PaymentSession`'s `alias_method` loop) — low-risk, no hidden fan-out. | ✅ | +| Per-gateway HTTP client behind an `api_client` method | Direct fit — `Rail0::Client.new(base_url:, ...)`, same shape as Adyen's real per-instance client (closer analog than Stripe's global-mutation pattern). | ✅ | +| Async/indeterminate settlement | Already supported by existing primitives, not new: `PaymentTransaction`'s `after_commit` succeed/fail cascade fires regardless of timing, and `PaymentSession` already has a generic `_refresh` trigger built for exactly "check again later" — just unused by any gateway today. | ✅ | +| Spec tooling | `Rail0::Request` uses plain `Net::HTTP` — WebMock intercepts it natively, no custom stub-helper needed (simpler than Adyen's `stub_adyen!`). | ✅ | +| Private gem dependency | Precedented via Commerce Layer's own `axerve_client`/`klarna_client`/`satispay_client` gems behind a private registry source block; `rail0` would follow once published. | ✅ | + +## Where it genuinely diverges + +| Item | Info | | +|---|---|---| +| Payment lifecycle shape | Every existing gateway assumes the merchant can unilaterally authorize/capture. RAIL0 requires the **payer** to sign an EIP-3009 payload from their own wallet first — a step core-api doesn't control the timing of. No existing gateway has this shape; Adyen's `require_action` (3DS) is the closest repurposable state, not a built-for-this pattern. | ❌ | +| "Wallets" mean two unrelated things | core-api's `PaymentWallet`/`Payment::Wallet::*` is a saved *customer* instrument (vaultable card/SetupIntent). RAIL0's `.wallets` resource (confirmed via `lib/rail0/resources/wallets.rb`) is *merchant payout-wallet* management — no per-customer concept at all. Forcing one onto the other would misrepresent the data model. | ❌ | +| Auth model | Every existing gateway uses a static API key. RAIL0's JWT-gated endpoints need a SIWE login producing an expiring token — and `Rail0::Client` freezes itself at construction (confirmed via `lib/rail0/client.rb`), so a JWT-bearing client must be a second, separately-refreshed instance. Genuinely new plumbing. | ❌ | +| Versioning machinery doesn't apply | `Payment::Payload::Base.factory`'s version-resolution and `GATEWAY_VERSIONS` exist for Stripe's dated API / Adyen's checkout version. RAIL0 has neither — using that machinery anyway would add indirection with no payoff. | ❌ | +| Currency model mismatch | Fiat `amount_cents`/`currency_code` vs. RAIL0's token base units/`token_decimals`. Conversion is mechanical; no existing FX story for currency ≠ stablecoin peg. | ❌ | +| No on-chain address concept in the data model | The payer's wallet address has no home today; `client_data` (already used for `gift_card_code`-style params) is the natural candidate but nothing validates it yet. | ❌ | +| Credentials risk class | Every other gateway's secret is a revocable, scope-limited API key in an unencrypted jsonb column. A RAIL0 `private_key` is irrevocable on-chain custody — a leak means direct, unrecoverable fund loss. Storing it the same way "because that's convention" changes the risk calculus materially. | ❌ | +| Webhook signature scheme unconfirmed | core-api has a precedented HMAC pattern (`PaymentSettingExternal`) that likely fits, but RAIL0's actual delivery header/encoding hasn't been verified against a real gateway. Compounded by RAIL0's one-webhook-per-topic model needing a secret *per topic*, not one global secret (see recommended approach, below). | ❌ | +| Void/Release asymmetry | RAIL0 distinguishes pre-capture `void` from remainder-returning `release`; core-api only has one `PaymentVoid` concept. Matters only if partial-capture-then-cancel becomes a real requirement. | ❌ | + +## Recommended approach for the async merchant-signing gap + +The major gap (payment lifecycle shape, above) is best addressed by making RAIL0's own +webhooks the **primary driver** of state transitions — reusing the framework's existing +`GatewayWebhook` concern + `Payment::EventHandler::Base.factory` dispatch pattern (the same +mechanism Stripe/Adyen already use) — rather than relying on the `_refresh` polling trigger +as the main mechanism. `_refresh` remains valuable as a reconciliation fallback (in case a +webhook is missed or delayed), matching how production Stripe/Adyen integrations already +combine both, but it shouldn't be the *primary* path for something this multi-staged. + +The reasoning: RAIL0 stacks two async dependencies — the payer signing, then the merchant +signing — on top of on-chain confirmation latency. Polling alone only tells core-api "check +again later"; it doesn't solve the actual gap, which is *reacting the instant the payer signs* +so the merchant-side prepare→sign→submit step fires with minimal delay, instead of waiting +for the next poll interval to notice. A `payments.signed` webhook is exactly that reactive +trigger. + +RAIL0 webhook topic → core-api action mapping: + +| RAIL0 topic | core-api action | +|---|---| +| `payments.signed` | Trigger the merchant-side signing step: `prepare` → `Rail0::Signing.sign_transaction` → `submit` for `authorize`/`charge`. This is the reactive replacement for polling on the payer's out-of-band step. | +| `payments.authorized` / `payments.charged` | Settle `PaymentAuthorization` (`transaction.succeed!`). | +| `payments.captured` | Settle `PaymentCapture`. | +| `payments.voided` | Settle `PaymentVoid`. | +| `payments.refunded` | Settle `PaymentRefund`. | +| `payments.failed` | `transaction.fail!`. | +| `payments.disputed` / `payments.dispute_closed` | Optional for a first pass; hook exists in the framework (`Payment::EventHandler`) but no existing gateway's dispute handling was found to model against directly. | + +**Structural wrinkle this surfaces**: RAIL0's webhook model is *one webhook per topic* +(`client.webhooks.create(name:, callback_url:, topic:)` takes a single topic, unlike Stripe's +one endpoint subscribing to a list of event types via `enabled_events`). Supporting the +mapping above means registering **multiple separate webhook subscriptions** per +`PaymentSettingRail0` (one per topic above), each returning its **own** `shared_secret`. The +credentials need to store a secret per topic (e.g. a small hash keyed by topic name), not one +global secret — signature verification must look up the right secret per inbound webhook. + +Without this webhook-driven mechanism, the only alternative is pure polling, which doesn't +resolve the reactive-timing half of the gap — it only tells core-api "check again later," +not "act now." + +## Open decisions needing a follow-up conversation + +| Item | Info | | +|---|---|---| +| Merchant payout-wallet management scope | Is it even wanted, and if so, as a standalone adapter rather than through `PaymentWallet`/`vaultable?`. | ❌ | +| Payer on-chain address plumbing | Where it lives (`client_data` vs. a new column/concern) and who owns validation. | ❌ | +| Currency/stablecoin-peg mismatch | What happens when a market's fiat currency doesn't match the configured stablecoin — hard error, or real multi-currency support? | ❌ | +| SIWE login key vs. signing key | Whether the SIWE-login private key and the on-chain transaction-signing key are meant to be the same wallet in every deployment, or separate credentials. | ❌ | +| `credentials` vs. `options` split | Non-secret config (`chain_id`/`token_contract`/etc.) — minor, but precedent exists both ways in the current codebase. | ❌ | +| Private-key custody | Whether unencrypted `credentials` storage is acceptable even short-term, or this needs a KMS/vault-backed signing design before real funds are at risk. | ❌ | +| Webhook signature format | The actual header/encoding RAIL0 uses for webhook delivery signatures — needs confirming against the real gateway, not assumed from another gateway's shape. | ❌ | +| Gem distribution | `rail0` has no private-registry presence yet; a `path:` dependency only works on machines with `rail0-ruby` checked out as a sibling directory — not CI-safe or shareable until published. | ❌ | + +## Verifying this analysis + +Since this is an analysis, not a build, "verification" means confirming the assumptions +above hold before anyone commits to implementation. + +| Item | Info | | +|---|---|---| +| Ruby/gem compatibility | `bundle install` with a local `path:` entry for `rail0-ruby` plus `eth`/`siwe-rb` against core-api's pinned Ruby (3.3.4) — not yet run; no conflict expected since `rail0-ruby` requires >= 3.0. | ❌ | +| SIWE/JWT shape | Attempt `Rail0::Client#auth.login` from a core-api console against a real/sandbox rail0-gateway instance and inspect the actual `expires_at`/token shape — not yet confirmed. | ❌ | +| Webhook signature format | Confirm directly against the gateway before treating `GatewayWebhook`'s HMAC pattern as a fit rather than a guess. | ❌ | +| Wallets mismatch sign-off | Explicit confirmation needed before any implementation plan is written, since it affects the shape of the first model built. | ❌ | +| Payer-address plumbing sign-off | Same — affects the first model built. | ❌ | + +## Files referenced during this analysis + +- `/Users/mikecosta/Sites/rail0-ruby/lib/rail0/client.rb`, `resources/wallets.rb`, `signing.rb`, `request.rb` +- `/Users/mikecosta/Sites/core-api/app/models/payment_setting.rb`, `payment_setting_stripe.rb`, `payment_setting_adyen.rb` +- `/Users/mikecosta/Sites/core-api/app/models/payment/client.rb`, `session/base.rb`, `wallet/base.rb`, `wallet/stripe.rb`, `payload/base.rb`, `event_handler/base.rb` +- `/Users/mikecosta/Sites/core-api/app/models/payment_session.rb`, `payment_wallet.rb` +- `/Users/mikecosta/Sites/core-api/app/models/concerns/gateway_webhook.rb` +- `/Users/mikecosta/Sites/core-api/db/schema.rb` (`payment_settings` table) +- `/Users/mikecosta/Sites/core-api/Gemfile` From cb787074f067455bfba1a32e28e917c7143bc3c6 Mon Sep 17 00:00:00 2001 From: mkcosta Date: Tue, 28 Jul 2026 15:19:35 +0200 Subject: [PATCH 3/5] Mike - ignore ticket description --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8ba2dcc..fcaeab0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor/ Gemfile.lock .rspec_status +TICKET.md From 0afb9db06faad7c0ccc8eae916608c80b18b29e7 Mon Sep 17 00:00:00 2001 From: PIERLUIGI VITI Date: Tue, 28 Jul 2026 16:15:17 +0200 Subject: [PATCH 4/5] fix(gemspec): declare the logger dependency so the SDK loads on Ruby 4 Found while trying to run this branch's suite: lib/rail0/default_logger.rb requires "logger" at load time, but nothing declares it. `logger` left Ruby's default gems in 4.0, so under bundler on a Ruby this gemspec claims to support (required_ruby_version ">= 3.0", no upper bound) requiring the SDK fails outright: LoadError: cannot load such file -- logger ./lib/rail0/default_logger.rb:3 Declared as a runtime dependency rather than added to the Gemfile's development group, because it is the library that needs it, not the tests. With it, the suite runs: 107 examples, 0 failures. Included in this branch because without it the merge cannot be verified at all; drop it if you would rather pin the Ruby version instead. Co-Authored-By: Claude Opus 5 --- Gemfile.lock | 3 +++ rail0.gemspec | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index b65fba0..23a0cbf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: rail0 (1.2.0) + logger (~> 1.6) GEM remote: https://rubygems.org/ @@ -43,6 +44,7 @@ GEM http-2 (>= 1.1.3) keccak (1.3.3) konstructor (1.0.2) + logger (1.7.0) mini_portile2 (2.8.9) openssl (3.3.3) pkg-config (1.6.5) @@ -109,6 +111,7 @@ CHECKSUMS httpx (1.7.8) sha256=6d769465ed608287a272ba0e4700fc22cee6f0335d80bd5c2effaf7fb7bd2a3a keccak (1.3.3) sha256=970dcb1e78b8c3129ba2baff8a5baa5cebf391136db1f4018e8ccc9130794557 konstructor (1.0.2) sha256=fd6ac9eb1dadc1e520e06042aa2ef0122d6a070e06cde86db5a54c0c7bfe2e31 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289 openssl (3.3.3) sha256=d46902138f2987c13122fab826030a11c2bb9b8a16394215cbfc5062c5e2d335 pkg-config (1.6.5) sha256=33f9f81c5322983d22b439b8b672f27777b406fea23bfec74ff14bbeb42ec733 diff --git a/rail0.gemspec b/rail0.gemspec index 9de2dec..ad58364 100644 --- a/rail0.gemspec +++ b/rail0.gemspec @@ -14,6 +14,13 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.0" + # `logger` left the default gems in Ruby 4.0, and lib/rail0/default_logger.rb requires + # it at load time — so without this declaration the SDK cannot be loaded at all under + # bundler on a Ruby this gemspec says it supports. Declared as a runtime dependency + # rather than pinned in the Gemfile because it is the library that needs it, not the + # test setup. + spec.add_dependency "logger", "~> 1.6" + spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE"] spec.require_paths = ["lib"] From 8ca4f977ace26f7b244020c3a1b8fcbe18bd75a0 Mon Sep 17 00:00:00 2001 From: PIERLUIGI VITI Date: Tue, 28 Jul 2026 16:21:08 +0200 Subject: [PATCH 5/5] style: apply the branch's comment convention to the merged-in files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the cleanup this branch made repo-wide — no inline implementation comments, no section dividers, class/module docs and YARD @param/@return kept — to the files that came in from main, which still carried the gateway repo's heavier commenting style. Dropped: the family dividers inside ERROR_HINTS, the rationale paragraphs on ApiError/#hint and the analytics methods, the notes above request.rb's error_code and error_message (this branch had already stripped the originals there), the require note in lib/rail0.rb, and the gemspec paragraph — reduced to the one line that says why the dependency exists. Kept where it earns its place: what each class is for, and every @param/@return. analytics.rb also picks up the conventions the other resources gained here — `require "query"`, `attr_reader :http` with `freeze`, and calling `http` rather than `@http` — so the new resource reads like its siblings rather than like an import. The reasoning those comments carried is not lost: it is in PR #4's description, where the one resolution that could silently revert the fix is spelled out. 107 examples, 0 failures. Co-Authored-By: Claude Opus 5 --- lib/rail0.rb | 1 - lib/rail0/api_error.rb | 16 +++------ lib/rail0/error_hints.rb | 20 +++-------- lib/rail0/request.rb | 7 ---- lib/rail0/resources/analytics.rb | 61 ++++++++++++-------------------- rail0.gemspec | 6 +--- 6 files changed, 33 insertions(+), 78 deletions(-) diff --git a/lib/rail0.rb b/lib/rail0.rb index e67c3fa..92b9121 100644 --- a/lib/rail0.rb +++ b/lib/rail0.rb @@ -6,7 +6,6 @@ end require "rail0/version" -# Before api_error: ApiError#hint calls Rail0.describe_error. require "rail0/error_hints" require "rail0/api_error" require "rail0/default_logger" diff --git a/lib/rail0/api_error.rb b/lib/rail0/api_error.rb index d924898..0001d93 100644 --- a/lib/rail0/api_error.rb +++ b/lib/rail0/api_error.rb @@ -1,13 +1,9 @@ # frozen_string_literal: true module Rail0 - # Raised for non-2xx responses from the RAIL0 gateway. - # - # The gateway answers every error with a code/title/detail triple; this mirrors it. - # Show `detail` to a user and `title` as a heading: both come from the gateway's error - # catalogue, so the same condition always reads the same way whichever endpoint - # surfaced it. `hint` is this SDK's own advice — a supplement, present only for codes - # worth adding a next step to. + # Raised for non-2xx responses from the RAIL0 gateway, mirroring the code/title/detail + # triple the gateway answers with. `detail` is written to be shown to a user; `hint` is + # this SDK's own advice, present only for codes worth adding a next step to. class ApiError < StandardError # @!attribute [r] status # @return [Integer] HTTP status code (e.g. 404, 409, 422). @@ -23,7 +19,7 @@ class ApiError < StandardError # @param status [Integer] # @param error [String] - # @param message [String] the detail; kept as the positional argument it has always been + # @param message [String] The detail; kept positional for compatibility. # @param title [String, nil] def initialize(status, error, message, title: nil) super(message) @@ -34,9 +30,7 @@ def initialize(status, error, message, title: nil) freeze end - # This SDK's actionable next step for the error, or nil when the code isn't one it - # knows. A SUPPLEMENT to #detail, which the gateway always sends. - # + # This SDK's actionable next step for the error, or nil when it has none. # @return [String, nil] def hint Rail0.describe_error(error) diff --git a/lib/rail0/error_hints.rb b/lib/rail0/error_hints.rb index 382734c..0a9a656 100644 --- a/lib/rail0/error_hints.rb +++ b/lib/rail0/error_hints.rb @@ -1,13 +1,8 @@ module Rail0 - # Actionable next steps per error code, so a rejected request can explain what to do - # rather than surfacing a bare code. The shared source with the Go SDK - # (rail0.DescribeError), the TS SDK (describeError), the CLI's hints and the admin's - # locked-action reasons — keep the four in step. - # - # These SUPPLEMENT the gateway's own `detail`, which is always present. A code belongs - # here only when there is a next step the gateway cannot know. + # Actionable next steps per error code, supplementing the gateway's own `detail`. + # Shared source with the Go SDK (rail0.DescribeError), the TS SDK (describeError) and + # the CLI's hints — keep the four in step. ERROR_HINTS = { - # payment-state guards (HTTP 422) "amount_exceeds_capturable" => "amount is above the capturable balance — check capturableAmount on the payment", "amount_exceeds_refundable" => "amount is above the refundable balance — check refundableAmount on the payment", "not_capturable" => "the payment must be 'authorized' or 'partially_captured' to capture", @@ -23,29 +18,24 @@ module Rail0 "nothing_to_dispute" => "a dispute needs a merchant-held (refundable) balance", "transaction_not_overwritable" => "a transaction for this operation is already in flight — wait for it to settle", "signer_mismatch" => "the signing key doesn't match the payment's payer/payee", - # in-flight payments on a superseded deployment "config_hash_mismatch" => "the payment record and its on-chain deployment disagree — the payment cannot be operated as recorded", "payment_not_on_chain" => "the contract has no record of this payment — its opening transaction may never have confirmed", "unsupported_contract_version" => "the payment's RAIL0 deployment is newer or older than this gateway supports — upgrade the gateway", - # token-level reverts: raised by the ERC-20 / EIP-3009 token, not by RAIL0 "insufficient_token_balance" => "the paying wallet does not hold enough of the token — top it up and retry", "invalid_token_signature" => "the EIP-3009 authorization did not recover to the paying wallet — wrong key, chain, token or amount", "authorization_already_used" => "that EIP-3009 authorization was already spent or cancelled — each is single-use, create a fresh payment", "authorization_not_yet_valid" => "the authorization's validAfter is still in the future", "token_account_blocked" => "the token issuer has blocklisted one of the wallets in this transfer", "token_paused" => "the token contract is paused by its issuer — no transfer can settle right now", - # broadcast rejections: the node refused the transaction, it never reached the chain "insufficient_gas_funds" => "the sending wallet cannot cover gas — fund it with the chain's native token", "nonce_too_low" => "a transaction with that nonce is already on-chain — re-prepare the operation", "replacement_underpriced" => "another transaction with that nonce is pending and this one does not pay enough to replace it", "gas_price_too_low" => "the fee is below what the node accepts — re-prepare to pick up current fees", "already_known" => "the node already has this exact transaction — wait for it to confirm rather than resending", "rpc_unavailable" => "no configured RPC endpoint answered — the transaction was not submitted", - # authorization: which party the session is missing "not_the_payee" => "only the payment's payee can do this — sign in with the merchant's wallet", "not_the_payer" => "only the payment's payer can do this — sign in with the buyer's wallet", "not_a_participant" => "only the payer and the payee can see or act on a payment", - # contract reverts (surfaced as contract_revert, or on a failed transaction) "not_payee" => "only the merchant (payee) may do this", "not_payer" => "only the buyer (payer) may do this", "not_payer_or_payee" => "only the payer or the payee may do this", @@ -56,9 +46,7 @@ module Rail0 "payment_already_exists" => "a payment with this id already exists on-chain" }.freeze - # An actionable hint for a rail0 error code (a gateway guard, a token revert, a - # broadcast rejection or a contract revert), or nil when the code is unknown. - # + # An actionable hint for a rail0 error code, or nil when the code is unknown. # @param code [String, nil] # @return [String, nil] def self.describe_error(code) diff --git a/lib/rail0/request.rb b/lib/rail0/request.rb index 50f6e64..8283e87 100644 --- a/lib/rail0/request.rb +++ b/lib/rail0/request.rb @@ -118,17 +118,10 @@ def parse_error_body(response) {} end - # The specific condition. `code` is what a caller branches on; `status` carries the - # wider family (e.g. "forbidden" where the code is "not_the_payee"), so it is only a - # fallback — as is Grape's `error` sub-code. Reading `status` first, as this used to, - # handed callers the family and hid the specific condition. def error_code(body) body[:code] || body[:error] || body[:status] end - # The sentence to show a user. Prefer `detail` (written for exactly that), then - # `message` (its older name), then Grape's `error`, and finally the bare HTTP status - # when the body carried no text at all. def error_message(body, response = nil) body[:detail] || body[:message] || body[:error] || (response && "HTTP #{response.code}") end diff --git a/lib/rail0/resources/analytics.rb b/lib/rail0/resources/analytics.rb index a8af5b5..c13fc45 100644 --- a/lib/rail0/resources/analytics.rb +++ b/lib/rail0/resources/analytics.rb @@ -1,71 +1,56 @@ # frozen_string_literal: true -require_relative "query" +require "query" module Rail0 module Resources - # Account-scoped payment analytics (GET /analytics/*). Requires a session whose - # wallet is attached to an account — a buyer's account-less token is refused with - # `account_required`, since these numbers are the merchant's own. - # - # Every endpoint takes the same filters (mode, status, token, chain_id, from, to) - # and applies them the same way, so the three views answer the same question at - # different resolutions: one total, a series over time, a split by dimension. + # Account-scoped payment analytics (requires JWT — a buyer's account-less token is + # refused with `account_required`). Every endpoint takes the same filters, so the + # three views answer one question at different resolutions: a total, a series over + # time, a split by dimension. Volume is reported only when a query pins both `token` + # and `chain_id`, since amounts in different tokens are different units. class Analytics include Query - # The filters every endpoint accepts, in one place so the three methods cannot - # drift on what they support. FILTERS = %i[mode status token chain_id from to].freeze + attr_reader :http + def initialize(http) @http = http + freeze end # Totals for the account's payments. - # - # Volume is only reported when the query pins BOTH token and chain_id: amounts in - # different tokens (or the same symbol on different chains) are different units, - # and summing them would produce a number that looks meaningful and isn't. - # - # @param mode [String, nil] "authorize" or "charge" - # @param status [String, nil] a payment status, e.g. "captured" - # @param token [String, nil] token address (0x…); with chain_id, unlocks volume - # @param chain_id [Integer, nil] chain id - # @param from [String, nil] ISO-8601 — payments created at/after this time - # @param to [String, nil] ISO-8601 — payments created at/before this time - # @return [Hash] count, disputed_count and (when token+chain_id are set) volume + # @param mode [String, nil] "authorize" or "charge". + # @param status [String, nil] A payment status, e.g. "captured". + # @param token [String, nil] Token address (0x…); with chain_id, unlocks volume. + # @param chain_id [Integer, nil] Chain ID. Pass nil or 0 for all chains. + # @param from [String, nil] ISO-8601 — payments created at/after this time. + # @param to [String, nil] ISO-8601 — payments created at/before this time. + # @return [Hash] count, disputed_count and (with token+chain_id) volume. def summary(**filters) - @http.get("/analytics/summary#{build_query(**only_filters(filters))}") + http.get("/analytics/summary#{build_query(**only_filters(filters))}") end # The account's payment count per time bucket, oldest first. - # - # @param interval [String, nil] "hour", "day", "week" or "month" (default "day") - # @return [Array] one entry per bucket: bucket, count, and volume when - # token+chain_id are set + # @param interval [String, nil] "hour", "day", "week" or "month" (default "day"). + # @return [Array] bucket, count, and volume with token+chain_id. def timeseries(interval: nil, **filters) - query = only_filters(filters).merge(interval: interval) - @http.get("/analytics/timeseries#{build_query(**query)}") + http.get("/analytics/timeseries#{build_query(**only_filters(filters).merge(interval: interval))}") end # The account's payments aggregated by one dimension. - # - # @param by [String] required — "token", "chain", "mode" or "status" - # @return [Array] one row per group. Token and chain rows carry per-token - # volume; mode and status rows are counts only, for the reason in #summary — - # a mode groups payments across tokens, so there is no single unit to add up. + # @param by [String] Required — "token", "chain", "mode" or "status". + # @return [Array] One row per group; token and chain rows carry volume. def breakdown(by:, **filters) raise ArgumentError, "by is required (token, chain, mode or status)" if by.nil? || by.to_s.empty? - query = only_filters(filters).merge(by: by) - @http.get("/analytics/breakdown#{build_query(**query)}") + http.get("/analytics/breakdown#{build_query(**only_filters(filters).merge(by: by))}") end private - # Keep only the known filters, and drop a chain_id of 0 the way the other - # resources do (callers pass 0 for "no filter"). def only_filters(filters) picked = filters.slice(*FILTERS) picked[:chain_id] = nil if picked[:chain_id] == 0 diff --git a/rail0.gemspec b/rail0.gemspec index ad58364..d3ce932 100644 --- a/rail0.gemspec +++ b/rail0.gemspec @@ -14,11 +14,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.0" - # `logger` left the default gems in Ruby 4.0, and lib/rail0/default_logger.rb requires - # it at load time — so without this declaration the SDK cannot be loaded at all under - # bundler on a Ruby this gemspec says it supports. Declared as a runtime dependency - # rather than pinned in the Gemfile because it is the library that needs it, not the - # test setup. + # Not a default gem since Ruby 4.0; required by lib/rail0/default_logger.rb. spec.add_dependency "logger", "~> 1.6" spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE"]