x402-rack 0.4.0 → 0.5.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: d1c49c723394e852eb4f825b4f849967895cddc8a787dad72a5b6b3959ab618d
4
- data.tar.gz: c417200fd8a715c7795d673f8e0f10b6627502aa6ee04e679265d44e3bb66e80
3
+ metadata.gz: '04961db3ca1280873324aa191fc39d968e52b79ff90819569851811354106398'
4
+ data.tar.gz: 4a90ebc4e88fc7c55412059687d19751ffb58db90767973a18a666d560a6020d
5
5
  SHA512:
6
- metadata.gz: 4b2a7277eea92f8f546fd45730ad860d239ec5df6592854942d214cc43415382f5f9adf33185cf871a81f971bd447e1dd804007597c9aa26a978d12cd6503728
7
- data.tar.gz: 5143cd4fae5666792ab3338344ff7b28269b5544e353965d7c5e19be3395de2a68cbb07e8d1050840e0a97a58336694c89a6a0fbf6926066ae56b027b06b4f41
6
+ metadata.gz: 7996d498aa4230ad84a2b02912c05347ac53830dd159ff36666a92268f6eb4dcbcf5e9acacb60c1be8fc8f1b930d6e7a942f852c6e4f87c4908022e314b59130
7
+ data.tar.gz: a75ee1bd1e2017b53aab6a204164825c6ffc06bd9ee6f312f2b5c7f560178e96afaf220199bf51b6fbc63f1fa7243e3a2360426774b899418ff408e66826c7e8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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.5.0] - 2026-04-04
9
+
10
+ ### Changed
11
+
12
+ - **Default `binding_mode` now `:strict`** — OP_RETURN request binding enforced by default. Set `binding_mode: :permissive` to restore previous behaviour.
13
+
14
+ ### Added
15
+
16
+ - **Txid deduplication store** — `record_if_unseen!` prevents double-processing of the same transaction across settlement and observer paths.
17
+
18
+ ### Fixed
19
+
20
+ - **Security audit quick wins** — handle numeric JSON amount, non-string derivation components (H-3, M-2, M-4, M-5).
21
+ - **SettlementWorker hardening** — `on_failure` callback, capped queue via Queue with size check (replaces SizedQueue), exponential backoff improvements.
22
+ - **Atomic txid deduplication** — `record_if_unseen!` provides thread-safe check-and-insert in a single call.
23
+ - **Runtime warnings scoped** — warnings emitted only for relevant gateways, check value not just key presence.
24
+ - **Production environment warnings** — warn on ephemeral `challenge_secret` and in-memory `PrefixStore`.
25
+
8
26
  ## [0.4.0] - 2026-04-03
9
27
 
10
28
  ### Added
@@ -109,6 +127,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
109
127
  - **Operations docs** — deployment, performance, treasury/nonce lifecycle.
110
128
  - **Ecosystem docs** — Coinbase v2, merkleworks, BRC-105 positioning and header namespace reservations.
111
129
 
130
+ [0.5.0]: https://github.com/sgbett/x402-rack/compare/v0.4.0...v0.5.0
112
131
  [0.4.0]: https://github.com/sgbett/x402-rack/compare/v0.3.0...v0.4.0
113
132
  [0.3.0]: https://github.com/sgbett/x402-rack/compare/v0.2.0...v0.3.0
114
133
  [0.2.0]: https://github.com/sgbett/x402-rack/compare/v0.1.0...v0.2.0
@@ -23,6 +23,7 @@ module X402
23
23
  PROOF_HEADER = "x-bsv-payment"
24
24
  NETWORK = "bsv:mainnet"
25
25
  COMPRESSED_PUBKEY_HEX = /\A0[23][0-9a-fA-F]{64}\z/
26
+ MAX_DERIVATION_BYTES = 64
26
27
 
27
28
  # @param key_deriver [BSV::Wallet::KeyDeriver] provides identity key + BRC-42 derivation
28
29
  # @param prefix_store [#store!, #valid?, #consume!] replay protection for derivation prefixes
@@ -94,8 +95,16 @@ module X402
94
95
  end
95
96
 
96
97
  def validate_prefix_and_suffix!(prefix, suffix)
97
- raise VerificationError.new("missing derivationPrefix", status: 400) if prefix.nil? || prefix.empty?
98
- raise VerificationError.new("missing derivationSuffix", status: 400) if suffix.nil? || suffix.empty?
98
+ validate_derivation_component!("derivationPrefix", prefix)
99
+ validate_derivation_component!("derivationSuffix", suffix)
100
+ end
101
+
102
+ def validate_derivation_component!(name, value)
103
+ raise VerificationError.new("missing #{name}", status: 400) if value.nil?
104
+ unless value.is_a?(String) && !value.empty? &&
105
+ value.bytesize <= MAX_DERIVATION_BYTES && value.match?(/\A[0-9a-f]+\z/)
106
+ raise VerificationError.new("invalid #{name} format", status: 400)
107
+ end
99
108
  end
100
109
 
101
110
  def consume_prefix!(prefix)
@@ -28,10 +28,12 @@ module X402
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
30
  # @param settlement_worker [#enqueue, nil] async settlement worker
31
+ # @param txid_store [#seen?, #record!, nil] optional txid deduplication store.
32
+ # When provided, rejects proofs whose txid has already been settled.
31
33
  def initialize(arc_client:, arc_wait_for: DEFAULT_ARC_WAIT_FOR,
32
- arc_timeout: DEFAULT_ARC_TIMEOUT, binding_mode: :permissive,
34
+ arc_timeout: DEFAULT_ARC_TIMEOUT, binding_mode: :strict,
33
35
  payee_locking_script_hex: nil, wallet: nil, challenge_secret: nil,
34
- settlement_worker: nil)
36
+ settlement_worker: nil, txid_store: nil)
35
37
  super(payee_locking_script_hex: payee_locking_script_hex, wallet: wallet,
36
38
  challenge_secret: challenge_secret)
37
39
  @arc_client = arc_client
@@ -39,6 +41,7 @@ module X402
39
41
  @arc_timeout = arc_timeout
40
42
  @binding_mode = binding_mode
41
43
  @settlement_worker = settlement_worker
44
+ @txid_store = txid_store
42
45
  end
43
46
 
44
47
  def challenge_headers(rack_request, route)
@@ -58,6 +61,7 @@ module X402
58
61
  pay_to_sig = payload.dig("accepted", "extra", "payToSig")
59
62
  verify_pay_to_signature!(accepted_payee, pay_to_sig)
60
63
  transaction = decode_transaction(payload)
64
+ check_txid_unique!(transaction)
61
65
  verify_payment_output!(transaction, required_sats, accepted_payee)
62
66
  verify_binding!(transaction, rack_request)
63
67
  settle_transaction!(transaction, route)
@@ -113,10 +117,13 @@ module X402
113
117
  raise VerificationError.new("network mismatch: expected #{NETWORK}", status: 400)
114
118
  end
115
119
 
116
- amount = accepted["amount"].to_i
120
+ raw_amount = accepted["amount"]
121
+ amount = raw_amount.is_a?(Integer) ? raw_amount : Integer(raw_amount, 10)
117
122
  return unless amount < required_sats
118
123
 
119
124
  raise VerificationError.new("insufficient amount: #{amount} < #{required_sats}", status: 402)
125
+ rescue ArgumentError, TypeError
126
+ raise VerificationError.new("invalid amount field", status: 400)
120
127
  end
121
128
 
122
129
  def decode_transaction(payload)
@@ -141,6 +148,13 @@ module X402
141
148
  raise VerificationError.new("no output pays >= #{required_sats} sats to payee", status: 402)
142
149
  end
143
150
 
151
+ def check_txid_unique!(transaction)
152
+ return unless @txid_store
153
+ return if @txid_store.record_if_unseen!(transaction.txid_hex)
154
+
155
+ raise VerificationError.new("transaction already settled", status: 400)
156
+ end
157
+
144
158
  def verify_binding!(transaction, rack_request)
145
159
  expected_hex = "006a047834303220#{request_binding_hash(rack_request).unpack1("H*")}"
146
160
  found = transaction.outputs.any? do |output|
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module X402
6
+ module BSV
7
+ # Pluggable txid deduplication store for PayGateway settlement.
8
+ #
9
+ # Prevents the same transaction from being accepted twice.
10
+ # Duck-type contract — any backend must implement:
11
+ # record_if_unseen!(txid) — atomically records the txid and returns true
12
+ # if it was not already seen. Returns false if already recorded.
13
+ module TxidStore
14
+ # In-memory backend suitable for development and single-process deployments.
15
+ # Thread-safe via Monitor. Entries expire after +ttl+ seconds.
16
+ #
17
+ # NOTE: This store provides no deduplication across OS processes.
18
+ # Production multi-process deployments should use a shared backend.
19
+ class Memory
20
+ DEFAULT_TTL = 600
21
+ DEFAULT_MAX_SIZE = 10_000
22
+
23
+ # @param ttl [Integer] seconds before a seen txid expires (default 600)
24
+ # @param max_size [Integer] cap on stored txids (default 10_000)
25
+ def initialize(ttl: DEFAULT_TTL, max_size: DEFAULT_MAX_SIZE)
26
+ @entries = {}
27
+ @monitor = Monitor.new
28
+ @ttl = ttl
29
+ @max_size = max_size
30
+ end
31
+
32
+ # Atomically checks and records a txid.
33
+ # Returns true if the txid was new (now recorded).
34
+ # Returns false if already seen (duplicate).
35
+ def record_if_unseen!(txid)
36
+ @monitor.synchronize do
37
+ purge_expired!
38
+
39
+ entry = @entries[txid]
40
+ return false if entry && !expired?(entry)
41
+
42
+ @entries[txid] = monotonic_now
43
+ evict_oldest! if @entries.size > @max_size
44
+ true
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def purge_expired!
51
+ @entries.delete_if { |_, recorded_at| (monotonic_now - recorded_at) > @ttl }
52
+ end
53
+
54
+ def evict_oldest!
55
+ oldest_key = @entries.min_by { |_, recorded_at| recorded_at }&.first
56
+ @entries.delete(oldest_key) if oldest_key
57
+ end
58
+
59
+ def expired?(recorded_at)
60
+ (monotonic_now - recorded_at) > @ttl
61
+ end
62
+
63
+ def monotonic_now
64
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/x402/bsv.rb CHANGED
@@ -5,3 +5,4 @@ require_relative "bsv/pay_gateway"
5
5
  require_relative "bsv/proof_gateway"
6
6
  require_relative "bsv/brc105_gateway"
7
7
  require_relative "bsv/prefix_store"
8
+ require_relative "bsv/txid_store"
@@ -17,7 +17,7 @@ module X402
17
17
 
18
18
  PAY_GATEWAY_KNOWN_OPTS = %i[
19
19
  arc_client payee_locking_script_hex arc_wait_for arc_timeout
20
- binding_mode wallet challenge_secret settlement_worker
20
+ binding_mode wallet challenge_secret settlement_worker txid_store
21
21
  ].freeze
22
22
 
23
23
  PROOF_GATEWAY_KNOWN_OPTS = %i[
@@ -120,11 +120,42 @@ module X402
120
120
  validate_payee_source!
121
121
  build_gateways_from_specs! if gateways.empty? && !gateway_specs.empty?
122
122
  validate_gateways!
123
+ warn_operational_concerns!
123
124
  raise ConfigurationError, "at least one route must be protected" if routes.empty?
124
125
  end
125
126
 
126
127
  private
127
128
 
129
+ def warn_operational_concerns!
130
+ return if gateway_specs.empty? # gateways= was used directly — specs not relevant
131
+
132
+ warn_ephemeral_challenge_secret!
133
+ warn_in_memory_prefix_store!
134
+ end
135
+
136
+ def warn_ephemeral_challenge_secret!
137
+ secret_gateways = %w[X402::BSV::PayGateway X402::BSV::ProofGateway]
138
+ needs_warning = gateway_specs.any? do |class_name, options|
139
+ secret_gateways.include?(class_name) && !options[:challenge_secret]
140
+ end
141
+ return unless needs_warning
142
+
143
+ warn "[x402] challenge_secret is auto-generated. " \
144
+ "In-flight challenges will fail after process restart. " \
145
+ "Set challenge_secret: explicitly for production."
146
+ end
147
+
148
+ def warn_in_memory_prefix_store!
149
+ needs_warning = gateway_specs.any? do |class_name, options|
150
+ class_name == "X402::BSV::BRC105Gateway" && !options[:prefix_store]
151
+ end
152
+ return unless needs_warning
153
+
154
+ warn "[x402] BRC105Gateway using in-memory PrefixStore. " \
155
+ "No replay protection across processes. " \
156
+ "Use a shared backend (Redis) for multi-process deployments."
157
+ end
158
+
128
159
  def validate_gateways!
129
160
  raise ConfigurationError, "at least one gateway is required" if gateways.nil? || gateways.empty?
130
161
 
@@ -211,7 +242,7 @@ module X402
211
242
  opts = { arc_client: options[:arc_client] || shared_arc_client }
212
243
  opts[:payee_locking_script_hex] = options[:payee_locking_script_hex] || payee_locking_script_hex
213
244
  opts[:wallet] = wallet if wallet
214
- %i[arc_wait_for arc_timeout binding_mode challenge_secret settlement_worker].each do |key|
245
+ %i[arc_wait_for arc_timeout binding_mode challenge_secret settlement_worker txid_store].each do |key|
215
246
  opts[key] = options[key] if options.key?(key)
216
247
  end
217
248
  klass.new(**opts)
@@ -9,14 +9,21 @@ module X402
9
9
  SUPPORTED_SCHEMES = ["bsv-tx-v1"].freeze
10
10
  DEFAULT_TTL = 300 # 5 minutes
11
11
 
12
- attr_reader :version, :scheme, :domain, :method, :path, :query,
13
- :req_headers_sha256, :req_body_sha256,
14
- :amount_sats, :payee_locking_script_hex,
15
- :nonce_txid, :nonce_vout, :nonce_satoshis, :nonce_locking_script_hex,
16
- :expires_at, :partial_tx_b64, :payee_sig
12
+ KNOWN_ATTRS = %i[
13
+ version scheme domain method path query
14
+ req_headers_sha256 req_body_sha256
15
+ amount_sats payee_locking_script_hex
16
+ nonce_txid nonce_vout nonce_satoshis nonce_locking_script_hex
17
+ expires_at partial_tx_b64 payee_sig
18
+ ].freeze
19
+
20
+ attr_reader(*KNOWN_ATTRS)
17
21
 
18
22
  def initialize(attrs = {})
19
- attrs.each { |k, v| instance_variable_set(:"@#{k}", v) }
23
+ attrs.each do |k, v|
24
+ key = k.to_sym
25
+ instance_variable_set(:"@#{key}", v) if KNOWN_ATTRS.include?(key)
26
+ end
20
27
  end
21
28
 
22
29
  def to_h
@@ -6,20 +6,41 @@ module X402
6
6
  #
7
7
  # Any object responding to +#enqueue(tx_binary)+ satisfies the pluggable
8
8
  # worker interface expected by PayGateway's async settlement path.
9
+ #
10
+ # @example With failure callback
11
+ # worker = X402::SettlementWorker.new(
12
+ # arc_client: arc,
13
+ # on_failure: ->(tx_binary, error) { logger.error("Settlement failed: #{error}") }
14
+ # )
9
15
  class SettlementWorker
10
16
  ACCEPTABLE_STATUSES = %w[SEEN_ON_NETWORK ANNOUNCED_TO_NETWORK MINED].freeze
17
+ DEFAULT_MAX_QUEUE = 1000
11
18
 
12
- attr_reader :max_retries
19
+ attr_reader :max_retries, :max_queue
13
20
 
14
- def initialize(arc_client:, max_retries: 5)
21
+ # @param arc_client [#broadcast] ARC client for broadcasting
22
+ # @param max_retries [Integer] retry attempts before giving up (default 5)
23
+ # @param max_queue [Integer] maximum queued transactions (default 1000).
24
+ # Raises +X402::VerificationError+ (503) when full.
25
+ # @param on_failure [#call, nil] callback invoked with +(tx_binary, error)+
26
+ # when all retries are exhausted. Default nil (silent drop).
27
+ def initialize(arc_client:, max_retries: 5, max_queue: DEFAULT_MAX_QUEUE, on_failure: nil)
15
28
  @arc_client = arc_client
16
29
  @max_retries = max_retries
30
+ @max_queue = max_queue
31
+ @on_failure = on_failure
17
32
  @queue = Queue.new
18
33
  @thread = nil
19
34
  @mutex = Mutex.new
20
35
  end
21
36
 
37
+ # Enqueue a transaction for background broadcast.
38
+ #
39
+ # @param tx_binary [String] raw transaction bytes
40
+ # @raise [X402::VerificationError] with status 503 when queue is full
22
41
  def enqueue(tx_binary)
42
+ raise VerificationError.new("settlement queue full — try again later", status: 503) if @queue.size >= @max_queue
43
+
23
44
  ensure_thread_running
24
45
  @queue.push(tx_binary)
25
46
  end
@@ -57,15 +78,28 @@ module X402
57
78
  def broadcast_with_retry(tx_binary, attempt = 0)
58
79
  result = @arc_client.broadcast(tx_binary, wait_for: "SEEN_ON_NETWORK")
59
80
  return if ACCEPTABLE_STATUSES.include?(result.tx_status)
60
- return if attempt >= @max_retries
81
+
82
+ if attempt >= @max_retries
83
+ notify_failure(tx_binary, "status #{result.tx_status} after #{attempt + 1} attempts")
84
+ return
85
+ end
61
86
 
62
87
  sleep(2**attempt)
63
88
  broadcast_with_retry(tx_binary, attempt + 1)
64
- rescue StandardError
65
- return if attempt >= @max_retries
89
+ rescue StandardError => e
90
+ if attempt >= @max_retries
91
+ notify_failure(tx_binary, e)
92
+ return
93
+ end
66
94
 
67
95
  sleep(2**attempt)
68
96
  broadcast_with_retry(tx_binary, attempt + 1)
69
97
  end
98
+
99
+ def notify_failure(tx_binary, error)
100
+ @on_failure&.call(tx_binary, error)
101
+ rescue StandardError
102
+ # Never let callback failures crash the worker thread
103
+ end
70
104
  end
71
105
  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.4.0"
4
+ VERSION = "0.5.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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-02 00:00:00.000000000 Z
10
+ date: 2026-04-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -117,6 +117,7 @@ files:
117
117
  - lib/x402/bsv/pay_gateway.rb
118
118
  - lib/x402/bsv/prefix_store.rb
119
119
  - lib/x402/bsv/proof_gateway.rb
120
+ - lib/x402/bsv/txid_store.rb
120
121
  - lib/x402/configuration.rb
121
122
  - lib/x402/errors.rb
122
123
  - lib/x402/middleware.rb