x402-rack
Rack middleware for payment-gated HTTP using BSV (Bitcoin SV) and the x402 protocol.
The middleware is a pure dispatcher — it matches routes, issues payment challenges, and routes proofs to pluggable gateway backends for settlement. It has no blockchain knowledge and holds no keys.
Installation
# Gemfile
gem "x402-rack"
bundle install
Quick start
Minimal: relay to an existing wallet
The simplest setup — no keys, no local wallet, no ARC configuration. Point operator_wallet_url at an existing @bsv/simple server wallet and PayGateway is auto-enabled:
X402.configure do |c|
c.domain = "api.example.com"
c.operator_wallet_url = "https://my-wallet.example.com/api/server-wallet"
c.protect method: :GET, path: "/api/data", amount_sats: 100
end
use X402::Middleware
ARC defaults to ARCADE. The server holds no private keys — it derives unique payment addresses from the remote wallet's public key, broadcasts via ARC, then relays the settlement to the wallet for UTXO tracking.
With a local wallet (enables BRC-121)
Set up a server wallet:
bundle exec rake x402:wallet:setup
Rails apps get the task automatically via the Railtie. Non-Rails Rack apps need one line in their
Rakefile:load "x402/tasks/x402.rake"
Then add the middleware:
X402.configure do |c|
c.domain = "api.example.com"
c.wallet = X402::Wallet.load
c.protect method: :GET, path: "/api/data", amount_sats: 100
end
use X402::Middleware
Both PayGateway and BRC121Gateway are auto-enabled. ARC defaults to ARCADE.
With a remote wallet for all gateways
RemoteWallet implements the same duck-typed interface as the local wallet, so all gateways work with it — PayGateway, BRC-121, and BRC-105:
X402.configure do |c|
c.domain = "api.example.com"
c.wallet = X402::RemoteWallet.new(url: "https://my-wallet.example.com/api/server-wallet")
c.protect method: :GET, path: "/api/data", amount_sats: 100
end
use X402::Middleware
With wallet: set and no explicit enable calls, gateways are auto-wired:
PayGateway— works with or without a wallet (the only gateway that does). Without a wallet, it usesoperator_wallet_urlfor keyless relay. With a wallet, it usesinternalize_actionfor settlement — converging with BRC-121's approach.BRC121Gateway— always enabled whenwallet:is set. Requires a wallet (local or remote).BRC105Gateway— opt-in viaconfig.enable :brc105_gateway. Requires a wallet.
Clients can pay using whichever protocol they support; the middleware dispatches on proof header.
Gateways
| Gateway | Wallet required? | Setup required | Status |
|---|---|---|---|
PayGateway (Coinbase v2) |
No — works with operator_wallet_url alone |
Auto-enabled | Stable |
BRC121Gateway (BRC-121) |
Yes (local or remote) | Auto-enabled when wallet: set |
Stable |
BRC105Gateway (BRC-105) |
Yes (local or remote) | config.enable :brc105_gateway |
Transitional |
ProofGateway (merkleworks) |
No | config.enable :proof_gateway |
Experimental |
PayGateway is the only gateway that works without a wallet — it derives payment addresses from the operator's public key and relays settlement after broadcast. BRC-121 and BRC-105 require a wallet for internalize_action. ARC defaults to ARCADE when no explicit arc_url is configured.
Advanced configuration
Explicit gateway enablement
X402.configure do |config|
config.domain = "api.example.com"
config.wallet = X402::Wallet.load
config.enable :pay_gateway # explicit, same as default
config.enable :brc105_gateway # opt in to BRC-105
config.enable :proof_gateway, nonce_provider: my_np # opt in to ProofGateway
end
If any config.enable calls are made, the auto-enable is skipped — you get exactly what you asked for.
Per-gateway overrides
config.enable :pay_gateway, arc_client: my_custom_arc # override ARC broadcaster
config.enable :brc121_gateway, wallet: alt_wallet # override wallet
Manual gateway construction (power-user escape hatch)
X402.configure do |config|
config.domain = "api.example.com"
config.gateways = [
X402::BSV::PayGateway.new(
arc_client: BSV::Network::ARC.default,
wallet: my_wallet
),
X402::BSV::BRC121Gateway.new(wallet: my_wallet)
]
config.protect method: :GET, path: "/api/expensive", amount_sats: 100
end
When config.gateways is set, any enable calls and the auto-enable are ignored.
Wallet options
X402::Wallet.load resolves the signing key in this order:
SERVER_WIFenvironment variable (wins if set)~/.bsv-wallet/wallet.key(orBSV_WALLET_DIR/wallet.key) — written byrake x402:wallet:setup- Raises
ConfigurationErrorsuggesting the setup task
The Rake task never overwrites an existing wallet.key. Pass FORCE=1 to replace an existing wallet (destructive).
Backwards-compat alternatives still work: config.server_wif = ENV["SERVER_WIF"] or config.payee_locking_script_hex = "76a914...88ac".
How It Works
- Client requests a protected resource
- Middleware returns
402 Payment Requiredwith challenge headers from each configured gateway - Client constructs a BSV payment transaction and retries with proof
- Middleware dispatches the proof to the matching gateway for settlement
- Gateway verifies and settles — middleware serves or rejects
Four BSV settlement schemes are supported:
- BSV-pay (Coinbase v2 headers) — server broadcasts via ARC (defaults to ARCADE). Partial transaction template, unique derived addresses per payment. Works without a wallet via
operator_wallet_url. - BRC-121 (BSV Association simple) — stateless, BRC-100 wallet-native, zero config.
- BRC-105 (BSV Association authenticated) — settlement via
wallet.internalize_action. Transitional; requires BRC-103 for spec compliance. - BSV-proof (merkleworks) — experimental; client broadcasts, server checks mempool.
See CHANGELOG.md for release history and docs/ for full documentation.
Development
bin/setup # Install dependencies
bundle exec rake spec # Run unit and integration tests
bundle exec rubocop # Lint
bundle exec rake # Run all checks (tests + lint)
bundle exec rake e2e # Run BSV testnet e2e tests (requires ARC + funded wallets)
bundle exec rake feature # Run browser feature tests (requires Chrome for Testing + bsv-x402 extension)
Feature specs (browser)
Feature specs drive a real Chrome browser with the bsv-x402 extension side-loaded. Google Chrome stable silently refuses --load-extension, so a separate automation build is required:
npx @puppeteer/browsers install chrome@stable \
--path=$HOME/.cache/chrome-for-testing
npx @puppeteer/browsers install chromedriver@stable \
--path=$HOME/.cache/chrome-for-testing
bundle install --with feature
bundle exec rake feature
Set BSV_X402_EXTENSION_PATH to override the default extension location. Set HEADED=1 to launch Chrome in headed mode for debugging.
Contributing
Bug reports and pull requests are welcome on GitHub.
Licence
Available under the terms of the Open BSV Licence.