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 +4 -4
- data/CHANGELOG.md +19 -0
- data/lib/x402/bsv/brc105_gateway.rb +11 -2
- data/lib/x402/bsv/pay_gateway.rb +17 -3
- data/lib/x402/bsv/txid_store.rb +69 -0
- data/lib/x402/bsv.rb +1 -0
- data/lib/x402/configuration.rb +33 -2
- data/lib/x402/protocol/challenge.rb +13 -6
- data/lib/x402/settlement_worker.rb +39 -5
- data/lib/x402/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '04961db3ca1280873324aa191fc39d968e52b79ff90819569851811354106398'
|
|
4
|
+
data.tar.gz: 4a90ebc4e88fc7c55412059687d19751ffb58db90767973a18a666d560a6020d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
98
|
-
|
|
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)
|
data/lib/x402/bsv/pay_gateway.rb
CHANGED
|
@@ -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: :
|
|
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
|
-
|
|
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
data/lib/x402/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
+
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-
|
|
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
|