From bf13c4f2866549b5e5dbecf9f1b5c6682c0de045 Mon Sep 17 00:00:00 2001 From: PIERLUIGI VITI Date: Tue, 28 Jul 2026 15:41:45 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20align=20with=20the=20gateway=20?= =?UTF-8?q?=E2=80=94=20code/title/detail=20errors,=20hints,=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This SDK had been parked while go and cli moved on, so it was three things behind. ERRORS. The gateway answers every error with a code/title/detail triple, where `code` is the specific condition and `detail` a sentence written to be shown to a user. ApiError read `status` — the wider FAMILY — as the code, so a caller branching on it got "invalid_state" where the gateway said "amount_exceeds_refundable", and printed what, for state guards, used to be the bare code. It now prefers `code` (falling back to `error` then `status`, so an older gateway still yields its most specific value) and carries `title` and `detail`; `detail` is also the exception's message. HINTS. Rail0.describe_error and ApiError#hint bring the SDK level with rail0.DescribeError (go) and describeError (ts) — the same table, including the codes the gateway learned to distinguish: token-level reverts (the most common failures in practice, and none of them raised by RAIL0), broadcast rejections that used to arrive as one opaque rpc_error, the three 403s that used to share "forbidden", and the in-flight-version guards. Documented as a SUPPLEMENT to the gateway's detail, not a replacement. ANALYTICS. The one resource go and ts had and this did not — summary, timeseries, breakdown, with the filters they share. Volume is only reported when a query pins both token and chain_id, and the resource says why: amounts in different tokens are different units, so a sum across them looks meaningful and isn't. types.rb regenerated from the gateway schema (Error gains code/title/detail, Transaction gains error_title/error_detail, Token gains active). 90 examples, 0 failures — 7 new, covering code-over-status precedence, the fallback to the older field names, the bare-HTTP-status floor, and the hint table's coverage of the two families a user cannot diagnose from the code alone. Co-Authored-By: Claude Opus 5 --- README.md | 65 ++++++++++++++++++-- lib/rail0.rb | 2 + lib/rail0/api_error.rb | 33 ++++++++-- lib/rail0/client.rb | 4 ++ lib/rail0/error_hints.rb | 69 +++++++++++++++++++++ lib/rail0/http_client.rb | 16 +++-- lib/rail0/resources/analytics.rb | 76 +++++++++++++++++++++++ lib/rail0/types.rb | 42 ++++++++++--- spec/errors_spec.rb | 102 +++++++++++++++++++++++++++++++ 9 files changed, 385 insertions(+), 24 deletions(-) create mode 100644 lib/rail0/error_hints.rb create mode 100644 lib/rail0/resources/analytics.rb create mode 100644 spec/errors_spec.rb diff --git a/README.md b/README.md index de3114b..ba22f45 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,33 @@ client.webhooks.event_callbacks(id, status: "failed") client.webhooks.delete(id) # 204 ``` +## Analytics (JWT) + +Account-scoped: the numbers are the merchant's own, so a buyer's account-less token is +refused with `account_required`. + +```ruby +client.analytics.summary(status: "captured") +# => { count: 42, disputed_count: 1 } + +# Volume needs 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. +client.analytics.summary(token: usdc, chain_id: 84_532) +# => { count: 12, disputed_count: 0, volume: { token: "0x…", chain_id: 84532, total: "12000000" } } + +client.analytics.timeseries(interval: "day", from: "2026-07-01T00:00:00Z") +# => [{ bucket: "2026-07-01", count: 3 }, …] oldest first + +client.analytics.breakdown(by: "chain") +# => [{ chain_id: 84532, count: 9 }, …] by: token | chain | mode | status +``` + +All three take the same filters — `mode`, `status`, `token`, `chain_id`, `from`, `to` — so +the same question can be asked at three resolutions: one total, a series over time, a split +by dimension. Token and chain rows carry per-token volume; mode and status rows are counts +only, for the reason above. + ## Signing helpers (`Rail0::Signing`) Requires the `eth` gem. No private key ever leaves your process. @@ -291,18 +318,44 @@ client = Rail0::Client.new( ## Error handling -Non-2xx responses raise `Rail0::ApiError`: +Non-2xx responses raise `Rail0::ApiError`, carrying the gateway's code/title/detail +triple: ```ruby begin client.payments.capture(rail0_id, { signed_transaction: raw }) rescue Rail0::ApiError => e - e.status # 422 - e.error # "not_capturable" (machine-readable code) - e.message # human-readable description + e.status # 422 + e.error # "insufficient_token_balance" — branch on this, and only this + e.title # "Not enough balance" — a heading + e.detail # a sentence you can show a user verbatim (also e.message) + e.hint # this SDK's own extra advice, nil when it has none end ``` +**`error` is the only field to branch on.** It is the specific condition, read from the +gateway's `code` and falling back to the older `error` sub-code and then to `status` (the +wider family), so an older gateway still yields the most specific value it sent. + +`title` and `detail` come from the gateway's error catalogue, so the same condition always +reads the same way whichever endpoint surfaced it; `detail` is written to be shown to a +user as-is. + +`hint` (or `Rail0.describe_error(code)`) is this SDK's own advice — a *supplement* to +`detail`, present only for codes with a next step worth adding. The codes span four +families, and the last two are the ones most requests actually hit, neither raised by +RAIL0 itself: + +| Family | Examples | +| --- | --- | +| Request & state guards | `not_capturable`, `amount_exceeds_refundable`, `not_the_payee` | +| RAIL0 custom errors | `not_payee`, `already_captured`, `refund_expired` | +| Token reverts | `insufficient_token_balance`, `invalid_token_signature`, `authorization_already_used` | +| Broadcast rejections | `insufficient_gas_funds`, `nonce_too_low`, `replacement_underpriced` | + +A failed transaction carries the same triple as `error_code`, `error_title` and +`error_detail`, whether it reverted on-chain or was refused before broadcast. + ## Configuration ```ruby @@ -326,7 +379,8 @@ 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) - api_error.rb Rail0::ApiError + api_error.rb Rail0::ApiError (code/title/detail + #hint) + error_hints.rb Rail0.describe_error — per-code next steps, shared with the other SDKs signing.rb EIP-3009 + EIP-1559 signing (requires 'eth') stablecoins.rb stablecoin address registry types.rb generated Struct docs of the gateway schema (reference only) @@ -340,6 +394,7 @@ lib/rail0/ wallets.rb account-scoped wallet management (JWT) payments.rb payment lifecycle + disputes webhooks.rb webhook subscription management (JWT) + analytics.rb account-scoped payment analytics (JWT) query.rb shared query-string helper ``` diff --git a/lib/rail0.rb b/lib/rail0.rb index 3ad2375..da4f84d 100644 --- a/lib/rail0.rb +++ b/lib/rail0.rb @@ -1,4 +1,6 @@ require_relative "rail0/version" +# Before api_error: ApiError#hint calls Rail0.describe_error. +require_relative "rail0/error_hints" require_relative "rail0/api_error" require_relative "rail0/http_client" require_relative "rail0/client" diff --git a/lib/rail0/api_error.rb b/lib/rail0/api_error.rb index cccc89b..ee4c198 100644 --- a/lib/rail0/api_error.rb +++ b/lib/rail0/api_error.rb @@ -1,19 +1,44 @@ module Rail0 - # Raised for non-2xx responses from the RAIL0 API. + # 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. 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"). + # @return [String] The specific condition, and the only field to branch on — e.g. + # "not_capturable", "insufficient_token_balance", "insufficient_gas_funds". attr_reader :error + # @return [String, nil] Short label for the failure, e.g. "Not enough balance". + attr_reader :title + + # @return [String, nil] One or two sentences fit to show a user verbatim. Also this + # exception's message. + attr_reader :detail + # @param status [Integer] # @param error [String] - # @param message [String] - def initialize(status, error, message) + # @param message [String] the detail; kept as the positional argument it has always been + # @param title [String, nil] + def initialize(status, error, message, title: nil) super(message) @status = status @error = error + @title = title + @detail = message + 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. + # + # @return [String, nil] + def hint + Rail0.describe_error(error) end end end diff --git a/lib/rail0/client.rb b/lib/rail0/client.rb index 6f88f66..c94d437 100644 --- a/lib/rail0/client.rb +++ b/lib/rail0/client.rb @@ -8,6 +8,7 @@ require_relative "resources/payments" require_relative "resources/disputes" require_relative "resources/webhooks" +require_relative "resources/analytics" module Rail0 # Entry point for the RAIL0 SDK. @@ -38,6 +39,8 @@ class Client attr_reader :disputes # @return [Resources::Webhooks] Webhook subscription management (JWT). attr_reader :webhooks + # @return [Resources::Analytics] Account-scoped payment analytics (JWT). + attr_reader :analytics # @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). @@ -59,6 +62,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) + @analytics = Resources::Analytics.new(http) end end end diff --git a/lib/rail0/error_hints.rb b/lib/rail0/error_hints.rb new file mode 100644 index 0000000..382734c --- /dev/null +++ b/lib/rail0/error_hints.rb @@ -0,0 +1,69 @@ +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. + 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", + "not_voidable" => "void is only allowed while 'authorized' with nothing captured — use release for the remainder after a capture", + "not_releasable" => "release opens only after authorizationExpiry", + "not_refundable" => "nothing is refundable — the payment must be charged/captured and within the refund window", + "not_signable" => "the payment must be 'unsigned' to sign", + "already_signed" => "the payer signature is already stored — the payee can act now", + "no_signature" => "the payer has not signed yet", + "wrong_mode" => "this operation doesn't match the payment's mode (authorize vs charge)", + "already_disputed" => "a dispute is already open — close it first", + "not_disputed" => "there is no open dispute to close", + "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", + "refund_expired" => "the refund window has closed (refundExpiry passed) — refund/dispute is no longer possible", + "authorization_not_expired" => "release opens only after authorizationExpiry — wait until it passes", + "already_captured" => "already (partially) captured — use release for the remainder, not void", + "token_not_accepted" => "the token isn't in this deployment's allowlist", + "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. + # + # @param code [String, nil] + # @return [String, nil] + def self.describe_error(code) + return nil if code.nil? || code.to_s.empty? + + ERROR_HINTS[code.to_s] + end +end diff --git a/lib/rail0/http_client.rb b/lib/rail0/http_client.rb index a39f696..41e4c5c 100644 --- a/lib/rail0/http_client.rb +++ b/lib/rail0/http_client.rb @@ -105,7 +105,8 @@ def request(method, path, body = nil, paginated: false, headers: {}) 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)) + api_error = ApiError.new(response.code.to_i, error_code(error_body), + error_message(error_body, response), title: error_body[:title]) log(LogEntry.new( method: method.to_s.upcase, url: url, duration_ms: duration_ms, request_body: body, status: response.code.to_i, @@ -174,14 +175,19 @@ def parse_error_body(response) # 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. + # 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[:status] || body[:code] || body[:error] + body[:code] || body[:error] || body[:status] end - # Human-readable error message. Prefer `message`, fall back to Grape's `error`, - # and finally the bare HTTP status when the body carried neither. + # 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[:message] || body[:error] || (response && "HTTP #{response.code}") + body[:detail] || body[:message] || body[:error] || (response && "HTTP #{response.code}") end def log(entry) diff --git a/lib/rail0/resources/analytics.rb b/lib/rail0/resources/analytics.rb new file mode 100644 index 0000000..a8af5b5 --- /dev/null +++ b/lib/rail0/resources/analytics.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative "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. + 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 + + def initialize(http) + @http = http + 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 + def summary(**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 + def timeseries(interval: nil, **filters) + query = only_filters(filters).merge(interval: interval) + @http.get("/analytics/timeseries#{build_query(**query)}") + 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. + 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)}") + 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 + picked + end + end + end +end diff --git a/lib/rail0/types.rb b/lib/rail0/types.rb index 1c758d7..787e850 100644 --- a/lib/rail0/types.rb +++ b/lib/rail0/types.rb @@ -46,19 +46,33 @@ module Types keyword_init: true ) - # Generic error envelope. `status` is a machine-readable code; additional context fields (e.g. - # `message`, `resource`, `param`, `errors`, `chain_id`) may also be present. + # Error envelope. Every error this API returns carries `code` (stable and machine-readable — the + # only field to branch on), `title` (a short label) and `detail` (one or two sentences fit to show a + # user verbatim). The wording comes from the gateway's error catalogue, so the same condition always + # reads the same way wherever it surfaces. `status` and `message` are the pre-code/title/detail + # names, derived from the same entry and kept for existing clients: `status` carries the family a + # client used to switch on (e.g. `forbidden`, `invalid_state`) where `code` is narrower, and + # `message` equals `detail`. Context fields may also be present (`resource`, `param`, `chain_id`, + # `token`, `payee`, `errors`). ApiErrorBody = Struct.new( - :status, # String - :message, # String + :code, # String + :title, # String + :detail, # String + :status, # String — Legacy: the error family, or the code itself when there is no wider family. + :message, # String — Legacy alias of `detail`. keyword_init: true ) + # Gateway liveness/readiness. Only the database gates the HTTP code (200 healthy, 503 when the DB is + # unreachable); Sidekiq is reported but never flips the code, since the API still serves synchronous + # requests when workers are down. `status` is the global signal: `ok` (all good), `degraded` (DB ok + # but Sidekiq not ok), `error` (DB down — the only 503). Health = Struct.new( :status, # String :api_version, # String :contract_version, # String :db, # String + :sidekiq, # Hash — Worker fleet health (does not gate liveness). :active_chains, # Integer :active_contracts, # Integer :timestamp, # String @@ -72,12 +86,15 @@ module Types keyword_init: true ) - # Issued after a successful SIWE verification. + # Issued after a successful SIWE verification. SIWE alone proves control of the address, so a token + # is issued even when the address is not registered to any account; in that case `account_id` and + # `name` are null (an account-less session, e.g. a buyer). Clients that require an account must + # treat a null `account_id` as not-allowed. Session = Struct.new( :token, # String — JWT bearer token. :address, # String — Resolved wallet address. - :account_id, # String - :name, # String — The account's human-readable name. + :account_id, # String — The account owning the signed-in wallet, or null for an account-less (e.g. buyer) session. + :name, # String — The account's human-readable name, or null for an account-less session. :expires_at, # String keyword_init: true ) @@ -92,12 +109,15 @@ module Types keyword_init: true ) - # Public accepted-token view. + # Public accepted-token view. The listing is not implicitly active-only (a payment references its + # token address forever, so a retired token must stay resolvable), so `active` tells a usable token + # from a retired one. Token = Struct.new( :chain_id, # Integer :symbol, # String :address, # String :decimals, # Integer + :active, # Boolean — False for a retired token: still resolvable for historical payments, but not usable for a new one. keyword_init: true ) @@ -187,8 +207,10 @@ module Types :payment_id, # String :operation, # String :status, # String - :error_code, # Decoded on-chain failure code (null unless status is "failed"): the RAIL0 custom error in snake_case (e.g. "not_payee"), or "revert" when the selector is unknown. The raw revert bytes are not exposed. - :error_message, # Human-readable form of error_code (e.g. "NotPayee"); null unless status is "failed". + :error_code, # Decoded failure code, null unless `status` is "failed". Same catalogue as an error body's `code`: a RAIL0 custom error (`not_payee`), a token-level revert (`insufficient_token_balance`, `invalid_token_signature`, `authorization_already_used`), a Solidity panic, or a rejection that stopped the broadcast before the chain saw it (`insufficient_gas_funds`, `nonce_too_low`). + :error_title, # String — Short label for `error_code`; null unless failed. + :error_detail, # String — Sentence explaining the failure; null unless failed. Carries the chain's own words when the revert was not one the gateway recognises. + :error_message, # Legacy alias of `error_detail`. :unsigned_transaction, :transaction_hash, :amount, diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb new file mode 100644 index 0000000..3950538 --- /dev/null +++ b/spec/errors_spec.rb @@ -0,0 +1,102 @@ +RSpec.describe "error surface" do + let(:client) { Rail0::Client.new(base_url: BASE_URL) } + + def stub_error(status, body) + stub_request(:get, "#{BASE_URL}/payments/0xabc") + .to_return(status: status, body: body.to_json, headers: { "Content-Type" => "application/json" }) + end + + # The gateway answers code/title/detail. `code` is the specific condition and + # `status` the wider family it sits in, so reading `status` as the code — which this + # SDK used to do — hands a caller "invalid_state" where the gateway said + # "amount_exceeds_refundable", and hides exactly what went wrong. + it "prefers code over the status family, and carries title and detail" do + stub_error(422, { + code: "amount_exceeds_refundable", + title: "Amount above the refundable balance", + detail: "The amount is higher than the balance the merchant still holds for this payment.", + status: "invalid_state", + message: "The amount is higher than the balance the merchant still holds for this payment." + }) + + error = begin + client.payments.get("0xabc") + rescue Rail0::ApiError => e + e + end + + expect(error.error).to eq("amount_exceeds_refundable") + expect(error.title).to eq("Amount above the refundable balance") + expect(error.detail).to start_with("The amount is higher") + expect(error.message).to eq(error.detail) + expect(error.status).to eq(422) + end + + # An older gateway sends neither code nor detail: the specific condition arrives in + # `error` and the text in `message`. Both must still surface. + it "falls back to the pre-code/title/detail field names" do + stub_error(422, { status: "invalid_state", error: "not_capturable", message: "no capturable balance" }) + + error = begin + client.payments.get("0xabc") + rescue Rail0::ApiError => e + e + end + + expect(error.error).to eq("not_capturable") + expect(error.detail).to eq("no capturable balance") + expect(error.title).to be_nil + end + + it "falls back to the bare HTTP status when the body carries no text" do + stub_error(500, { code: "db_error" }) + + error = begin + client.payments.get("0xabc") + rescue Rail0::ApiError => e + e + end + + expect(error.error).to eq("db_error") + expect(error.message).to eq("HTTP 500") + end + + describe "hints" do + # A supplement to the gateway's detail, not a replacement — so it exists only for + # codes with a next step worth adding, and never invents one. + it "returns an actionable hint for a known code" do + expect(Rail0.describe_error("insufficient_gas_funds")).to include("native token") + expect(Rail0.describe_error("not_the_payee")).to include("merchant") + end + + it "returns nil for an unknown or empty code" do + expect(Rail0.describe_error("brand_new_condition")).to be_nil + expect(Rail0.describe_error(nil)).to be_nil + expect(Rail0.describe_error("")).to be_nil + end + + it "exposes the same hint through the raised error" do + stub_error(422, { code: "authorization_already_used", detail: "spent" }) + + error = begin + client.payments.get("0xabc") + rescue Rail0::ApiError => e + e + end + + expect(error.hint).to eq(Rail0.describe_error("authorization_already_used")) + expect(error.hint).to include("single-use") + end + + # The three SDKs and the CLI share this table; a code the gateway can return with no + # entry here is not a bug, but the families that DO have entries must stay complete — + # these are the ones a user hits most and cannot diagnose from the code alone. + it "covers the token-level and broadcast families" do + %w[insufficient_token_balance invalid_token_signature authorization_already_used + insufficient_gas_funds nonce_too_low replacement_underpriced already_known + config_hash_mismatch unsupported_contract_version].each do |code| + expect(Rail0.describe_error(code)).not_to be_nil, "#{code} has no hint" + end + end + end +end From 0afb9db06faad7c0ccc8eae916608c80b18b29e7 Mon Sep 17 00:00:00 2001 From: PIERLUIGI VITI Date: Tue, 28 Jul 2026 16:15:17 +0200 Subject: [PATCH 2/3] 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 3/3] 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"]