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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +4 -8
- data/lib/x402/bsv/brc105_gateway.rb +8 -5
- data/lib/x402/bsv/gateway.rb +2 -2
- data/lib/x402/bsv/pay_gateway.rb +38 -17
- data/lib/x402/bsv/proof_gateway.rb +14 -8
- data/lib/x402/configuration.rb +87 -18
- data/lib/x402/payment_observer.rb +132 -0
- data/lib/x402/settlement_worker.rb +71 -0
- data/lib/x402/version.rb +1 -1
- data/lib/x402.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1c49c723394e852eb4f825b4f849967895cddc8a787dad72a5b6b3959ab618d
|
|
4
|
+
data.tar.gz: c417200fd8a715c7795d673f8e0f10b6627502aa6ee04e679265d44e3bb66e80
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
|
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`** —
|
|
60
|
-
`
|
|
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.
|
|
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,
|
|
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,
|
|
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 >=
|
|
145
|
+
output.locking_script == expected_script && output.satoshis >= required_sats
|
|
145
146
|
end
|
|
146
147
|
return if found
|
|
147
148
|
|
|
148
|
-
raise VerificationError.new(
|
|
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)
|
data/lib/x402/bsv/gateway.rb
CHANGED
|
@@ -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,
|
|
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:
|
|
54
|
+
satoshis: required_sats,
|
|
55
55
|
locking_script: payee_script
|
|
56
56
|
))
|
|
57
57
|
|
data/lib/x402/bsv/pay_gateway.rb
CHANGED
|
@@ -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,
|
|
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,
|
|
61
|
+
verify_payment_output!(transaction, required_sats, accepted_payee)
|
|
58
62
|
verify_binding!(transaction, rack_request)
|
|
59
|
-
|
|
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
|
-
|
|
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,
|
|
83
|
+
"accepts" => [build_accept_entry(payee_hex, required_sats, template)]
|
|
79
84
|
}
|
|
80
85
|
end
|
|
81
86
|
|
|
82
|
-
def build_accept_entry(payee_hex,
|
|
87
|
+
def build_accept_entry(payee_hex, required_sats, template)
|
|
83
88
|
{
|
|
84
89
|
"scheme" => SCHEME,
|
|
85
90
|
"network" => NETWORK,
|
|
86
|
-
"amount" =>
|
|
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,
|
|
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 <
|
|
117
|
+
return unless amount < required_sats
|
|
113
118
|
|
|
114
|
-
raise VerificationError.new("insufficient amount: #{amount} < #{
|
|
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,
|
|
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 >=
|
|
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 >= #{
|
|
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
|
-
|
|
152
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
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,
|
|
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,
|
|
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,
|
|
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 >=
|
|
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 >= #{
|
|
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)
|
data/lib/x402/configuration.rb
CHANGED
|
@@ -2,13 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module X402
|
|
4
4
|
class Configuration
|
|
5
|
-
Route
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
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.
|
|
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
|