From 5754313e6022156cdab2d14198ab6b3cdbf47a7d Mon Sep 17 00:00:00 2001 From: PIERLUIGI VITI Date: Tue, 18 Aug 2026 13:10:48 +0200 Subject: [PATCH] feat: close the surface gaps against rail0-go and rail0-ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three calls the other two SDKs have had and this one did not. Each is reachable only through the SDK, so their absence was not an inconvenience — it was work a Ruby integration could not do at all. auth.logout — revokes the token THIS client carries, not every session for the address, so signing out one process leaves the others signed in. It returns the body rather than nil on purpose: the gateway's denylist fails open by design (a store outage must not sign out the whole platform), so `revoked: false` means the token is still usable until it expires, and the caller should treat its copy as compromised rather than assume the session is gone. Ruby was the one SDK where a long-lived process could not hand a session back. payments.dispute_submit_by_hash / close_dispute_submit_by_hash — the payer's counterpart to submit_by_hash. That generic method only covers the single-segment /payments/{id}/{operation}/submitted shape, and `dispute/close` is two segments, which is why these are separate methods rather than another operation argument. Without them a Ruby caller signing with a wallet that broadcasts on its own could open and close disputes with a raw signed transaction but never report one it had already sent. Payer-only, and the payer authenticates account-less via SIWE: a bare hash carries no signature, so the session is what proves who is reporting it. Four specs, and the two that matter assert what would otherwise pass silently: that `revoked: false` is surfaced rather than swallowed, and that close-dispute posts to the two-segment path. Co-Authored-By: Claude Opus 5 --- README.md | 14 +++++++++++++ lib/rail0/resources/auth.rb | 18 +++++++++++++++++ lib/rail0/resources/payments.rb | 26 ++++++++++++++++++++++++ spec/client_spec.rb | 35 +++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+) diff --git a/README.md b/README.md index 7bb6ddf..1fa6b06 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ client.payments.submit_by_hash(rail0_id, "capture", { transaction_hash: "0x…" | `refund_prepare` (phase 1+2) + `refund` | payee | Return captured funds to the payer via EIP-3009 | | `dispute_prepare` + `dispute` | payer | Open a dispute (signal-only) | | `close_dispute_prepare` + `close_dispute` | payer | Close an open dispute | +| `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`, @@ -140,6 +141,12 @@ Two role rules are worth knowing before the first call, because both surface as **payee-only**, while `release` and the prepare steps accept either participant, and `dispute`/`close_dispute` submits are **payer-only**. +A wallet that signs *and broadcasts* in one step (MetaMask) reports the result by hash +instead of handing over a signed transaction: `submit_by_hash` covers the merchant +operations, and the two dispute paths have their own payer-only methods +(`dispute_submit_by_hash`, `close_dispute_submit_by_hash`) because `dispute/close` is two +path segments and does not fit the generic shape. + ```ruby auth = client.auth.login(private_key: "0x…", domain: "api.rail0.xyz") # => { token:, address:, account_id:, name:, expires_at: } @@ -167,8 +174,15 @@ Lower-level building blocks are also available: ```ruby nonce = client.auth.nonce # POST /auth/nonces session = client.auth.verify(message: siwe_msg, signature: sig) # POST /auth +client.auth.logout # POST /auth/logout ``` +`logout` revokes **the token this client carries**, not every session for the address — +signing out one process leaves the others signed in. Read the answer: the gateway's +denylist **fails open** by design (a store outage must not sign out the whole platform), +so `{ revoked: false }` means the token is *still usable* until it expires, and the +caller should treat its own copy as compromised rather than assume the session is gone. + ## Catalog (public) ```ruby diff --git a/lib/rail0/resources/auth.rb b/lib/rail0/resources/auth.rb index 32f4e2a..9ac19c9 100644 --- a/lib/rail0/resources/auth.rb +++ b/lib/rail0/resources/auth.rb @@ -35,6 +35,24 @@ def verify(message:, signature:) http.post("/auth", { message: message, signature: signature }) end + # End the session whose token this client carries. + # + # Per TOKEN, not per address: signing out one process leaves the others signed in. + # Requires the session it revokes, so the client must be holding one — a client + # built without a token gets a 401 rather than a silent no-op. + # + # `revoked` is the OUTCOME, not a formality, and the reason this returns the body + # instead of nil. The gateway's denylist fails open by design — a store outage must + # not sign out the whole platform — so `false` means the token is STILL USABLE until + # its own expiry, and a caller should treat its copy as compromised rather than + # assume the session is gone. rail0-go and rail0-ts have had this; Ruby was the one + # SDK where a long-lived process could not hand a session back. (#19) + # + # @return [Hash] { revoked: true|false } + def logout + http.post("/auth/logout", {}) + end + # Perform the full SIWE authentication flow: # 1. Fetch a nonce # 2. Build an EIP-4361 message via siwe-rb diff --git a/lib/rail0/resources/payments.rb b/lib/rail0/resources/payments.rb index 5fa396c..58ffd7d 100644 --- a/lib/rail0/resources/payments.rb +++ b/lib/rail0/resources/payments.rb @@ -254,6 +254,32 @@ def close_dispute(id, params) http.post("/payments/#{id}/dispute/close", params) end + # The payer's counterpart to {#submit_by_hash}, which covers only the operations + # under /payments/{id}/{operation}/submitted — the two dispute paths are not shaped + # that way (`dispute/close` is two segments), so they need their own methods. Both + # exist in rail0-go and rail0-ts; without them a Ruby caller signing with a wallet + # that broadcasts on its own (MetaMask) could open and close disputes with a raw + # signed transaction, but never report one it had already sent. (#19) + # + # Payer-only, and the payer authenticates account-less via SIWE: a bare hash + # carries no signature, so the session is what proves who is reporting it. + + # Report an already-broadcast dispute transaction by hash (payer only); HTTP 202. + # @param id [String] Payment UUID or rail0_id. + # @param params [Hash] { transaction_hash: "0x…" }. + # @return [Hash] + def dispute_submit_by_hash(id, params) + http.post("/payments/#{id}/dispute/submitted", params) + end + + # Report an already-broadcast close-dispute transaction by hash (payer only); HTTP 202. + # @param id [String] Payment UUID or rail0_id. + # @param params [Hash] { transaction_hash: "0x…" }. + # @return [Hash] + def close_dispute_submit_by_hash(id, params) + http.post("/payments/#{id}/dispute/close/submitted", params) + end + private def prepare_dispute(path, id, reason) diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 2432f8f..fb9f266 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -126,6 +126,23 @@ def stub_patch(path, body, status: 200) end end + describe "auth.logout" do + it "POSTs /auth/logout and returns the revocation outcome" do + stub = stub_post("/auth/logout", { revoked: true }) + result = client.auth.logout + expect(stub).to have_been_requested + expect(result[:revoked]).to be(true) + end + + it "reports revoked: false rather than treating it as success" do + # The gateway's denylist fails open by design — a store outage must not sign out + # the whole platform — so false means the token is STILL USABLE until it expires. + # Swallowing it would tell a caller its session is gone when it is not. + stub_post("/auth/logout", { revoked: false }) + expect(client.auth.logout[:revoked]).to be(false) + end + end + describe "auth.login" do let(:key) { "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" } @@ -394,6 +411,24 @@ def stub_authed_get(token) end end + describe "payments dispute report-by-hash" do + # The payer's counterpart to submit_by_hash, which only covers the single-segment + # /payments/{id}/{operation}/submitted shape — `dispute/close` is two segments, which + # is why these need their own methods rather than an operation argument. + it "reports a broadcast dispute by hash" do + stub = stub_post("/payments/#{PAYMENT_ID}/dispute/submitted", { status: "submitted" }, status: 202) + result = client.payments.dispute_submit_by_hash(PAYMENT_ID, { transaction_hash: "0x#{'ab' * 32}" }) + expect(stub).to have_been_requested + expect(result[:status]).to eq("submitted") + end + + it "reports a broadcast close-dispute by hash on the two-segment path" do + stub = stub_post("/payments/#{PAYMENT_ID}/dispute/close/submitted", { status: "submitted" }, status: 202) + client.payments.close_dispute_submit_by_hash(PAYMENT_ID, { transaction_hash: "0x#{'cd' * 32}" }) + expect(stub).to have_been_requested + end + end + describe "payments.disputes" do it "lists a payment's dispute history" do stub_list("/payments/#{PAYMENT_ID}/disputes?status=open", [DISPUTE])