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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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: }
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions lib/rail0/resources/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions lib/rail0/resources/payments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions spec/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down Expand Up @@ -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])
Expand Down
Loading