Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions lib/rail0/error_hints.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 7 additions & 2 deletions lib/rail0/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions lib/rail0/types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions spec/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions spec/errors_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Loading