From 7b22648ee0e63dfbe5fd681336ac00db956cb7b4 Mon Sep 17 00:00:00 2001 From: PIERLUIGI VITI Date: Tue, 25 Aug 2026 19:46:53 +0200 Subject: [PATCH] feat!: absorb the gateway's breaking batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #21. THE ERROR SHAPE (gateway #252). A body is exactly code/title/detail: `status`, `message` and Grape's `error` were DELETED rather than dual-sent, so `body[:code] || body[:error] || body[:status]` and its detail twin were chains whose later terms cannot arrive. Each collapses to the one field the gateway sends; the bare HTTP status stays as the last resort for a body with no text at all. SIWE hints for the four binding failures split out of signer_mismatch (#216), plus sessions_revoked. None names the server's expectation — that endpoint is unauthenticated, so echoing the allow-list or the expected chain id would make each hint a probe. `escrow_stranded` / `escrow_returnable_at` on the payment (#233): the window after a partial capture where neither void nor release can return the remaining escrow. `expired` documented in the README status list, with the part that matters: it is NOT terminal. Release still works from it, so code that treats it as closed leaves the buyer's funds in escrow. Four specs changed rather than deleted. One asserted the OPPOSITE of the new behaviour — that an older gateway's aliases still surfaced — and now pins what actually matters: a body carrying only the deleted keys must yield NO code, because a silent "" would be branched on as if it were a real condition. 166 examples green, rubocop clean. Co-Authored-By: Claude Fable 5 --- README.md | 12 ++++++++---- lib/rail0/error_hints.rb | 10 ++++++++++ lib/rail0/request.rb | 9 +++++++-- lib/rail0/types.rb | 4 ++++ spec/client_spec.rb | 11 ++++++----- spec/errors_spec.rb | 12 +++++++----- 6 files changed, 42 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2849bd6..94a169f 100644 --- a/README.md +++ b/README.md @@ -119,10 +119,14 @@ client.payments.submit_by_hash(rail0_id, "capture", { transaction_hash: "0x…" | `dispute_submit_by_hash` / `close_dispute_submit_by_hash` | payer | Report a dispute tx the wallet already broadcast | **Payment statuses:** `unsigned`, `signed`, `authorized`, `charged`, `captured`, -`partially_captured`, `voided`, `released`, `refunded` — plus `partially_refunded`, -which is no longer produced (a partial refund deliberately leaves the status alone) -but is still a legal value on historical rows, so don't write an exhaustive `case` -that raises on it. +`partially_captured`, `voided`, `released`, `refunded`, `expired` — plus +`partially_refunded`, which is no longer produced (a partial refund deliberately leaves +the status alone) but is still a legal value on historical rows, so don't write an +exhaustive `case` that raises on it. + +`expired` is a never-captured authorization whose window lapsed. It is **not** terminal: +the escrow is still on-chain and `release` still works from it (closing the payment as +`released`), so treating it as closed leaves the buyer's funds where they are. **Transaction statuses:** `pending`, `submitting`, `submitted`, `confirmed`, `failed`. ## Authentication (SIWE) diff --git a/lib/rail0/error_hints.rb b/lib/rail0/error_hints.rb index 354d1d9..4f99fd5 100644 --- a/lib/rail0/error_hints.rb +++ b/lib/rail0/error_hints.rb @@ -20,6 +20,16 @@ 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", + # The SIWE BINDING failures, split out of signer_mismatch so a failed login says + # WHICH part of the proof did not bind (#216). None names the server's own + # expectation: that endpoint is unauthenticated, so echoing the allow-list or the + # expected chain id would turn every hint into a probe. + "siwe_domain_not_allowed" => "sign with the origin the front-end is served from, and have it added to the gateway's SIWE domain allow-list", + "siwe_uri_mismatch" => "the message's uri host must equal its own domain", + "siwe_chain_mismatch" => "use the chain id the client library sends - this login is off-chain and nominal", + "siwe_proof_expired" => "get a fresh nonce and sign again", + # Address-wide, not one token: "sign in again", not "that token is dead". + "sessions_revoked" => "every session issued before this address's revoke-all cutoff is refused - sign in again", "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", diff --git a/lib/rail0/request.rb b/lib/rail0/request.rb index f2980d4..6fe7389 100644 --- a/lib/rail0/request.rb +++ b/lib/rail0/request.rb @@ -172,12 +172,17 @@ def parse_error_body(response) {} end + # The gateway answers exactly code/title/detail, having DELETED the older aliases + # rather than dual-sending them (#252) — `status` (the wider family), `message` + # (equal to detail) and Grape's `error`. The chains that read them could only ever + # find absent keys, so each collapses to the one field there is. The bare HTTP status + # stays as the last resort for a body with no text at all. def error_code(body) - body[:code] || body[:error] || body[:status] + body[:code] end def error_message(body, response = nil) - body[:detail] || body[:message] || body[:error] || (response && "HTTP #{response.code}") + body[:detail] || (response && "HTTP #{response.code}") end def elapsed_ms(start) diff --git a/lib/rail0/types.rb b/lib/rail0/types.rb index 43abb1a..f9c42cc 100644 --- a/lib/rail0/types.rb +++ b/lib/rail0/types.rb @@ -172,6 +172,10 @@ module Types :amount, # String :capturable_amount, # String — Mirrors on-chain capturableAmount (escrow still held); base units. :refundable_amount, # String — Mirrors on-chain refundableAmount (held by payee, still refundable); base units. + # The window after a PARTIAL capture where neither void nor release can return the + # buyer's remaining escrow — the answer to "why did both just refuse?". + :escrow_stranded, # Boolean + :escrow_returnable_at, # String, nil — ISO-8601 end of that window; nil outside it. :config_hash, # String :payer, # String :payee, # String diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 10341cf..03e632d 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -179,7 +179,8 @@ def stub_patch(path, body, status: 200) it "raises Rail0::ApiError when the address is not registered" do stub_post("/auth/nonces", NONCE_RESPONSE, status: 201) - stub_post("/auth", { status: "address_not_registered", message: "Address is not registered." }, status: 403) + stub_post("/auth", { code: "address_not_registered", title: "Address not registered", + detail: "Address is not registered." }, status: 403) expect { client.auth.login(private_key: key, domain: "api.rail0.xyz") } .to raise_error(Rail0::ApiError) do |err| @@ -630,9 +631,9 @@ def stub_authed_get(token) # ── Error handling ───────────────────────────────────────────────────────── describe "error handling" do - it "raises Rail0::ApiError on 422 with status/message" do + it "raises Rail0::ApiError on 422 with code/detail" do stub_get("/payments/#{PAYMENT_ID}", - { status: "payment_not_found", message: "No payment exists for the given id." }, status: 422) + { code: "payment_not_found", detail: "No payment exists for the given id." }, status: 422) expect { client.payments.get(PAYMENT_ID) } .to raise_error(Rail0::ApiError) do |err| expect(err.status).to eq(422) @@ -643,7 +644,7 @@ def stub_authed_get(token) it "raises Rail0::ApiError on a 422 state error" do stub_post("/payments/#{PAYMENT_ID}/capture", - { status: "not_capturable", message: "Payment is not capturable." }, status: 422) + { code: "not_capturable", detail: "Payment is not capturable." }, status: 422) expect { client.payments.capture(PAYMENT_ID, { signed_transaction: "0x02" }) } .to raise_error(Rail0::ApiError) { |err| expect(err.error).to eq("not_capturable") } end @@ -691,7 +692,7 @@ def stub_authed_get(token) attempts = 0 stub_request(:get, "#{BASE_URL}/payments/#{PAYMENT_ID}").to_return do attempts += 1 - { status: 422, body: { status: "payment_not_found", message: "x" }.to_json, headers: json_headers } + { status: 422, body: { code: "payment_not_found", detail: "x" }.to_json, headers: json_headers } end retrying = Rail0::Client.new(base_url: BASE_URL, max_retries: 2, retry_delay: 0) expect { retrying.payments.get(PAYMENT_ID) }.to raise_error(Rail0::ApiError) diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index 5a31d35..fd57d71 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -34,9 +34,11 @@ def stub_error(status, body) 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 + # This used to assert the OPPOSITE: that an older gateway's `status`/`error`/`message` + # still surfaced. Those keys were deleted from the wire rather than dual-sent (#252), + # so what matters now is that a body carrying only them yields nothing pretending to be + # a code — a silent "" would be branched on as if it were a real condition. + it "does not invent a code from the deleted alias fields" do stub_error(422, { status: "invalid_state", error: "not_capturable", message: "no capturable balance" }) error = begin @@ -45,8 +47,8 @@ def stub_error(status, body) e end - expect(error.error).to eq("not_capturable") - expect(error.detail).to eq("no capturable balance") + expect(error.error).to be_nil + expect(error.detail).to eq("HTTP 422") expect(error.title).to be_nil end