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])