x402-rack 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c88a0b6770421ac5fa4d83a410a9951f9544af70325fe467bc29676b7d0957f
4
- data.tar.gz: b0f8f30ff3a63b6a83794a148e4e8e8aed60555e337c70a6925539147b2a4850
3
+ metadata.gz: 5cd89fcca7ffc76bbe6c64b59f9a3874f31dcd4ef81b0b5e5de2bdf6ef06cb1a
4
+ data.tar.gz: 9ed4a30017bf928d1cfcce9da5ff119f7976fd2b30e83720ebed86e16d4ae5de
5
5
  SHA512:
6
- metadata.gz: 8208b0343e9f84a4d7f998a42d574627f92c6457f40a4d8732aa3ed6ff7383789410fc895a43118db49e45bde9a31fb79283524e3b7c4677dcff1465d92119cd
7
- data.tar.gz: d65d2ed290d6c097a3a0bccfda18f2634c458e59dace5b4a86ae478616525483adbeb5ba7b62d112f2bfd4475c0a0668772aa1baf2c92e16c52f547bda6f591e
6
+ metadata.gz: 6ef8a23f261ea47e375f0c40177713960f5023f624ffd52463f865143aa633503a8c4f048a5cb0d39dc5af5940de78faebd82d01de68161b7533b3571db44398
7
+ data.tar.gz: 917457986e6916d200acc8eee63a583984577ea6440dd5a6c8300c786f20288bf5228fde48e666e0b8bc56a42de8f6cdbcdda39ff387c60af715508725a92258
data/CHANGELOG.md ADDED
@@ -0,0 +1,90 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.0] - 2026-04-02
9
+
10
+ ### Added
11
+
12
+ - **Configuration DSL** — `config.enable :pay_gateway` with shared dependencies (ARC client, payee script) wired automatically. Convenience options: `server_wif:` builds KeyDeriver, `nonce_wif:` builds PrivateKey, default PrefixStore for BRC-105. Per-gateway overrides supported. Deferred construction at `validate!` time. Full backwards compatibility with `config.gateways = [...]`.
13
+ - **Copilot review instructions** — `.github/copilot-instructions.md` with payment-bypass-focused review guidance.
14
+ - **Dependabot** — weekly checks for bundler and GitHub Actions dependencies.
15
+
16
+ ### Changed
17
+
18
+ - **Treasury refactor** — ProofGateway no longer holds the treasury's private key (`nonce_key:` removed). The `nonce_provider` callable now optionally returns a pre-signed `partial_tx:` for Profile B. Signing responsibility pushed from gateway to treasury. Trust boundary: `[(X)+(B)] <-> [(T)]`.
19
+ - **nonce_provider interface** — now receives `payee:` and `amount:` kwargs. Profile detection moved from constructor config to provider response.
20
+ - **Dependencies** — `bsv-sdk ~> 0.4`, `bsv-wallet ~> 0.2` (BRC-100 wallet interface now available).
21
+
22
+ ### Fixed
23
+
24
+ - **ARC wait_for enforced** — PayGateway now passes `arc_wait_for` to `broadcast(tx, wait_for:)`. Previously stored but never used.
25
+ - **Mempool status validated** — ProofGateway `check_mempool!` now verifies `tx_status` is `SEEN_ON_NETWORK`, `ANNOUNCED_TO_NETWORK`, or `MINED`. Non-propagated statuses (`RECEIVED`, `STORED`) correctly rejected.
26
+ - **Error messages hardened** — all gateways, middleware, and protocol parsers now return fixed generic strings. No SDK exception messages forwarded to HTTP clients.
27
+ - **Config DSL memoisation** — `shared_arc_client` checks injected `arc_client` on every call, not just first.
28
+
29
+ ### Removed
30
+
31
+ - **`nonce_key:` parameter** from ProofGateway constructor (breaking change for Profile B users — signing moves to nonce_provider).
32
+ - **`nonce_wif:` and `nonce_key:` convenience options** from configuration DSL (no longer applicable).
33
+
34
+ ## [0.2.0] - 2026-03-30
35
+
36
+ ### Added
37
+
38
+ - **BRC-105 Gateway** — `X402::BSV::BRC105Gateway` implementing the BSV Association's native payment protocol (`x-bsv-*` headers). Uses BRC-29 key derivation for unique per-payment addresses and AtomicBEEF (BRC-95) transaction format.
39
+ - **Prefix Store** — `X402::BSV::PrefixStore::Memory` for BRC-105 derivation prefix replay protection. Thread-safe via Monitor, with TTL-based expiry (default 300s) and max capacity cap (default 10,000).
40
+ - **BRC-103 composition** — BRC105Gateway works standalone (advertises server identity key in header) or composes with future BRC-103 mutual authentication middleware. Detects mode automatically from `env['brc103.identity_key']`.
41
+ - **BRC-105 e2e test** — full standalone payment flow against BSV testnet (derive address, build tx, encode AtomicBEEF, verify, broadcast).
42
+ - **Comprehensive documentation** — scheme doc (`docs/schemes/brc-105.md`), process flow diagrams for both standalone and authenticated modes, security analysis, client integration guide.
43
+
44
+ ### Security
45
+
46
+ - Derivation prefix consumed after full transaction validation, not before (prevents MITM prefix burning).
47
+ - BRC-103 identity key validated as compressed pubkey hex before trusting as BRC-29 counterparty (prevents sentinel injection).
48
+ - SDK exception messages not forwarded to HTTP clients — fixed generic strings returned.
49
+ - `PrefixStore::Memory` bounded with TTL and max capacity to prevent heap exhaustion from unauthenticated challenge requests.
50
+ - `StoreFullError` returns 503 (server at capacity), not 400.
51
+
52
+ ## [0.1.0] - 2026-03-28
53
+
54
+ ### Added
55
+
56
+ #### Middleware
57
+
58
+ - **`X402::Middleware`** — pure Rack dispatcher for payment-gated HTTP. No blockchain knowledge, no keys. Matches routes, polls gateways for challenge headers, dispatches proofs to the matching gateway.
59
+ - **Multi-gateway support** — multiple gateways can be configured simultaneously. The middleware issues challenge headers from all gateways and dispatches proofs to whichever gateway recognises the proof header.
60
+ - **Payment content negotiation** — different x402 ecosystems use different HTTP headers. A server sends multiple challenge headers; the client picks the one it can satisfy.
61
+
62
+ #### Gateways
63
+
64
+ - **`X402::BSV::PayGateway`** — Coinbase v2 headers (`Payment-Required` / `Payment-Signature` / `Payment-Response`). Server broadcasts via ARC. HMAC-signed `payToSig` prevents payee address tampering. OP_RETURN request binding (strict/permissive mode). This is the recommended "BSV way" — vendor verifies, vendor broadcasts, vendor serves.
65
+ - **`X402::BSV::ProofGateway`** — merkleworks x402 headers (`X402-Challenge` / `X402-Proof`). Client broadcasts, server checks mempool. Profile A (bare nonce metadata) and Profile B (pre-signed template with 0xC3 nonce signature for provenance).
66
+ - **`X402::BSV::Gateway`** — base class for template-based gateways. Builds partial transaction templates with payment output and OP_RETURN binding. Supports wallet-based address derivation (BRC-43) or static payee address.
67
+
68
+ #### Configuration
69
+
70
+ - **Route protection** — `config.protect method: :GET, path: "/api/expensive", amount_sats: 100`
71
+ - **Duplicate proof header detection** — validates no two gateways claim the same proof header name.
72
+
73
+ #### Testing
74
+
75
+ - **E2e test suite** — PayGateway, ProofGateway (Profile B), and fee delegation flows tested against BSV testnet with real ARC broadcasts.
76
+ - **E2ELogger** — pretty logging with actors, timestamps, transaction links, and timestamped markdown log files.
77
+ - **Fee delegation e2e** — treasury + client + delegator + payee four-wallet flow with 0xC3 sighash alignment.
78
+
79
+ #### Documentation
80
+
81
+ - **Architecture docs** — middleware as dispatcher, gateway interface, component boundaries, unified template model, 0xC3 sighash rationale.
82
+ - **Scheme docs** — BSV-pay and BSV-proof with headers, challenge/settlement flows, replay protection.
83
+ - **Process flow diagrams** — mermaid sequence diagrams for PayGateway and ProofGateway.
84
+ - **Security docs** — threat model, payToSig HMAC, nonce provenance (Profile B), OP_RETURN binding, error handling.
85
+ - **Operations docs** — deployment, performance, treasury/nonce lifecycle.
86
+ - **Ecosystem docs** — Coinbase v2, merkleworks, BRC-105 positioning and header namespace reservations.
87
+
88
+ [0.3.0]: https://github.com/sgbett/x402-rack/compare/v0.2.0...v0.3.0
89
+ [0.2.0]: https://github.com/sgbett/x402-rack/compare/v0.1.0...v0.2.0
90
+ [0.1.0]: https://github.com/sgbett/x402-rack/releases/tag/v0.1.0
data/README.md CHANGED
@@ -20,20 +20,77 @@ gem "x402-rack", git: "https://github.com/sgbett/x402-rack.git"
20
20
 
21
21
  ## Usage
22
22
 
23
+ ### Configuration DSL (recommended)
24
+
25
+ The DSL handles shared dependencies (ARC client, payee script) and gateway
26
+ construction automatically. You declare *what* you want; the middleware builds it
27
+ at validation time.
28
+
23
29
  ```ruby
24
30
  # config.ru or Rails initialiser
25
31
  require "x402"
26
32
 
33
+ X402.configure do |config|
34
+ config.domain = "api.example.com"
35
+ config.payee_locking_script_hex = "76a914...88ac"
36
+
37
+ # Shared ARC connection — built once, used by all gateways
38
+ config.arc_url = "https://arc.taal.com"
39
+ config.arc_api_key = "..." # optional — ARC supports anonymous access
40
+
41
+ # Enable gateways — constructed automatically from shared deps
42
+ config.enable :pay_gateway
43
+ config.enable :proof_gateway, nonce_provider: my_nonce_provider
44
+ config.enable :brc105_gateway, server_wif: "L3..."
45
+
46
+ config.protect method: :GET, path: "/api/expensive", amount_sats: 100
47
+ end
48
+
49
+ use X402::Middleware
50
+ ```
51
+
52
+ #### Convenience options
53
+
54
+ Each gateway type supports convenience options that expand into their full form:
55
+
56
+ - **`:brc105_gateway`** — `server_wif: "L3..."` builds a `KeyDeriver` from a WIF
57
+ string. `server_key:` accepts a `PrivateKey` directly. A default in-memory
58
+ `PrefixStore` is used if `prefix_store:` is not provided.
59
+ - **`:proof_gateway`** — `nonce_wif: "L3..."` builds a `PrivateKey` for the
60
+ `nonce_key:` parameter.
61
+ - **`:pay_gateway`** — pass-through options: `arc_wait_for:`, `arc_timeout:`,
62
+ `binding_mode:`, `wallet:`, `challenge_secret:`.
63
+
64
+ #### Per-gateway overrides
65
+
66
+ Any gateway can override shared dependencies:
67
+
68
+ ```ruby
69
+ config.enable :pay_gateway, arc_client: my_custom_arc
70
+ config.enable :proof_gateway, nonce_provider: np, payee_locking_script_hex: "deadbeef..."
71
+ ```
72
+
73
+ #### Order independence
74
+
75
+ `enable` records gateway specs; construction is deferred to `validate!`. This
76
+ means `arc_url` can be set after `enable` calls — order within the configure
77
+ block does not matter.
78
+
79
+ ### Manual gateway construction (power-user escape hatch)
80
+
81
+ For full control, construct gateways yourself and assign them directly. This
82
+ bypasses DSL construction entirely and works exactly as before:
83
+
84
+ ```ruby
27
85
  X402.configure do |config|
28
86
  config.domain = "api.example.com"
29
87
  config.payee_locking_script_hex = "76a914...88ac"
30
88
 
31
89
  config.gateways = [
32
90
  X402::BSV::PayGateway.new(
33
- arc_url: "https://arc.taal.com",
34
- arc_api_key: "..."
91
+ arc_client: BSV::Network::ARC.new("https://arc.taal.com", api_key: "..."),
92
+ payee_locking_script_hex: "76a914...88ac"
35
93
  ),
36
- # BRC-105 gateway (BSV Association payment protocol)
37
94
  X402::BSV::BRC105Gateway.new(
38
95
  key_deriver: BSV::Wallet::KeyDeriver.new(server_private_key),
39
96
  prefix_store: X402::BSV::PrefixStore::Memory.new,
@@ -47,6 +104,9 @@ end
47
104
  use X402::Middleware
48
105
  ```
49
106
 
107
+ If `config.gateways` is set to a non-empty array, any `enable` calls are
108
+ ignored.
109
+
50
110
  ## How It Works
51
111
 
52
112
  1. Client requests a protected resource
data/docs/architecture.md CHANGED
@@ -15,7 +15,7 @@ The middleware never decodes transactions, checks mempool, broadcasts, or intera
15
15
 
16
16
  ## Gateways
17
17
 
18
- Gateways are pluggable backends that handle chain-specific settlement. They **can** hold keys and sign transactions they are separate components from the gatekeeper. Each gateway:
18
+ Gateways are pluggable backends that handle chain-specific settlement. They delegate key management and signing to external providers (e.g. the treasury via `nonce_provider`). Each gateway:
19
19
 
20
20
  - Builds challenge data (including partial transaction templates)
21
21
  - Verifies and settles proofs
@@ -69,8 +69,7 @@ config.gateways = [
69
69
  X402::BSV::PayGateway.new(arc_client: arc),
70
70
  X402::BSV::ProofGateway.new(
71
71
  nonce_provider: treasury,
72
- arc_client: arc,
73
- nonce_key: nonce_key
72
+ arc_client: arc
74
73
  )
75
74
  ]
76
75
  ```
@@ -12,7 +12,7 @@ The treasury creates 1-sat P2PKH outputs locked to the nonce key. Each output be
12
12
 
13
13
  When the ProofGateway builds a challenge, it requests a nonce from the treasury (via the `nonce_provider` callable). The nonce UTXO details (txid, vout, satoshis, locking_script_hex) are included in the challenge.
14
14
 
15
- In Profile B, the gateway signs the nonce input with `0xC3` and includes the pre-signed template in the challenge.
15
+ In Profile B, the treasury signs the nonce input with `0xC3` and returns the pre-signed template (as `partial_tx` binary) to the gateway. The gateway appends the OP_RETURN and includes the template in the challenge.
16
16
 
17
17
  ### Spending
18
18
 
@@ -51,12 +51,20 @@ The treasury is not on the critical path for settlement — it only participates
51
51
 
52
52
  ## Current Implementation
53
53
 
54
- The `nonce_provider` is currently a raw callable injected into ProofGateway:
54
+ The `nonce_provider` is a callable injected into ProofGateway. It receives `payee:` and `amount:` kwargs so the treasury can build the template:
55
55
 
56
56
  ```ruby
57
- nonce_provider = ->(request) {
57
+ # Profile A — bare UTXO metadata
58
+ nonce_provider = ->(request, payee:, amount:) {
58
59
  { txid: "...", vout: 0, satoshis: 1, locking_script_hex: "76a914...88ac" }
59
60
  }
61
+
62
+ # Profile B — treasury builds and signs the template
63
+ nonce_provider = ->(request, payee:, amount:) {
64
+ tx = build_and_sign_template(payee: payee, amount: amount)
65
+ { txid: "...", vout: 0, satoshis: 1, locking_script_hex: "76a914...88ac",
66
+ partial_tx: tx.to_binary }
67
+ }
60
68
  ```
61
69
 
62
70
  Full wallet-backed treasury integration (basket-managed nonce pool, automatic minting, timelock expiry) is tracked in [bsv-ruby-sdk#196](https://github.com/sgbett/bsv-ruby-sdk/issues/196).
@@ -81,7 +81,7 @@ sequenceDiagram
81
81
  ## Notes
82
82
 
83
83
  - **Client broadcasts, not server** — broadcasting is settlement, not authorisation. Keeps the server stateless (per Rui at merkleworks).
84
- - **Profile B template** — the challenge includes a pre-signed template (`partial_tx_b64`) with the nonce input at index 0 signed with `0xC3`. The client extends it by appending funding inputs.
84
+ - **Profile B template** — the treasury (via `nonce_provider`) builds and signs the template. The gateway appends the OP_RETURN and includes it in the challenge as `partial_tx_b64`. The nonce input at index 0 is signed with `0xC3`. The client extends it by appending funding inputs.
85
85
  - **0xC3 sighash** — `SIGHASH_SINGLE | ANYONECANPAY | FORKID` commits to output 0 (payment) while allowing additional inputs and outputs. See [architecture.md](../architecture.md#why-0xc3-for-the-nonce-signature).
86
86
  - **Fee delegation is optional** — shown as `opt` in the diagram. Most clients can fund their own fees (1-50 sats). The delegator adds fee inputs and signs only those.
87
87
  - **Client signs with 0xC1** — `SIGHASH_ALL | ANYONECANPAY | FORKID` on funding inputs. Commits to all outputs but allows the delegator to append fee inputs.
@@ -11,21 +11,23 @@ Client broadcasts, server checks mempool. Proof-of-payment model. Nonce-bound wi
11
11
 
12
12
  ## Profiles
13
13
 
14
- ### Profile A (no nonce key)
14
+ ### Profile A (no partial template)
15
15
 
16
16
  The challenge includes nonce UTXO metadata only. The client must construct the entire transaction including the nonce input. No cryptographic proof of nonce provenance.
17
17
 
18
18
  Suitable for deployments using an external treasury service that returns bare UTXO references.
19
19
 
20
- ### Profile B (with nonce key) — recommended
20
+ ### Profile B (treasury-signed template) — recommended
21
21
 
22
- The gateway holds a nonce key and produces a **pre-signed template**:
22
+ The **treasury** (via the `nonce_provider` callable) builds and signs a partial template. The gateway receives it and appends the OP_RETURN request binding:
23
23
 
24
24
  - Input 0: nonce UTXO, signed with `SIGHASH_SINGLE | ANYONECANPAY | FORKID` (`0xC3`)
25
- - Output 0: payment (committed by the signature)
26
- - Output 1: OP_RETURN binding (appendable)
25
+ - Output 0: payment (committed by the treasury's signature)
26
+ - Output 1: OP_RETURN binding (appended by the gateway after receiving the template)
27
27
 
28
- The client extends the template by adding funding inputs. The `0xC3` signature proves the server issued the nonce this is the provenance guarantee.
28
+ The gateway never holds a private key. Profile B is detected from the presence of `partial_tx` in the provider response.
29
+
30
+ The client extends the template by adding funding inputs. The `0xC3` signature proves the treasury issued the nonce — this is the provenance guarantee.
29
31
 
30
32
  The template is included in the challenge as `partial_tx_b64` (base64-encoded), excluded from the canonical challenge hash (merkleworks spec compliance).
31
33
 
@@ -79,7 +81,29 @@ Per Rui at merkleworks: broadcasting is settlement, not authorisation. If the se
79
81
 
80
82
  See [operations/treasury.md](../operations/treasury.md) for nonce lifecycle.
81
83
 
84
+ ## Nonce Provider Interface
85
+
86
+ The `nonce_provider` is a callable that receives `(rack_request, payee:, amount:)` and returns a hash:
87
+
88
+ ```ruby
89
+ # Profile A — bare UTXO metadata
90
+ provider = ->(request, payee:, amount:) {
91
+ { txid: "...", vout: 0, satoshis: 1, locking_script_hex: "76a914...88ac" }
92
+ }
93
+
94
+ # Profile B — includes pre-signed partial template
95
+ provider = ->(request, payee:, amount:) {
96
+ tx = build_and_sign_template(payee: payee, amount: amount)
97
+ { txid: "...", vout: 0, satoshis: 1, locking_script_hex: "76a914...88ac",
98
+ partial_tx: tx.to_binary }
99
+ }
100
+ ```
101
+
102
+ The presence of `:partial_tx` in the response triggers Profile B behaviour. The gateway appends the OP_RETURN after deserialising the template.
103
+
82
104
  ## Infrastructure Required
83
105
 
84
- - **Treasury**: mints nonce UTXOs, holds the nonce key, signs templates (Profile B)
106
+ Trust boundary: `[(X)+(B)] <-> [(T)]` — the server (x402-rack + BSV gateway) never holds keys; the treasury is a separate trust domain.
107
+
108
+ - **Treasury** (`nonce_provider`): mints nonce UTXOs, holds keys, signs templates (Profile B)
85
109
  - **ARC**: mempool queries (`status(txid)`)
data/docs/security.md CHANGED
@@ -48,7 +48,7 @@ The `0xC3` signature on input 0 proves the server issued the nonce. At settlemen
48
48
 
49
49
  ### Nonce Key Validation
50
50
 
51
- At challenge time (template signing), the gateway validates that the nonce key's public key hash matches the nonce UTXO's P2PKH locking script. Catches misconfiguration before producing an invalid template.
51
+ Key validation is the treasury's responsibility. The gateway never holds a private key the `nonce_provider` callable builds and signs the template. Misconfiguration (key/UTXO mismatch) is caught at settlement time when `verify_input(0)` fails.
52
52
 
53
53
  ### Payee Verification
54
54
 
@@ -97,8 +97,8 @@ module X402
97
97
  def decode_payment_payload(proof_payload)
98
98
  json = Base64.strict_decode64(proof_payload)
99
99
  JSON.parse(json)
100
- rescue ArgumentError, JSON::ParserError => e
101
- raise VerificationError.new("invalid payment payload: #{e.message}", status: 400)
100
+ rescue ArgumentError, JSON::ParserError
101
+ raise VerificationError.new("invalid payment payload", status: 400)
102
102
  end
103
103
 
104
104
  def verify_accepted!(payload, route)
@@ -122,8 +122,8 @@ module X402
122
122
  ::BSV::Transaction::Transaction.from_binary(raw)
123
123
  rescue VerificationError
124
124
  raise
125
- rescue StandardError => e
126
- raise VerificationError.new("failed to decode transaction: #{e.message}", status: 400)
125
+ rescue StandardError
126
+ raise VerificationError.new("failed to decode transaction", status: 400)
127
127
  end
128
128
 
129
129
  def verify_payment_output!(transaction, route, payee_hex)
@@ -149,9 +149,11 @@ module X402
149
149
  end
150
150
 
151
151
  def broadcast!(transaction)
152
- arc_client.broadcast(transaction)
153
- rescue StandardError => e
154
- raise VerificationError.new("ARC broadcast failed: #{e.message}", status: 502)
152
+ arc_client.broadcast(transaction, wait_for: arc_wait_for)
153
+ rescue VerificationError
154
+ raise
155
+ rescue StandardError
156
+ raise VerificationError.new("ARC broadcast failed", status: 502)
155
157
  end
156
158
 
157
159
  def build_settlement_result(transaction)
@@ -17,23 +17,22 @@ module X402
17
17
  # Proof: X402-Proof (echoed challenge hash + rawtx + txid)
18
18
  #
19
19
  # Supports two modes:
20
- # - Profile A (no nonce_key): challenge includes nonce UTXO metadata only
21
- # - Profile B (with nonce_key): challenge includes pre-signed template with
22
- # nonce input at index 0 signed with 0xC3
20
+ # - Profile A: challenge includes nonce UTXO metadata only
21
+ # - Profile B: nonce_provider returns a pre-signed partial_tx template
23
22
  class ProofGateway < Gateway
24
- NONCE_SIGHASH = ::BSV::Transaction::Sighash::SINGLE_FORK_ID_ANYONE_CAN_PAY
23
+ ACCEPTABLE_MEMPOOL_STATUSES = %w[SEEN_ON_NETWORK ANNOUNCED_TO_NETWORK MINED].freeze
25
24
 
26
- # @param nonce_provider [#call] callable returning nonce UTXO hash
25
+ # @param nonce_provider [#call] callable returning nonce UTXO hash;
26
+ # receives (rack_request, payee:, amount:) kwargs.
27
+ # Profile B providers include :partial_tx (binary) in the response.
27
28
  # @param arc_client [#status] ARC client for mempool queries
28
- # @param nonce_key [BSV::Primitives::PrivateKey, nil] key for signing nonce input (Profile B)
29
29
  # @param payee_locking_script_hex [String, nil] payee script (falls back to config)
30
- def initialize(nonce_provider:, arc_client:, nonce_key: nil,
30
+ def initialize(nonce_provider:, arc_client:,
31
31
  payee_locking_script_hex: nil, wallet: nil, challenge_secret: nil)
32
32
  super(payee_locking_script_hex: payee_locking_script_hex, wallet: wallet,
33
33
  challenge_secret: challenge_secret)
34
34
  @nonce_provider = nonce_provider
35
35
  @arc_client = arc_client
36
- @nonce_key = nonce_key
37
36
  end
38
37
 
39
38
  def challenge_headers(rack_request, route)
@@ -57,57 +56,14 @@ module X402
57
56
 
58
57
  private
59
58
 
60
- # Build a Profile B template: nonce input (signed 0xC3) + payment + OP_RETURN.
61
- # The 0xC3 signature commits to output 0 (payment) only.
62
- # The OP_RETURN is added AFTER signing — it's unsigned data.
63
- # Delegated clients should pop the OP_RETURN before adding their change
64
- # (to preserve SIGHASH_SINGLE index alignment) and re-append it last.
65
- # Falls back to base class (Profile A) when nonce_key is nil.
66
- def build_proof_template(rack_request, route, nonce)
67
- return super(rack_request, route) unless @nonce_key
68
-
69
- validate_nonce_key!(nonce)
70
-
71
- tx = ::BSV::Transaction::Transaction.new
72
-
73
- # Input 0: nonce UTXO (will be signed with 0xC3)
74
- nonce_script = ::BSV::Script::Script.from_hex(nonce[:locking_script_hex])
75
- nonce_input = ::BSV::Transaction::TransactionInput.new(
76
- prev_tx_id: [nonce[:txid]].pack("H*").reverse,
77
- prev_tx_out_index: nonce[:vout]
78
- )
79
- nonce_input.source_satoshis = nonce[:satoshis]
80
- nonce_input.source_locking_script = nonce_script
81
- tx.add_input(nonce_input)
82
-
83
- # Output 0: payment (committed by 0xC3 signature on input 0)
84
- payee_hex = derive_payee_hex
85
- payee_script = ::BSV::Script::Script.from_hex(payee_hex)
86
- tx.add_output(::BSV::Transaction::TransactionOutput.new(
87
- satoshis: route.amount_sats,
88
- locking_script: payee_script
89
- ))
90
-
91
- # Sign input 0 with 0xC3 (commits to output 0 only)
92
- tx.sign(0, @nonce_key, NONCE_SIGHASH)
93
-
94
- # Output 1: OP_RETURN binding (added AFTER signing — unsigned)
95
- binding_hash = request_binding_hash(rack_request)
96
- tx.add_output(::BSV::Transaction::TransactionOutput.new(
97
- satoshis: 0,
98
- locking_script: build_op_return_script(binding_hash)
99
- ))
100
-
101
- [tx, payee_hex]
102
- end
103
-
104
59
  def build_merkleworks_challenge(rack_request, route)
105
- nonce = @nonce_provider.call(rack_request)
106
60
  config = X402.configuration
107
61
  payee_hex = derive_payee_hex
108
62
 
109
- # Build template if Profile B
110
- template, = build_proof_template(rack_request, route, nonce) if @nonce_key
63
+ nonce = @nonce_provider.call(rack_request, payee: payee_hex, amount: route.amount_sats)
64
+
65
+ # Profile B: provider returns a pre-signed partial_tx (binary)
66
+ template_binary = nonce[:partial_tx]
111
67
 
112
68
  attrs = {
113
69
  version: Challenge::CURRENT_VERSION,
@@ -127,8 +83,16 @@ module X402
127
83
  expires_at: Time.now.to_i + Challenge::DEFAULT_TTL
128
84
  }
129
85
 
130
- # Profile B: include pre-signed template (not part of canonical hash)
131
- attrs[:partial_tx_b64] = Base64.strict_encode64(template.to_binary) if template
86
+ if template_binary
87
+ # Deserialise the treasury's template and append OP_RETURN
88
+ tx = ::BSV::Transaction::Transaction.from_binary(template_binary)
89
+ binding_hash = request_binding_hash(rack_request)
90
+ tx.add_output(::BSV::Transaction::TransactionOutput.new(
91
+ satoshis: 0,
92
+ locking_script: build_op_return_script(binding_hash)
93
+ ))
94
+ attrs[:partial_tx_b64] = Base64.strict_encode64(tx.to_binary)
95
+ end
132
96
 
133
97
  Challenge.new(attrs)
134
98
  end
@@ -154,7 +118,7 @@ module X402
154
118
  transaction = decode_transaction(proof)
155
119
  check_txid!(transaction, proof)
156
120
  check_nonce_input!(transaction, challenge)
157
- verify_nonce_provenance!(transaction, challenge) if @nonce_key
121
+ verify_nonce_provenance!(transaction, challenge) if challenge.partial_tx_b64
158
122
  server_payee_hex = resolve_static_payee_hex
159
123
  verify_payment_output!(transaction, route, server_payee_hex)
160
124
  transaction
@@ -163,8 +127,10 @@ module X402
163
127
  def decode_transaction(proof)
164
128
  raw = Base64.decode64(proof.rawtx_b64)
165
129
  ::BSV::Transaction::Transaction.from_binary(raw)
166
- rescue StandardError => e
167
- raise VerificationError, "failed to decode transaction: #{e.message}"
130
+ rescue VerificationError
131
+ raise
132
+ rescue StandardError
133
+ raise VerificationError, "failed to decode transaction"
168
134
  end
169
135
 
170
136
  def check_txid!(transaction, proof)
@@ -199,8 +165,8 @@ module X402
199
165
  end
200
166
  rescue VerificationError
201
167
  raise
202
- rescue StandardError => e
203
- raise VerificationError, "nonce provenance check failed: #{e.message}"
168
+ rescue StandardError
169
+ raise VerificationError, "nonce provenance check failed"
204
170
  end
205
171
 
206
172
  def verify_payment_output!(transaction, route, payee_hex)
@@ -214,19 +180,14 @@ module X402
214
180
  end
215
181
 
216
182
  def check_mempool!(txid)
217
- @arc_client.status(txid)
218
- rescue StandardError => e
219
- raise VerificationError.new("mempool check failed: #{e.message}", status: 502)
220
- end
221
-
222
- # Validate that the nonce key matches the nonce UTXO's P2PKH locking script
223
- def validate_nonce_key!(nonce)
224
- expected_h160 = @nonce_key.public_key.hash160.unpack1("H*")
225
- expected_script = "76a914#{expected_h160}88ac"
226
- return if nonce[:locking_script_hex] == expected_script
183
+ response = @arc_client.status(txid)
184
+ return if ACCEPTABLE_MEMPOOL_STATUSES.include?(response.tx_status)
227
185
 
228
- raise X402::ConfigurationError,
229
- "nonce_key does not match nonce UTXO locking script"
186
+ raise VerificationError.new("transaction not yet visible in mempool", status: 402)
187
+ rescue VerificationError
188
+ raise
189
+ rescue StandardError
190
+ raise VerificationError.new("mempool check failed", status: 502)
230
191
  end
231
192
  end
232
193
  end
@@ -6,12 +6,58 @@ module X402
6
6
 
7
7
  GATEWAY_METHODS = %i[challenge_headers proof_header_names settle!].freeze
8
8
 
9
- attr_accessor :domain, :payee_locking_script_hex, :gateways
10
- attr_reader :routes
9
+ PAY_GATEWAY_KNOWN_OPTS = %i[
10
+ arc_client payee_locking_script_hex arc_wait_for arc_timeout
11
+ binding_mode wallet challenge_secret
12
+ ].freeze
13
+
14
+ PROOF_GATEWAY_KNOWN_OPTS = %i[
15
+ arc_client payee_locking_script_hex nonce_provider
16
+ wallet challenge_secret
17
+ ].freeze
18
+
19
+ BRC105_GATEWAY_KNOWN_OPTS = %i[
20
+ arc_client key_deriver server_wif server_key prefix_store
21
+ ].freeze
22
+
23
+ GATEWAY_REGISTRY = {
24
+ pay_gateway: "X402::BSV::PayGateway",
25
+ proof_gateway: "X402::BSV::ProofGateway",
26
+ brc105_gateway: "X402::BSV::BRC105Gateway"
27
+ }.freeze
28
+
29
+ attr_accessor :domain, :payee_locking_script_hex, :gateways,
30
+ :arc_url, :arc_api_key, :arc_client
31
+ attr_reader :routes, :gateway_specs
11
32
 
12
33
  def initialize
13
34
  @routes = []
14
35
  @gateways = []
36
+ @gateway_specs = []
37
+ end
38
+
39
+ # Record a gateway to be constructed later during +validate!+.
40
+ #
41
+ # @param name [Symbol] registered gateway name (e.g. +:pay_gateway+)
42
+ # @param options [Hash] options forwarded to the gateway constructor
43
+ # @raise [ConfigurationError] if the gateway name is not registered
44
+ def enable(name, **options)
45
+ class_name = GATEWAY_REGISTRY[name]
46
+ raise ConfigurationError, "unknown gateway: #{name.inspect}" unless class_name
47
+
48
+ @gateway_specs << [class_name, options]
49
+ end
50
+
51
+ # Returns a memoised ARC client instance. If +arc_client+ has been
52
+ # injected directly, that takes precedence. Otherwise, builds one
53
+ # from +arc_url+ (and optional +arc_api_key+).
54
+ #
55
+ # @return [BSV::Network::ARC]
56
+ # @raise [ConfigurationError] if +arc_url+ is nil and no +arc_client+ injected
57
+ def shared_arc_client
58
+ return @arc_client if @arc_client
59
+
60
+ @shared_arc_client ||= build_arc_client
15
61
  end
16
62
 
17
63
  # Register a protected route.
@@ -40,6 +86,7 @@ module X402
40
86
  raise ConfigurationError,
41
87
  "payee_locking_script_hex is required"
42
88
  end
89
+ build_gateways_from_specs! if gateways.empty? && !gateway_specs.empty?
43
90
  validate_gateways!
44
91
  raise ConfigurationError, "at least one route must be protected" if routes.empty?
45
92
  end
@@ -74,6 +121,89 @@ module X402
74
121
  end
75
122
  end
76
123
 
124
+ def build_arc_client
125
+ raise ConfigurationError, "arc_url is required (or inject arc_client directly)" if arc_url.nil? || arc_url.empty?
126
+
127
+ ::BSV::Network::ARC.new(arc_url, api_key: arc_api_key)
128
+ end
129
+
130
+ def build_gateways_from_specs!
131
+ require_relative "bsv"
132
+ require "bsv-wallet"
133
+
134
+ @gateways = gateway_specs.map do |class_name, options|
135
+ klass = Object.const_get(class_name)
136
+ case class_name
137
+ when "X402::BSV::PayGateway" then build_pay_gateway(klass, options)
138
+ when "X402::BSV::ProofGateway" then build_proof_gateway(klass, options)
139
+ when "X402::BSV::BRC105Gateway" then build_brc105_gateway(klass, options)
140
+ end
141
+ end
142
+ end
143
+
144
+ def build_pay_gateway(klass, options)
145
+ reject_unknown_options!(:pay_gateway, options, PAY_GATEWAY_KNOWN_OPTS)
146
+ opts = { arc_client: options[:arc_client] || shared_arc_client,
147
+ payee_locking_script_hex: options[:payee_locking_script_hex] || payee_locking_script_hex }
148
+ %i[arc_wait_for arc_timeout binding_mode wallet challenge_secret].each do |key|
149
+ opts[key] = options[key] if options.key?(key)
150
+ end
151
+ klass.new(**opts)
152
+ end
153
+
154
+ def build_proof_gateway(klass, options)
155
+ reject_unknown_options!(:proof_gateway, options, PROOF_GATEWAY_KNOWN_OPTS)
156
+
157
+ raise ConfigurationError, "proof_gateway requires nonce_provider:" unless options.key?(:nonce_provider)
158
+
159
+ opts = { arc_client: options[:arc_client] || shared_arc_client,
160
+ payee_locking_script_hex: options[:payee_locking_script_hex] || payee_locking_script_hex,
161
+ nonce_provider: options[:nonce_provider] }
162
+
163
+ %i[wallet challenge_secret].each do |key|
164
+ opts[key] = options[key] if options.key?(key)
165
+ end
166
+ klass.new(**opts)
167
+ end
168
+
169
+ def build_brc105_gateway(klass, options) # rubocop:disable Metrics/PerceivedComplexity
170
+ reject_unknown_options!(:brc105_gateway, options, BRC105_GATEWAY_KNOWN_OPTS)
171
+
172
+ key_sources = %i[key_deriver server_wif server_key].select { |k| options.key?(k) }
173
+ if key_sources.size > 1
174
+ raise ConfigurationError,
175
+ "brc105_gateway: #{key_sources.join(", ")} are mutually exclusive — provide only one"
176
+ end
177
+ if key_sources.empty?
178
+ raise ConfigurationError,
179
+ "brc105_gateway requires one of: key_deriver:, server_wif:, or server_key:"
180
+ end
181
+
182
+ key_deriver = if options.key?(:key_deriver)
183
+ options[:key_deriver]
184
+ elsif options.key?(:server_wif)
185
+ ::BSV::Wallet::KeyDeriver.new(::BSV::Primitives::PrivateKey.from_wif(options[:server_wif]))
186
+ else
187
+ ::BSV::Wallet::KeyDeriver.new(options[:server_key])
188
+ end
189
+
190
+ klass.new(
191
+ key_deriver: key_deriver,
192
+ prefix_store: options[:prefix_store] || X402::BSV::PrefixStore::Memory.new,
193
+ arc_client: options[:arc_client] || shared_arc_client
194
+ )
195
+ end
196
+
197
+ # -- Option validation ---------------------------------------------------
198
+
199
+ def reject_unknown_options!(gateway_name, options, known)
200
+ unknown = options.keys - known
201
+ return if unknown.empty?
202
+
203
+ raise ConfigurationError,
204
+ "#{gateway_name}: unknown option(s): #{unknown.map(&:inspect).join(", ")}"
205
+ end
206
+
77
207
  def method_matches?(route_method, request_method)
78
208
  route_method == "*" || route_method == request_method.upcase
79
209
  end
@@ -70,8 +70,8 @@ module X402
70
70
  error_response(e.status, e.reason)
71
71
  rescue X402::Error => e
72
72
  error_response(400, e.message)
73
- rescue StandardError => e
74
- error_response(500, "internal error: #{e.message}")
73
+ rescue StandardError
74
+ error_response(500, "internal error")
75
75
  end
76
76
 
77
77
  def error_response(status, reason)
@@ -12,8 +12,8 @@ module X402
12
12
 
13
13
  def decode(data)
14
14
  Base64.urlsafe_decode64(data)
15
- rescue ArgumentError => e
16
- raise X402::Error, "invalid base64url: #{e.message}"
15
+ rescue ArgumentError
16
+ raise X402::Error, "invalid base64url encoding"
17
17
  end
18
18
  end
19
19
  end
@@ -65,8 +65,8 @@ module X402
65
65
  json = Base64Url.decode(header_value)
66
66
  data = JSON.parse(json, symbolize_names: true)
67
67
  new(data)
68
- rescue JSON::ParserError => e
69
- raise X402::Error, "invalid challenge JSON: #{e.message}"
68
+ rescue JSON::ParserError
69
+ raise X402::Error, "invalid challenge JSON"
70
70
  end
71
71
  end
72
72
  end
@@ -20,8 +20,8 @@ module X402
20
20
  challenge_sha256: data[:challenge_sha256],
21
21
  payment: data[:payment]
22
22
  )
23
- rescue JSON::ParserError => e
24
- raise X402::Error, "invalid proof JSON: #{e.message}"
23
+ rescue JSON::ParserError
24
+ raise X402::Error, "invalid proof JSON"
25
25
  end
26
26
 
27
27
  # Convenience accessors for payment fields.
data/lib/x402/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module X402
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: x402-rack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-31 00:00:00.000000000 Z
10
+ date: 2026-04-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -29,28 +29,28 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.3'
32
+ version: '0.4'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0.3'
39
+ version: '0.4'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: bsv-wallet
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.1'
46
+ version: '0.2'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.1'
53
+ version: '0.2'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: json-canonicalization
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -91,6 +91,7 @@ files:
91
91
  - ".claude/plans/20260326-rack-stack-architecture.md"
92
92
  - ".rspec"
93
93
  - ".rubocop.yml"
94
+ - CHANGELOG.md
94
95
  - CLAUDE.md
95
96
  - DESIGN.md
96
97
  - LICENSE.txt