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