Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
vendor/
Gemfile.lock
.rspec_status
TICKET.md
2 changes: 1 addition & 1 deletion .rspec
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
--require spec_helper
--format documentation
--format progress
--color
1 change: 1 addition & 0 deletions .ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.3.4
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,20 @@ When a change in one repo affects the contract, the indexer, or any SDK, flag it
- **Track the gateway's public surface.** Client methods, request/response shapes, and the README mirror the gateway's public API. Operational/admin-only fields the gateway hides must not be exposed by the SDK; request inputs (signatures, signed transactions) stay as inputs.
- **One client, per-resource methods.** Keep new endpoints in the matching resource grouping rather than introducing a parallel client.
- **Validation before commit.** The gem's test suite (and linter, if configured) must pass.

## Commands

- `bundle install` — install deps
- `bundle exec rake` — run the full test suite (default task, wraps rspec)
- `bundle exec rspec spec/client_spec.rb` — run one spec file
- `bundle exec rspec spec/client_spec.rb:42` — run a single example by line
- `ruby gen/generate.rb` — regenerate `lib/rail0/types.rb` from the gateway OpenAPI schema (defaults to `../rail0-gateway/docs/openapi.json`, override with `RAIL0_SCHEMA_PATH`)

## Architecture

- `Rail0::Client` (`lib/rail0/client.rb`) builds one `HttpClient` and hands it to one instance per resource (`auth`, `chains`, `tokens`, `health`, `payment_methods`, `wallets`, `payments`, `disputes`, `webhooks`) under `lib/rail0/resources/`. New endpoints go on the matching resource, never a new client.
- `HttpClient` (`lib/rail0/http_client.rb`) is a thin per-verb facade (`get`/`post`/`put`/`patch`/`delete`) that builds a `Rail0::Request` per call. `Request` (`lib/rail0/request.rb`) is the sole Net::HTTP touchpoint: retries (network errors only, not HTTP error responses), pagination envelope, error mapping, and logging all live there. Resource classes never call Net::HTTP directly.
- On-chain ops follow a two-phase `prepare`/`submit` pattern implemented in `resources/payments.rb`: `<op>_prepare` returns an `unsigned_transaction`, the caller signs it via `Rail0::Signing`, then `<op>` submits the signed tx (HTTP 202, confirms async — poll `payments.get`). Every named wrapper (`authorize`, `capture`, `void`, `release`, `refund`, `dispute`, `close_dispute`) is a thin delegate over the generic `prepare`/`submit`/`submit_by_hash` — add new on-chain ops the same way rather than as a parallel path.
- `Rail0::Signing` (`lib/rail0/signing.rb`) and `siwe-rb` (used by `auth.login`) are lazy-loaded — `require "rail0"` must keep working without the `eth`/`siwe-rb` gems present.
- `lib/rail0/types.rb` is generated, reference-only documentation (Structs) — never hand-edit it; regenerate via `gen/generate.rb`.
- Non-2xx responses raise `Rail0::ApiError` (`status`/`error`/`message`) from `request.rb`; resource methods let it propagate rather than rescuing it.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ source "https://rubygems.org"
gemspec

group :development do
gem "rake", "~> 13.0"
gem "rspec", "~> 3.13"
gem "webmock", "~> 3.23"
gem "eth", "~> 0.5"
Expand Down
5 changes: 3 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
rail0 (1.1.0)
rail0 (1.2.0)

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -84,6 +84,7 @@ PLATFORMS
DEPENDENCIES
eth (~> 0.5)
rail0!
rake (~> 13.0)
rspec (~> 3.13)
siwe-rb (~> 0.2)
webmock (~> 3.23)
Expand Down Expand Up @@ -112,7 +113,7 @@ CHECKSUMS
openssl (3.3.3) sha256=d46902138f2987c13122fab826030a11c2bb9b8a16394215cbfc5062c5e2d335
pkg-config (1.6.5) sha256=33f9f81c5322983d22b439b8b672f27777b406fea23bfec74ff14bbeb42ec733
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
rail0 (1.1.0)
rail0 (1.2.0)
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rbsecp256k1 (6.0.0) sha256=d38f563bfc6bcf3ce20c336a4798cc6990427f291e74c6817360363a72f11669
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ lifecycle plus account, wallet, catalog, and webhook management. It mirrors the

## Requirements

- Ruby ≥ 2.6
- Ruby ≥ 3.0
- For SIWE login and off-chain signing: `eth` (`~> 0.5`) and `siwe-rb` (`~> 0.2`)

The core HTTP client has **no runtime dependencies** (Ruby stdlib only). The `eth`
Expand Down Expand Up @@ -278,8 +278,14 @@ client.payments.sign(rail0_id, { signature: sig.to_hex })
Pass any callable as `logger` to receive a `Rail0::LogEntry` per request attempt.

```ruby
client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER)
# [rail0] GET 200 https://.../payments/0x… 87ms
client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)
# D, [...] DEBUG -- : [rail0] GET 200 https://.../payments/0x… 87ms

# Rail0::DefaultLogger is a Logger subclass, so it takes any Logger.new argument:
client = Rail0::Client.new(
base_url: "https://api.rail0.xyz",
logger: Rail0::DefaultLogger.new("rail0.log", level: Logger::WARN)
)

# Or route into your own logger:
log = Logger.new($stdout)
Expand Down Expand Up @@ -312,7 +318,7 @@ Rail0::Client.new(
timeout: 30, # seconds (default 30)
max_retries: 0, # network-error retries (default 0)
retry_delay: 0.2, # base delay, doubles each attempt
logger: Rail0::DEBUG_LOGGER # optional
logger: Rail0::DEFAULT_LOGGER # optional
)
```

Expand All @@ -325,7 +331,9 @@ gen/generate.rb regenerates lib/rail0/types.rb from the gateway OpenAPI s

lib/rail0/
client.rb Rail0::Client — entry point
http_client.rb internal HTTP client (Net::HTTP, retry, pagination, logging)
http_client.rb thin per-verb facade (get/post/put/patch/delete) over Request
request.rb Rail0::Request — one HTTP call: retry, pagination, error mapping, logging
default_logger.rb Rail0::LogEntry + Rail0::DefaultLogger (Logger subclass) for `logger:`
api_error.rb Rail0::ApiError
signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
stablecoins.rb stablecoin address registry
Expand All @@ -347,7 +355,7 @@ lib/rail0/

```bash
bundle install
bundle exec rspec # run the test suite
bundle exec rake # run the test suite (default task)

# Regenerate lib/rail0/types.rb after a gateway schema change:
# defaults to ../rail0-gateway/docs/openapi.json, or set RAIL0_SCHEMA_PATH.
Expand Down
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec)

task default: :spec
118 changes: 118 additions & 0 deletions docs/gap_analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Gap Analysis: Integrating `rail0-ruby` into core-api's Payment Gateway Framework

## Context

core-api's `PaymentSetting`/`Payment::*` framework was designed against card/redirect
gateways (Stripe, Adyen, Braintree): one HTTP call → a synchronous, gateway-final result →
translate status → drive an AASM transaction. RAIL0 is a settlement protocol with multiple
independent signers and asynchronous on-chain confirmation. This document maps where the
existing framework transfers directly onto RAIL0, and where it genuinely doesn't fit.

This is an analysis, not an implementation plan — no code has been written. Findings are
based on direct reads of both `rail0-ruby` (`Rail0::Client`, `Rail0::Signing`,
`lib/rail0/resources/*`) and core-api (`PaymentSetting` and its Stripe/Adyen subclasses,
`Payment::Client`/`Session`/`Wallet`/`Payload`/`EventHandler` base classes, `db/schema.rb`,
`Gemfile`).

## Where the framework aligns cleanly

| Item | Info | |
|---|---|---|
| STI `PaymentSetting` subclass, secrets in `credentials`/config in `options` | Direct fit — `private_key`, `base_url`, `webhook_shared_secret` as credentials; `chain_id`/`token_contract`/`token_decimals` as options, same shape as `PaymentSettingExternal`. | ✅ |
| `ERROR_CLASSES` consumed by inherited `rescue_and_log` | Direct fit — `Rail0::ApiError` is a single, well-formed exception class (`status`/`error`/`message`), simpler than Stripe/Adyen's SDK-specific hierarchies. | ✅ |
| `PaymentSetting::TYPES` registration | Confirmed by direct code read: referenced in exactly one other place app-wide (`PaymentSession`'s `alias_method` loop) — low-risk, no hidden fan-out. | ✅ |
| Per-gateway HTTP client behind an `api_client` method | Direct fit — `Rail0::Client.new(base_url:, ...)`, same shape as Adyen's real per-instance client (closer analog than Stripe's global-mutation pattern). | ✅ |
| Async/indeterminate settlement | Already supported by existing primitives, not new: `PaymentTransaction`'s `after_commit` succeed/fail cascade fires regardless of timing, and `PaymentSession` already has a generic `_refresh` trigger built for exactly "check again later" — just unused by any gateway today. | ✅ |
| Spec tooling | `Rail0::Request` uses plain `Net::HTTP` — WebMock intercepts it natively, no custom stub-helper needed (simpler than Adyen's `stub_adyen!`). | ✅ |
| Private gem dependency | Precedented via Commerce Layer's own `axerve_client`/`klarna_client`/`satispay_client` gems behind a private registry source block; `rail0` would follow once published. | ✅ |

## Where it genuinely diverges

| Item | Info | |
|---|---|---|
| Payment lifecycle shape | Every existing gateway assumes the merchant can unilaterally authorize/capture. RAIL0 requires the **payer** to sign an EIP-3009 payload from their own wallet first — a step core-api doesn't control the timing of. No existing gateway has this shape; Adyen's `require_action` (3DS) is the closest repurposable state, not a built-for-this pattern. | ❌ |
| "Wallets" mean two unrelated things | core-api's `PaymentWallet`/`Payment::Wallet::*` is a saved *customer* instrument (vaultable card/SetupIntent). RAIL0's `.wallets` resource (confirmed via `lib/rail0/resources/wallets.rb`) is *merchant payout-wallet* management — no per-customer concept at all. Forcing one onto the other would misrepresent the data model. | ❌ |
| Auth model | Every existing gateway uses a static API key. RAIL0's JWT-gated endpoints need a SIWE login producing an expiring token — and `Rail0::Client` freezes itself at construction (confirmed via `lib/rail0/client.rb`), so a JWT-bearing client must be a second, separately-refreshed instance. Genuinely new plumbing. | ❌ |
| Versioning machinery doesn't apply | `Payment::Payload::Base.factory`'s version-resolution and `GATEWAY_VERSIONS` exist for Stripe's dated API / Adyen's checkout version. RAIL0 has neither — using that machinery anyway would add indirection with no payoff. | ❌ |
| Currency model mismatch | Fiat `amount_cents`/`currency_code` vs. RAIL0's token base units/`token_decimals`. Conversion is mechanical; no existing FX story for currency ≠ stablecoin peg. | ❌ |
| No on-chain address concept in the data model | The payer's wallet address has no home today; `client_data` (already used for `gift_card_code`-style params) is the natural candidate but nothing validates it yet. | ❌ |
| Credentials risk class | Every other gateway's secret is a revocable, scope-limited API key in an unencrypted jsonb column. A RAIL0 `private_key` is irrevocable on-chain custody — a leak means direct, unrecoverable fund loss. Storing it the same way "because that's convention" changes the risk calculus materially. | ❌ |
| Webhook signature scheme unconfirmed | core-api has a precedented HMAC pattern (`PaymentSettingExternal`) that likely fits, but RAIL0's actual delivery header/encoding hasn't been verified against a real gateway. Compounded by RAIL0's one-webhook-per-topic model needing a secret *per topic*, not one global secret (see recommended approach, below). | ❌ |
| Void/Release asymmetry | RAIL0 distinguishes pre-capture `void` from remainder-returning `release`; core-api only has one `PaymentVoid` concept. Matters only if partial-capture-then-cancel becomes a real requirement. | ❌ |

## Recommended approach for the async merchant-signing gap

The major gap (payment lifecycle shape, above) is best addressed by making RAIL0's own
webhooks the **primary driver** of state transitions — reusing the framework's existing
`GatewayWebhook` concern + `Payment::EventHandler::Base.factory` dispatch pattern (the same
mechanism Stripe/Adyen already use) — rather than relying on the `_refresh` polling trigger
as the main mechanism. `_refresh` remains valuable as a reconciliation fallback (in case a
webhook is missed or delayed), matching how production Stripe/Adyen integrations already
combine both, but it shouldn't be the *primary* path for something this multi-staged.

The reasoning: RAIL0 stacks two async dependencies — the payer signing, then the merchant
signing — on top of on-chain confirmation latency. Polling alone only tells core-api "check
again later"; it doesn't solve the actual gap, which is *reacting the instant the payer signs*
so the merchant-side prepare→sign→submit step fires with minimal delay, instead of waiting
for the next poll interval to notice. A `payments.signed` webhook is exactly that reactive
trigger.

RAIL0 webhook topic → core-api action mapping:

| RAIL0 topic | core-api action |
|---|---|
| `payments.signed` | Trigger the merchant-side signing step: `prepare` → `Rail0::Signing.sign_transaction` → `submit` for `authorize`/`charge`. This is the reactive replacement for polling on the payer's out-of-band step. |
| `payments.authorized` / `payments.charged` | Settle `PaymentAuthorization` (`transaction.succeed!`). |
| `payments.captured` | Settle `PaymentCapture`. |
| `payments.voided` | Settle `PaymentVoid`. |
| `payments.refunded` | Settle `PaymentRefund`. |
| `payments.failed` | `transaction.fail!`. |
| `payments.disputed` / `payments.dispute_closed` | Optional for a first pass; hook exists in the framework (`Payment::EventHandler`) but no existing gateway's dispute handling was found to model against directly. |

**Structural wrinkle this surfaces**: RAIL0's webhook model is *one webhook per topic*
(`client.webhooks.create(name:, callback_url:, topic:)` takes a single topic, unlike Stripe's
one endpoint subscribing to a list of event types via `enabled_events`). Supporting the
mapping above means registering **multiple separate webhook subscriptions** per
`PaymentSettingRail0` (one per topic above), each returning its **own** `shared_secret`. The
credentials need to store a secret per topic (e.g. a small hash keyed by topic name), not one
global secret — signature verification must look up the right secret per inbound webhook.

Without this webhook-driven mechanism, the only alternative is pure polling, which doesn't
resolve the reactive-timing half of the gap — it only tells core-api "check again later,"
not "act now."

## Open decisions needing a follow-up conversation

| Item | Info | |
|---|---|---|
| Merchant payout-wallet management scope | Is it even wanted, and if so, as a standalone adapter rather than through `PaymentWallet`/`vaultable?`. | ❌ |
| Payer on-chain address plumbing | Where it lives (`client_data` vs. a new column/concern) and who owns validation. | ❌ |
| Currency/stablecoin-peg mismatch | What happens when a market's fiat currency doesn't match the configured stablecoin — hard error, or real multi-currency support? | ❌ |
| SIWE login key vs. signing key | Whether the SIWE-login private key and the on-chain transaction-signing key are meant to be the same wallet in every deployment, or separate credentials. | ❌ |
| `credentials` vs. `options` split | Non-secret config (`chain_id`/`token_contract`/etc.) — minor, but precedent exists both ways in the current codebase. | ❌ |
| Private-key custody | Whether unencrypted `credentials` storage is acceptable even short-term, or this needs a KMS/vault-backed signing design before real funds are at risk. | ❌ |
| Webhook signature format | The actual header/encoding RAIL0 uses for webhook delivery signatures — needs confirming against the real gateway, not assumed from another gateway's shape. | ❌ |
| Gem distribution | `rail0` has no private-registry presence yet; a `path:` dependency only works on machines with `rail0-ruby` checked out as a sibling directory — not CI-safe or shareable until published. | ❌ |

## Verifying this analysis

Since this is an analysis, not a build, "verification" means confirming the assumptions
above hold before anyone commits to implementation.

| Item | Info | |
|---|---|---|
| Ruby/gem compatibility | `bundle install` with a local `path:` entry for `rail0-ruby` plus `eth`/`siwe-rb` against core-api's pinned Ruby (3.3.4) — not yet run; no conflict expected since `rail0-ruby` requires >= 3.0. | ❌ |
| SIWE/JWT shape | Attempt `Rail0::Client#auth.login` from a core-api console against a real/sandbox rail0-gateway instance and inspect the actual `expires_at`/token shape — not yet confirmed. | ❌ |
| Webhook signature format | Confirm directly against the gateway before treating `GatewayWebhook`'s HMAC pattern as a fit rather than a guess. | ❌ |
| Wallets mismatch sign-off | Explicit confirmation needed before any implementation plan is written, since it affects the shape of the first model built. | ❌ |
| Payer-address plumbing sign-off | Same — affects the first model built. | ❌ |

## Files referenced during this analysis

- `/Users/mikecosta/Sites/rail0-ruby/lib/rail0/client.rb`, `resources/wallets.rb`, `signing.rb`, `request.rb`
- `/Users/mikecosta/Sites/core-api/app/models/payment_setting.rb`, `payment_setting_stripe.rb`, `payment_setting_adyen.rb`
- `/Users/mikecosta/Sites/core-api/app/models/payment/client.rb`, `session/base.rb`, `wallet/base.rb`, `wallet/stripe.rb`, `payload/base.rb`, `event_handler/base.rb`
- `/Users/mikecosta/Sites/core-api/app/models/payment_session.rb`, `payment_wallet.rb`
- `/Users/mikecosta/Sites/core-api/app/models/concerns/gateway_webhook.rb`
- `/Users/mikecosta/Sites/core-api/db/schema.rb` (`payment_settings` table)
- `/Users/mikecosta/Sites/core-api/Gemfile`
2 changes: 1 addition & 1 deletion examples/01_authorize_and_capture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
ACCOUNT_ID = ENV.fetch("RAIL0_ACCOUNT_ID") # merchant account UUID
BUYER = ENV.fetch("PAYER_ADDRESS") # payer's 0x address

client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER)
client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)

# ── Step 0 — discover the merchant's accepted payment methods (public) ────────
wallets = client.payment_methods.list(account_id: ACCOUNT_ID)
Expand Down
2 changes: 1 addition & 1 deletion examples/02_charge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
ACCOUNT_ID = ENV.fetch("RAIL0_ACCOUNT_ID")
BUYER = ENV.fetch("PAYER_ADDRESS")

client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER)
client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)

# ── Step 0 — discover a merchant USDC wallet (public) ─────────────────────────
wallet = client.payment_methods.list(account_id: ACCOUNT_ID)
Expand Down
2 changes: 1 addition & 1 deletion examples/03_refund.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
PAYEE_KEY = ENV.fetch("PAYEE_PRIVATE_KEY")
PAYMENT_ID = ENV.fetch("RAIL0_PAYMENT_ID") # UUID or 0x-prefixed rail0_id

client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER)
client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)

# Authenticate the payee (refund is a payee/JWT operation).
auth = client.auth.login(private_key: PAYEE_KEY, domain: "api.rail0.xyz")
Expand Down
2 changes: 1 addition & 1 deletion examples/04_dispute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
PAYER_KEY = ENV.fetch("PAYER_PRIVATE_KEY") # the payer signs dispute txs
PAYMENT_ID = ENV.fetch("RAIL0_PAYMENT_ID") # UUID or 0x-prefixed rail0_id

client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEBUG_LOGGER)
client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)

# ── Open a dispute ────────────────────────────────────────────────────────────
# reason is an optional bytes32 code; omit it to default to zero server-side.
Expand Down
Loading