x402-rack 0.3.0 → 0.4.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: 5cd89fcca7ffc76bbe6c64b59f9a3874f31dcd4ef81b0b5e5de2bdf6ef06cb1a
4
- data.tar.gz: 9ed4a30017bf928d1cfcce9da5ff119f7976fd2b30e83720ebed86e16d4ae5de
3
+ metadata.gz: d1c49c723394e852eb4f825b4f849967895cddc8a787dad72a5b6b3959ab618d
4
+ data.tar.gz: c417200fd8a715c7795d673f8e0f10b6627502aa6ee04e679265d44e3bb66e80
5
5
  SHA512:
6
- metadata.gz: 6ef8a23f261ea47e375f0c40177713960f5023f624ffd52463f865143aa633503a8c4f048a5cb0d39dc5af5940de78faebd82d01de68161b7533b3571db44398
7
- data.tar.gz: 917457986e6916d200acc8eee63a583984577ea6440dd5a6c8300c786f20288bf5228fde48e666e0b8bc56a42de8f6cdbcdda39ff387c60af715508725a92258
6
+ metadata.gz: 4b2a7277eea92f8f546fd45730ad860d239ec5df6592854942d214cc43415382f5f9adf33185cf871a81f971bd447e1dd804007597c9aa26a978d12cd6503728
7
+ data.tar.gz: 5143cd4fae5666792ab3338344ff7b28269b5544e353965d7c5e19be3395de2a68cbb07e8d1050840e0a97a58336694c89a6a0fbf6926066ae56b027b06b4f41
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-04-03
9
+
10
+ ### Added
11
+
12
+ - **Server wallet** — `config.server_wif` builds a shared `ProtoWallet` (BRC-42/43 key derivation) for all gateways. Per-payment derived addresses, no address reuse. Falls back to static `payee_locking_script_hex` when not set. Per-gateway overrides (`wallet:`, `key_deriver:`) take precedence.
13
+ - **Settlement worker** — `X402::SettlementWorker` for async background broadcast. Ruby stdlib only (Thread + Queue), exponential backoff retry, zero dependencies. Pluggable interface (`#enqueue(tx_binary)`) for Sidekiq/Redis.
14
+ - **Per-route ARC thresholds** — `config.protect` accepts `arc_wait_for:` to override the gateway default. `:async` validates tx locally then enqueues, responding 200 immediately.
15
+ - **Payment observer** — `X402::PaymentObserver` Rack middleware for voluntary ungated payments. Watches for payment headers, validates payee, enqueues to settlement worker. Never gates access. Configurable proof headers and `on_payment` callback.
16
+ - **Pluggable recogniser** — `PaymentObserver` accepts `recogniser:` (any object responding to `#ours?(locking_script_hex)`) for BRC-29 derived address payment channels. `StaticRecogniser` wraps the existing static payee behaviour.
17
+ - **Fiat-denominated pricing** — `config.protect` accepts `amount_usd:` resolved to sats at challenge time via `exchange_rate_provider`. Also accepts callable `amount_sats:` for any dynamic pricing. Provider interface: `#sats_for(currency, amount)`.
18
+
19
+ ### Changed
20
+
21
+ - **`build_template` signature** — now accepts `required_sats` integer instead of route object. Gateways snapshot the resolved amount once per request.
22
+ - **ProofGateway rejects callable pricing** — raises `ConfigurationError` at challenge time. The merkleworks canonical hash includes `amount_sats`; a callable would produce different hashes across requests.
23
+
24
+ ### Fixed
25
+
26
+ - **`arc_wait_for` coerced to string** — prevents Symbol values being passed to ARC client.
27
+ - **PaymentObserver pass-through guarantee** — enqueue/callback failures wrapped in rescue, never break the request.
28
+ - **Recogniser interface validated** — `ConfigurationError` if recogniser doesn't respond to `#ours?`.
29
+ - **Static payee hex canonicalised** — round-trips through `Script.from_hex.to_hex` in `StaticRecogniser`.
30
+ - **Exchange rate provider validated** — `ConfigurationError` if provider doesn't respond to `#sats_for`.
31
+
8
32
  ## [0.3.0] - 2026-04-02
9
33
 
10
34
  ### Added
@@ -85,6 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
85
109
  - **Operations docs** — deployment, performance, treasury/nonce lifecycle.
86
110
  - **Ecosystem docs** — Coinbase v2, merkleworks, BRC-105 positioning and header namespace reservations.
87
111
 
112
+ [0.4.0]: https://github.com/sgbett/x402-rack/compare/v0.3.0...v0.4.0
88
113
  [0.3.0]: https://github.com/sgbett/x402-rack/compare/v0.2.0...v0.3.0
89
114
  [0.2.0]: https://github.com/sgbett/x402-rack/compare/v0.1.0...v0.2.0
90
115
  [0.1.0]: https://github.com/sgbett/x402-rack/releases/tag/v0.1.0
data/README.md CHANGED
@@ -6,9 +6,7 @@ The middleware is a pure dispatcher — it matches routes, issues payment challe
6
6
 
7
7
  ## Status
8
8
 
9
- Early development (v0.1.0). Architecture is defined; gateway implementation is in progress. See [DESIGN.md](DESIGN.md) for architecture notes.
10
-
11
- **This is pre-release software and should not be used for real-world monetary purposes.**
9
+ v0.3.0 PayGateway, ProofGateway, and BRC105Gateway all functional with e2e tests against BSV testnet. See [CHANGELOG.md](CHANGELOG.md) for release history and [DESIGN.md](DESIGN.md) for architecture notes.
12
10
 
13
11
  ## Installation
14
12
 
@@ -32,7 +30,7 @@ require "x402"
32
30
 
33
31
  X402.configure do |config|
34
32
  config.domain = "api.example.com"
35
- config.payee_locking_script_hex = "76a914...88ac"
33
+ config.server_wif = ENV["SERVER_WIF"] # recommended — derives unique addresses per payment
36
34
 
37
35
  # Shared ARC connection — built once, used by all gateways
38
36
  config.arc_url = "https://arc.taal.com"
@@ -40,8 +38,6 @@ X402.configure do |config|
40
38
 
41
39
  # Enable gateways — constructed automatically from shared deps
42
40
  config.enable :pay_gateway
43
- config.enable :proof_gateway, nonce_provider: my_nonce_provider
44
- config.enable :brc105_gateway, server_wif: "L3..."
45
41
 
46
42
  config.protect method: :GET, path: "/api/expensive", amount_sats: 100
47
43
  end
@@ -56,8 +52,8 @@ Each gateway type supports convenience options that expand into their full form:
56
52
  - **`:brc105_gateway`** — `server_wif: "L3..."` builds a `KeyDeriver` from a WIF
57
53
  string. `server_key:` accepts a `PrivateKey` directly. A default in-memory
58
54
  `PrefixStore` is used if `prefix_store:` is not provided.
59
- - **`:proof_gateway`** — `nonce_wif: "L3..."` builds a `PrivateKey` for the
60
- `nonce_key:` parameter.
55
+ - **`:proof_gateway`** — requires `nonce_provider:` callable. The provider
56
+ receives `(rack_request, payee:, amount:)` and returns nonce UTXO metadata.
61
57
  - **`:pay_gateway`** — pass-through options: `arc_wait_for:`, `arc_timeout:`,
62
58
  `binding_mode:`, `wallet:`, `challenge_secret:`.
63
59
 
@@ -47,7 +47,7 @@ module X402
47
47
  end
48
48
 
49
49
  headers = {
50
- "x-bsv-payment-satoshis-required" => route.amount_sats.to_s,
50
+ "x-bsv-payment-satoshis-required" => route.resolve_amount_sats.to_s,
51
51
  "x-bsv-payment-derivation-prefix" => prefix
52
52
  }
53
53
 
@@ -72,13 +72,14 @@ module X402
72
72
  # @param route [X402::Configuration::Route]
73
73
  # @return [SettlementResult]
74
74
  def settle!(_header_name, proof_payload, rack_request, route)
75
+ required_sats = route.resolve_amount_sats
75
76
  payment = parse_payment(proof_payload)
76
77
  prefix = payment["derivationPrefix"]
77
78
  suffix = payment["derivationSuffix"]
78
79
  validate_prefix_and_suffix!(prefix, suffix)
79
80
  subject_tx = parse_beef_transaction(payment["transaction"])
80
81
  expected_script = derive_payment_script(prefix, suffix, rack_request)
81
- verify_payment_output!(subject_tx, route, expected_script)
82
+ verify_payment_output!(subject_tx, required_sats, expected_script)
82
83
  consume_prefix!(prefix)
83
84
  broadcast!(subject_tx)
84
85
  build_settlement_result(subject_tx)
@@ -139,13 +140,15 @@ module X402
139
140
  key if key.is_a?(String) && key.match?(COMPRESSED_PUBKEY_HEX)
140
141
  end
141
142
 
142
- def verify_payment_output!(transaction, route, expected_script)
143
+ def verify_payment_output!(transaction, required_sats, expected_script)
143
144
  found = transaction.outputs.any? do |output|
144
- output.locking_script == expected_script && output.satoshis >= route.amount_sats
145
+ output.locking_script == expected_script && output.satoshis >= required_sats
145
146
  end
146
147
  return if found
147
148
 
148
- raise VerificationError.new("no output pays >= #{route.amount_sats} sats to derived address", status: 402)
149
+ raise VerificationError.new(
150
+ "no output pays >= #{required_sats} sats to derived address", status: 402
151
+ )
149
152
  end
150
153
 
151
154
  def broadcast!(transaction)
@@ -44,14 +44,14 @@ module X402
44
44
  # @param rack_request [Rack::Request]
45
45
  # @param route [X402::Configuration::Route]
46
46
  # @return [Array(BSV::Transaction::Transaction, String)] transaction and payee script hex
47
- def build_template(_rack_request, route)
47
+ def build_template(_rack_request, required_sats)
48
48
  tx = ::BSV::Transaction::Transaction.new
49
49
 
50
50
  # Output 0: payment (unique address if wallet configured, static otherwise)
51
51
  payee_hex = derive_payee_hex
52
52
  payee_script = ::BSV::Script::Script.from_hex(payee_hex)
53
53
  tx.add_output(::BSV::Transaction::TransactionOutput.new(
54
- satoshis: route.amount_sats,
54
+ satoshis: required_sats,
55
55
  locking_script: payee_script
56
56
  ))
57
57
 
@@ -20,22 +20,25 @@ module X402
20
20
  ASSET = "BSV"
21
21
  SCHEME = "exact"
22
22
 
23
- attr_reader :arc_client, :arc_wait_for, :arc_timeout, :binding_mode
23
+ attr_reader :arc_client, :arc_wait_for, :arc_timeout, :binding_mode, :settlement_worker
24
24
 
25
25
  # @param arc_client [#broadcast] ARC client for broadcasting
26
26
  # @param arc_wait_for [String] ARC X-WaitFor header value
27
27
  # @param arc_timeout [Integer] seconds before ARC timeout
28
28
  # @param binding_mode [Symbol] :strict or :permissive for OP_RETURN binding
29
29
  # @param payee_locking_script_hex [String, nil] payee script (falls back to config)
30
+ # @param settlement_worker [#enqueue, nil] async settlement worker
30
31
  def initialize(arc_client:, arc_wait_for: DEFAULT_ARC_WAIT_FOR,
31
32
  arc_timeout: DEFAULT_ARC_TIMEOUT, binding_mode: :permissive,
32
- payee_locking_script_hex: nil, wallet: nil, challenge_secret: nil)
33
+ payee_locking_script_hex: nil, wallet: nil, challenge_secret: nil,
34
+ settlement_worker: nil)
33
35
  super(payee_locking_script_hex: payee_locking_script_hex, wallet: wallet,
34
36
  challenge_secret: challenge_secret)
35
37
  @arc_client = arc_client
36
38
  @arc_wait_for = arc_wait_for
37
39
  @arc_timeout = arc_timeout
38
40
  @binding_mode = binding_mode
41
+ @settlement_worker = settlement_worker
39
42
  end
40
43
 
41
44
  def challenge_headers(rack_request, route)
@@ -48,22 +51,24 @@ module X402
48
51
  end
49
52
 
50
53
  def settle!(_header_name, proof_payload, rack_request, route)
54
+ required_sats = route.resolve_amount_sats
51
55
  payload = decode_payment_payload(proof_payload)
52
- verify_accepted!(payload, route)
56
+ verify_accepted!(payload, required_sats)
53
57
  accepted_payee = payload.dig("accepted", "payTo")
54
58
  pay_to_sig = payload.dig("accepted", "extra", "payToSig")
55
59
  verify_pay_to_signature!(accepted_payee, pay_to_sig)
56
60
  transaction = decode_transaction(payload)
57
- verify_payment_output!(transaction, route, accepted_payee)
61
+ verify_payment_output!(transaction, required_sats, accepted_payee)
58
62
  verify_binding!(transaction, rack_request)
59
- broadcast!(transaction)
63
+ settle_transaction!(transaction, route)
60
64
  build_settlement_result(transaction)
61
65
  end
62
66
 
63
67
  private
64
68
 
65
69
  def build_challenge(rack_request, route)
66
- template, payee_hex = build_template(rack_request, route)
70
+ required_sats = route.resolve_amount_sats
71
+ template, payee_hex = build_template(rack_request, required_sats)
67
72
 
68
73
  # PayGateway includes OP_RETURN in the template (no fee delegation index issues)
69
74
  binding_hash = request_binding_hash(rack_request)
@@ -75,15 +80,15 @@ module X402
75
80
  {
76
81
  "x402Version" => 2,
77
82
  "resource" => { "url" => rack_request.path_info },
78
- "accepts" => [build_accept_entry(payee_hex, route, template)]
83
+ "accepts" => [build_accept_entry(payee_hex, required_sats, template)]
79
84
  }
80
85
  end
81
86
 
82
- def build_accept_entry(payee_hex, route, template)
87
+ def build_accept_entry(payee_hex, required_sats, template)
83
88
  {
84
89
  "scheme" => SCHEME,
85
90
  "network" => NETWORK,
86
- "amount" => route.amount_sats.to_s,
91
+ "amount" => required_sats.to_s,
87
92
  "asset" => ASSET,
88
93
  "payTo" => payee_hex,
89
94
  "maxTimeoutSeconds" => DEFAULT_MAX_TIMEOUT_SECONDS,
@@ -101,7 +106,7 @@ module X402
101
106
  raise VerificationError.new("invalid payment payload", status: 400)
102
107
  end
103
108
 
104
- def verify_accepted!(payload, route)
109
+ def verify_accepted!(payload, required_sats)
105
110
  accepted = payload["accepted"]
106
111
  raise VerificationError.new("missing accepted field", status: 400) unless accepted
107
112
  if accepted["network"] != NETWORK
@@ -109,9 +114,9 @@ module X402
109
114
  end
110
115
 
111
116
  amount = accepted["amount"].to_i
112
- return unless amount < route.amount_sats
117
+ return unless amount < required_sats
113
118
 
114
- raise VerificationError.new("insufficient amount: #{amount} < #{route.amount_sats}", status: 402)
119
+ raise VerificationError.new("insufficient amount: #{amount} < #{required_sats}", status: 402)
115
120
  end
116
121
 
117
122
  def decode_transaction(payload)
@@ -126,14 +131,14 @@ module X402
126
131
  raise VerificationError.new("failed to decode transaction", status: 400)
127
132
  end
128
133
 
129
- def verify_payment_output!(transaction, route, payee_hex)
134
+ def verify_payment_output!(transaction, required_sats, payee_hex)
130
135
  payee_script = payee_script_from_hex(payee_hex)
131
136
  found = transaction.outputs.any? do |output|
132
- output.locking_script == payee_script && output.satoshis >= route.amount_sats
137
+ output.locking_script == payee_script && output.satoshis >= required_sats
133
138
  end
134
139
  return if found
135
140
 
136
- raise VerificationError.new("no output pays >= #{route.amount_sats} sats to payee", status: 402)
141
+ raise VerificationError.new("no output pays >= #{required_sats} sats to payee", status: 402)
137
142
  end
138
143
 
139
144
  def verify_binding!(transaction, rack_request)
@@ -148,8 +153,24 @@ module X402
148
153
  raise VerificationError.new("OP_RETURN request binding mismatch", status: 400)
149
154
  end
150
155
 
151
- def broadcast!(transaction)
152
- arc_client.broadcast(transaction, wait_for: arc_wait_for)
156
+ # Determine the effective wait_for strategy and either broadcast
157
+ # synchronously or enqueue for async settlement.
158
+ def settle_transaction!(transaction, route)
159
+ effective = (route.arc_wait_for || arc_wait_for).to_s
160
+
161
+ if effective == "async"
162
+ unless settlement_worker
163
+ raise ConfigurationError,
164
+ "route arc_wait_for is :async but no settlement_worker configured"
165
+ end
166
+ settlement_worker.enqueue(transaction.to_binary)
167
+ else
168
+ broadcast!(transaction, wait_for: effective)
169
+ end
170
+ end
171
+
172
+ def broadcast!(transaction, wait_for: arc_wait_for)
173
+ arc_client.broadcast(transaction, wait_for: wait_for)
153
174
  rescue VerificationError
154
175
  raise
155
176
  rescue StandardError
@@ -36,6 +36,10 @@ module X402
36
36
  end
37
37
 
38
38
  def challenge_headers(rack_request, route)
39
+ if route.amount_sats.respond_to?(:call)
40
+ raise ConfigurationError,
41
+ "proof_gateway does not support callable amount_sats (fiat pricing) — use a static value"
42
+ end
39
43
  challenge = build_merkleworks_challenge(rack_request, route)
40
44
  { "X402-Challenge" => challenge.to_header }
41
45
  end
@@ -45,10 +49,11 @@ module X402
45
49
  end
46
50
 
47
51
  def settle!(_header_name, proof_payload, rack_request, route)
52
+ required_sats = route.resolve_amount_sats
48
53
  proof = Proof.from_header(proof_payload)
49
54
  challenge = reconstruct_challenge(rack_request)
50
55
  run_protocol_checks!(challenge, proof, rack_request)
51
- decode_and_verify_transaction!(proof, challenge, route)
56
+ decode_and_verify_transaction!(proof, challenge, required_sats)
52
57
  check_mempool!(proof.txid)
53
58
 
54
59
  SettlementResult.new(txid: proof.txid, network: "bsv:mainnet")
@@ -58,9 +63,10 @@ module X402
58
63
 
59
64
  def build_merkleworks_challenge(rack_request, route)
60
65
  config = X402.configuration
66
+ required_sats = route.resolve_amount_sats
61
67
  payee_hex = derive_payee_hex
62
68
 
63
- nonce = @nonce_provider.call(rack_request, payee: payee_hex, amount: route.amount_sats)
69
+ nonce = @nonce_provider.call(rack_request, payee: payee_hex, amount: required_sats)
64
70
 
65
71
  # Profile B: provider returns a pre-signed partial_tx (binary)
66
72
  template_binary = nonce[:partial_tx]
@@ -74,7 +80,7 @@ module X402
74
80
  query: rack_request.query_string,
75
81
  req_headers_sha256: RequestBinding.headers_sha256(rack_request),
76
82
  req_body_sha256: RequestBinding.body_sha256(rack_request),
77
- amount_sats: route.amount_sats,
83
+ amount_sats: required_sats,
78
84
  payee_locking_script_hex: payee_hex,
79
85
  nonce_txid: nonce[:txid],
80
86
  nonce_vout: nonce[:vout],
@@ -114,13 +120,13 @@ module X402
114
120
  Verification::ProtocolChecks.check_expiry!(challenge)
115
121
  end
116
122
 
117
- def decode_and_verify_transaction!(proof, challenge, route)
123
+ def decode_and_verify_transaction!(proof, challenge, required_sats)
118
124
  transaction = decode_transaction(proof)
119
125
  check_txid!(transaction, proof)
120
126
  check_nonce_input!(transaction, challenge)
121
127
  verify_nonce_provenance!(transaction, challenge) if challenge.partial_tx_b64
122
128
  server_payee_hex = resolve_static_payee_hex
123
- verify_payment_output!(transaction, route, server_payee_hex)
129
+ verify_payment_output!(transaction, required_sats, server_payee_hex)
124
130
  transaction
125
131
  end
126
132
 
@@ -169,14 +175,14 @@ module X402
169
175
  raise VerificationError, "nonce provenance check failed"
170
176
  end
171
177
 
172
- def verify_payment_output!(transaction, route, payee_hex)
178
+ def verify_payment_output!(transaction, required_sats, payee_hex)
173
179
  payee_script = payee_script_from_hex(payee_hex)
174
180
  found = transaction.outputs.any? do |output|
175
- output.locking_script == payee_script && output.satoshis >= route.amount_sats
181
+ output.locking_script == payee_script && output.satoshis >= required_sats
176
182
  end
177
183
  return if found
178
184
 
179
- raise VerificationError.new("no output pays >= #{route.amount_sats} sats to payee", status: 402)
185
+ raise VerificationError.new("no output pays >= #{required_sats} sats to payee", status: 402)
180
186
  end
181
187
 
182
188
  def check_mempool!(txid)
@@ -2,13 +2,22 @@
2
2
 
3
3
  module X402
4
4
  class Configuration
5
- Route = Struct.new(:http_method, :path, :amount_sats, keyword_init: true)
5
+ # Route holds a raw +amount_sats+ that may be an Integer or a callable.
6
+ # The +resolve_amount_sats+ method evaluates callables at access time,
7
+ # enabling fiat-denominated pricing with live exchange rates.
8
+ Route = Struct.new(:http_method, :path, :amount_sats, :arc_wait_for, keyword_init: true) do
9
+ # Resolves +amount_sats+ — if it's a callable (Proc/Lambda),
10
+ # it is evaluated each time to get the current sats amount.
11
+ def resolve_amount_sats
12
+ amount_sats.respond_to?(:call) ? amount_sats.call : amount_sats
13
+ end
14
+ end
6
15
 
7
16
  GATEWAY_METHODS = %i[challenge_headers proof_header_names settle!].freeze
8
17
 
9
18
  PAY_GATEWAY_KNOWN_OPTS = %i[
10
19
  arc_client payee_locking_script_hex arc_wait_for arc_timeout
11
- binding_mode wallet challenge_secret
20
+ binding_mode wallet challenge_secret settlement_worker
12
21
  ].freeze
13
22
 
14
23
  PROOF_GATEWAY_KNOWN_OPTS = %i[
@@ -27,7 +36,8 @@ module X402
27
36
  }.freeze
28
37
 
29
38
  attr_accessor :domain, :payee_locking_script_hex, :gateways,
30
- :arc_url, :arc_api_key, :arc_client
39
+ :arc_url, :arc_api_key, :arc_client, :server_wif,
40
+ :exchange_rate_provider
31
41
  attr_reader :routes, :gateway_specs
32
42
 
33
43
  def initialize
@@ -60,13 +70,38 @@ module X402
60
70
  @shared_arc_client ||= build_arc_client
61
71
  end
62
72
 
73
+ # Returns a memoised ProtoWallet built from +server_wif+.
74
+ # Used as the shared wallet for all gateways, providing per-payment
75
+ # derived addresses via BRC-42/43 key derivation.
76
+ #
77
+ # @return [BSV::Wallet::ProtoWallet, nil]
78
+ def shared_wallet
79
+ return if @server_wif.nil? || @server_wif.empty?
80
+
81
+ @shared_wallet ||= build_wallet
82
+ end
83
+
63
84
  # Register a protected route.
64
85
  #
65
86
  # @param method [String] HTTP method or "*" for any
66
87
  # @param path [String, Regexp] exact path or pattern
67
- # @param amount_sats [Integer] required payment in satoshis
68
- def protect(method:, path:, amount_sats:)
69
- @routes << Route.new(http_method: method.upcase, path: path, amount_sats: amount_sats)
88
+ # @param amount_sats [Integer, #call] required payment in satoshis.
89
+ # Accepts a static Integer or a callable (Proc/Lambda) that returns
90
+ # the current sats amount at challenge time. Use a callable for
91
+ # fiat-denominated pricing with live exchange rates.
92
+ # @param amount_usd [Numeric, nil] convenience — price in USD, resolved
93
+ # to sats at challenge time via +exchange_rate_provider+. Mutually
94
+ # exclusive with +amount_sats+.
95
+ # @param arc_wait_for [String, Symbol, nil] per-route ARC settlement override.
96
+ # +nil+ (default) uses the gateway's +arc_wait_for+ setting.
97
+ # A string value (e.g. +"SEEN_ON_NETWORK"+, +"MINED"+) overrides the
98
+ # gateway default for synchronous broadcast.
99
+ # +:async+ validates the transaction locally then enqueues it for
100
+ # background settlement via the gateway's +settlement_worker+, returning
101
+ # 200 immediately without waiting for ARC confirmation.
102
+ def protect(method:, path:, amount_sats: nil, amount_usd: nil, arc_wait_for: nil)
103
+ sats = resolve_amount(amount_sats, amount_usd)
104
+ @routes << Route.new(http_method: method.upcase, path: path, amount_sats: sats, arc_wait_for: arc_wait_for)
70
105
  end
71
106
 
72
107
  # Find the matching route for a request method and path.
@@ -82,10 +117,7 @@ module X402
82
117
  def validate!
83
118
  raise ConfigurationError, "domain is required" if domain.nil? || domain.empty?
84
119
 
85
- if payee_locking_script_hex.nil? || payee_locking_script_hex.empty?
86
- raise ConfigurationError,
87
- "payee_locking_script_hex is required"
88
- end
120
+ validate_payee_source!
89
121
  build_gateways_from_specs! if gateways.empty? && !gateway_specs.empty?
90
122
  validate_gateways!
91
123
  raise ConfigurationError, "at least one route must be protected" if routes.empty?
@@ -121,12 +153,44 @@ module X402
121
153
  end
122
154
  end
123
155
 
156
+ def resolve_amount(amount_sats, amount_usd)
157
+ raise ConfigurationError, "amount_sats and amount_usd are mutually exclusive" if amount_sats && amount_usd
158
+
159
+ return amount_sats if amount_sats
160
+
161
+ raise ConfigurationError, "protect requires amount_sats: or amount_usd:" unless amount_usd
162
+ unless exchange_rate_provider
163
+ raise ConfigurationError, "amount_usd requires exchange_rate_provider to be configured"
164
+ end
165
+ unless exchange_rate_provider.respond_to?(:sats_for)
166
+ raise ConfigurationError, "exchange_rate_provider must respond to #sats_for(currency, amount)"
167
+ end
168
+
169
+ provider = exchange_rate_provider
170
+ usd = amount_usd
171
+ -> { provider.sats_for("USD", usd) }
172
+ end
173
+
174
+ def validate_payee_source!
175
+ has_payee = payee_locking_script_hex && !payee_locking_script_hex.empty?
176
+ has_wallet = @server_wif && !@server_wif.empty?
177
+
178
+ return if has_payee || has_wallet
179
+
180
+ raise ConfigurationError, "server_wif or payee_locking_script_hex is required"
181
+ end
182
+
124
183
  def build_arc_client
125
184
  raise ConfigurationError, "arc_url is required (or inject arc_client directly)" if arc_url.nil? || arc_url.empty?
126
185
 
127
186
  ::BSV::Network::ARC.new(arc_url, api_key: arc_api_key)
128
187
  end
129
188
 
189
+ def build_wallet
190
+ key = ::BSV::Primitives::PrivateKey.from_wif(@server_wif)
191
+ ::BSV::Wallet::ProtoWallet.new(key)
192
+ end
193
+
130
194
  def build_gateways_from_specs!
131
195
  require_relative "bsv"
132
196
  require "bsv-wallet"
@@ -143,9 +207,11 @@ module X402
143
207
 
144
208
  def build_pay_gateway(klass, options)
145
209
  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|
210
+ wallet = options[:wallet] || shared_wallet
211
+ opts = { arc_client: options[:arc_client] || shared_arc_client }
212
+ opts[:payee_locking_script_hex] = options[:payee_locking_script_hex] || payee_locking_script_hex
213
+ opts[:wallet] = wallet if wallet
214
+ %i[arc_wait_for arc_timeout binding_mode challenge_secret settlement_worker].each do |key|
149
215
  opts[key] = options[key] if options.key?(key)
150
216
  end
151
217
  klass.new(**opts)
@@ -174,19 +240,22 @@ module X402
174
240
  raise ConfigurationError,
175
241
  "brc105_gateway: #{key_sources.join(", ")} are mutually exclusive — provide only one"
176
242
  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
243
 
182
244
  key_deriver = if options.key?(:key_deriver)
183
245
  options[:key_deriver]
184
246
  elsif options.key?(:server_wif)
185
247
  ::BSV::Wallet::KeyDeriver.new(::BSV::Primitives::PrivateKey.from_wif(options[:server_wif]))
186
- else
248
+ elsif options.key?(:server_key)
187
249
  ::BSV::Wallet::KeyDeriver.new(options[:server_key])
250
+ elsif shared_wallet
251
+ shared_wallet.key_deriver
188
252
  end
189
253
 
254
+ unless key_deriver
255
+ raise ConfigurationError,
256
+ "brc105_gateway requires one of: key_deriver:, server_wif:, server_key:, or top-level server_wif"
257
+ end
258
+
190
259
  klass.new(
191
260
  key_deriver: key_deriver,
192
261
  prefix_store: options[:prefix_store] || X402::BSV::PrefixStore::Memory.new,
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "bsv-sdk"
6
+
7
+ module X402
8
+ # Rack middleware that silently observes voluntary payment headers and
9
+ # enqueues them for settlement. Never gates access — requests always
10
+ # pass through regardless of payment presence or validity.
11
+ #
12
+ # Only enqueues transactions that contain at least one recognised output
13
+ # — the observer is not an open relay.
14
+ #
15
+ # == Static payee (simple)
16
+ #
17
+ # use X402::PaymentObserver,
18
+ # worker: settlement_worker,
19
+ # payee_locking_script_hex: "76a914...88ac"
20
+ #
21
+ # == Recogniser (derived addresses / payment channels)
22
+ #
23
+ # use X402::PaymentObserver,
24
+ # worker: settlement_worker,
25
+ # recogniser: session_tracker # responds to #ours?(locking_script_hex)
26
+ #
27
+ # The recogniser is duck-typed — any object responding to
28
+ # +#ours?(locking_script_hex)+ qualifies. For payment channels with
29
+ # BRC-29 derived addresses, the recogniser tracks which derived addresses
30
+ # belong to active sessions.
31
+ #
32
+ # Any object responding to +#enqueue(tx_binary)+ satisfies the worker
33
+ # interface (e.g. +X402::SettlementWorker+, a Sidekiq job, etc.).
34
+ class PaymentObserver
35
+ DEFAULT_PROOF_HEADERS = %w[Payment-Signature].freeze
36
+
37
+ # @param app [#call] next Rack app in the stack
38
+ # @param worker [#enqueue] settlement worker for background broadcast
39
+ # @param payee_locking_script_hex [String, nil] static payee script hex
40
+ # @param recogniser [#ours?, nil] object that recognises derived payment addresses.
41
+ # Takes precedence over +payee_locking_script_hex+ when both are provided.
42
+ # @param proof_headers [Array<String>] HTTP header names to watch for payments
43
+ # @param on_payment [#call, nil] optional callback invoked with the raw tx
44
+ # binary after successful enqueue, for application-level tracking
45
+ def initialize(app, worker:, payee_locking_script_hex: nil, recogniser: nil,
46
+ proof_headers: DEFAULT_PROOF_HEADERS, on_payment: nil)
47
+ @app = app
48
+ @worker = worker
49
+ @recogniser = build_recogniser(recogniser, payee_locking_script_hex)
50
+ @proof_headers = proof_headers
51
+ @on_payment = on_payment
52
+ end
53
+
54
+ def call(env)
55
+ observe_payment(env)
56
+ @app.call(env)
57
+ end
58
+
59
+ private
60
+
61
+ def build_recogniser(recogniser, payee_hex)
62
+ if recogniser
63
+ unless recogniser.respond_to?(:ours?)
64
+ raise ConfigurationError,
65
+ "PaymentObserver recogniser must respond to #ours?(locking_script_hex)"
66
+ end
67
+ return recogniser
68
+ end
69
+
70
+ unless payee_hex
71
+ raise ConfigurationError,
72
+ "PaymentObserver requires recogniser: or payee_locking_script_hex:"
73
+ end
74
+
75
+ StaticRecogniser.new(payee_hex)
76
+ end
77
+
78
+ def observe_payment(env)
79
+ @proof_headers.each do |header_name|
80
+ rack_key = "HTTP_#{header_name.upcase.tr("-", "_")}"
81
+ value = env[rack_key]
82
+ next if value.nil? || value.empty?
83
+
84
+ tx_binary = extract_and_validate(value)
85
+ next unless tx_binary
86
+
87
+ enqueue_payment(tx_binary)
88
+ break
89
+ end
90
+ end
91
+
92
+ def enqueue_payment(tx_binary)
93
+ @worker.enqueue(tx_binary)
94
+ @on_payment&.call(tx_binary)
95
+ rescue StandardError
96
+ # Never let enqueue/callback failures break the request pass-through
97
+ end
98
+
99
+ def extract_and_validate(proof_payload)
100
+ json = Base64.strict_decode64(proof_payload)
101
+ payload = JSON.parse(json)
102
+ rawtx_hex = payload.dig("payload", "rawtx")
103
+ return unless rawtx_hex
104
+
105
+ tx_binary = [rawtx_hex].pack("H*")
106
+ tx = ::BSV::Transaction::Transaction.from_binary(tx_binary)
107
+ return unless recognised?(tx)
108
+
109
+ tx_binary
110
+ rescue StandardError
111
+ nil
112
+ end
113
+
114
+ def recognised?(transaction)
115
+ transaction.outputs.any? do |output|
116
+ @recogniser.ours?(output.locking_script.to_hex)
117
+ end
118
+ end
119
+
120
+ # Built-in recogniser for a single static payee address.
121
+ # Used when +payee_locking_script_hex+ is provided without a custom recogniser.
122
+ class StaticRecogniser
123
+ def initialize(payee_locking_script_hex)
124
+ @payee_hex = ::BSV::Script::Script.from_hex(payee_locking_script_hex).to_hex
125
+ end
126
+
127
+ def ours?(locking_script_hex)
128
+ locking_script_hex == @payee_hex
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module X402
4
+ # Background thread that broadcasts transactions to ARC with exponential
5
+ # backoff retry. Uses Ruby stdlib Thread + Queue (zero dependencies).
6
+ #
7
+ # Any object responding to +#enqueue(tx_binary)+ satisfies the pluggable
8
+ # worker interface expected by PayGateway's async settlement path.
9
+ class SettlementWorker
10
+ ACCEPTABLE_STATUSES = %w[SEEN_ON_NETWORK ANNOUNCED_TO_NETWORK MINED].freeze
11
+
12
+ attr_reader :max_retries
13
+
14
+ def initialize(arc_client:, max_retries: 5)
15
+ @arc_client = arc_client
16
+ @max_retries = max_retries
17
+ @queue = Queue.new
18
+ @thread = nil
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def enqueue(tx_binary)
23
+ ensure_thread_running
24
+ @queue.push(tx_binary)
25
+ end
26
+
27
+ def stop
28
+ thread_to_join = nil
29
+ @mutex.synchronize do
30
+ return unless @thread&.alive?
31
+
32
+ @queue.push(:shutdown)
33
+ thread_to_join = @thread
34
+ @thread = nil
35
+ end
36
+ thread_to_join&.join
37
+ end
38
+
39
+ private
40
+
41
+ def ensure_thread_running
42
+ @mutex.synchronize do
43
+ @thread = nil unless @thread&.alive?
44
+ @thread ||= Thread.new { process_loop }
45
+ end
46
+ end
47
+
48
+ def process_loop
49
+ loop do
50
+ item = @queue.pop
51
+ break if item == :shutdown
52
+
53
+ broadcast_with_retry(item)
54
+ end
55
+ end
56
+
57
+ def broadcast_with_retry(tx_binary, attempt = 0)
58
+ result = @arc_client.broadcast(tx_binary, wait_for: "SEEN_ON_NETWORK")
59
+ return if ACCEPTABLE_STATUSES.include?(result.tx_status)
60
+ return if attempt >= @max_retries
61
+
62
+ sleep(2**attempt)
63
+ broadcast_with_retry(tx_binary, attempt + 1)
64
+ rescue StandardError
65
+ return if attempt >= @max_retries
66
+
67
+ sleep(2**attempt)
68
+ broadcast_with_retry(tx_binary, attempt + 1)
69
+ end
70
+ end
71
+ end
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.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/x402.rb CHANGED
@@ -5,6 +5,8 @@ require_relative "x402/errors"
5
5
  require_relative "x402/settlement_result"
6
6
  require_relative "x402/configuration"
7
7
  require_relative "x402/middleware"
8
+ require_relative "x402/payment_observer"
9
+ require_relative "x402/settlement_worker"
8
10
 
9
11
  module X402
10
12
  class << self
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: x402-rack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
@@ -120,11 +120,13 @@ files:
120
120
  - lib/x402/configuration.rb
121
121
  - lib/x402/errors.rb
122
122
  - lib/x402/middleware.rb
123
+ - lib/x402/payment_observer.rb
123
124
  - lib/x402/protocol/base64url.rb
124
125
  - lib/x402/protocol/challenge.rb
125
126
  - lib/x402/protocol/proof.rb
126
127
  - lib/x402/protocol/request_binding.rb
127
128
  - lib/x402/settlement_result.rb
129
+ - lib/x402/settlement_worker.rb
128
130
  - lib/x402/verification/protocol_checks.rb
129
131
  - lib/x402/version.rb
130
132
  - sig/x402.rbs