openreceive-server 0.2.1
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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +46 -0
- data/lib/openreceive/server/client_ip.rb +73 -0
- data/lib/openreceive/server/config.rb +46 -0
- data/lib/openreceive/server/errors.rb +221 -0
- data/lib/openreceive/server/lsc_uri.rb +104 -0
- data/lib/openreceive/server/rack_app.rb +92 -0
- data/lib/openreceive/server/reconciliation.rb +55 -0
- data/lib/openreceive/server/request_handler.rb +598 -0
- data/lib/openreceive/server/service.rb +775 -0
- data/lib/openreceive/server/swap/assets.rb +88 -0
- data/lib/openreceive/server/swap/fixedfloat.rb +831 -0
- data/lib/openreceive/server/swap/rates_feed.rb +311 -0
- data/lib/openreceive/server/swap/transient_cache.rb +106 -0
- data/lib/openreceive/server/swap/weight_budget.rb +115 -0
- data/lib/openreceive/server/swap.rb +141 -0
- data/lib/openreceive/server/version.rb +7 -0
- data/lib/openreceive/server/wallet_info.rb +69 -0
- data/lib/openreceive/server.rb +39 -0
- metadata +83 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenReceive
|
|
4
|
+
module Server
|
|
5
|
+
# Terminal-transition decisions for one non-settled reconciliation result.
|
|
6
|
+
# Mirrors spec/test-vectors/attempt-reconciliation.json exactly: closure of
|
|
7
|
+
# an unpaid attempt requires a successful wallet scan observed at or after
|
|
8
|
+
# expiry plus the grace window — a local clock alone never closes a row.
|
|
9
|
+
module Reconciliation
|
|
10
|
+
# Seconds past an attempt's expiry during which reconciliation still scans
|
|
11
|
+
# for a settlement before closing the attempt. Covers clock skew and
|
|
12
|
+
# wallets that accept a payment moments after nominal invoice expiry.
|
|
13
|
+
# The value 900 is pinned by spec/test-vectors/attempt-reconciliation.json
|
|
14
|
+
# ("expiry_grace_seconds", asserted by tools/conformance/ruby-crosslang.rb)
|
|
15
|
+
# and mirrored by JS OPENRECEIVE_ATTEMPT_EXPIRY_GRACE_SECONDS.
|
|
16
|
+
EXPIRY_GRACE_SECONDS = 900
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Returns { "status" =>, "reason" => } to persist, or nil to keep the
|
|
21
|
+
# attempt pending. Settled results never reach this decision; they deliver
|
|
22
|
+
# settlement instead. transaction_state is the explicit state field on the
|
|
23
|
+
# wallet's transaction record, when the scan found one; it decides whether
|
|
24
|
+
# a pending result past expiry plus grace is an operator-attention case or
|
|
25
|
+
# just an abandoned invoice.
|
|
26
|
+
def transition(expires_at:, status:, observed_at:, transaction_state: nil)
|
|
27
|
+
case status.to_s
|
|
28
|
+
when "failed"
|
|
29
|
+
{ "status" => "failed", "reason" => "wallet_reported_failed" }
|
|
30
|
+
when "expired"
|
|
31
|
+
{ "status" => "expired", "reason" => "wallet_reported_expired" }
|
|
32
|
+
when "not_found", "pending"
|
|
33
|
+
# The invoice may outlive the requested expiry, so closure waits for a
|
|
34
|
+
# scan past expiry plus grace instead of trusting the local clock alone.
|
|
35
|
+
return nil if Integer(observed_at) < Integer(expires_at) + EXPIRY_GRACE_SECONDS
|
|
36
|
+
|
|
37
|
+
if status.to_s == "not_found"
|
|
38
|
+
{ "status" => "expired", "reason" => "not_found_after_expiry" }
|
|
39
|
+
elsif %w[pending accepted].include?(transaction_state.to_s)
|
|
40
|
+
# `attention` requires the wallet's EXPLICIT claim that the
|
|
41
|
+
# transaction is still in flight long after expiry.
|
|
42
|
+
{ "status" => "attention", "reason" => "unsettled_after_expiry" }
|
|
43
|
+
else
|
|
44
|
+
# NIP-47 state fields are optional and the unpaid scan lists unpaid
|
|
45
|
+
# invoices, so a state-less record is indistinguishable from an
|
|
46
|
+
# ordinary abandoned invoice — close it as expired.
|
|
47
|
+
{ "status" => "expired", "reason" => "no_finality_after_expiry" }
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
raise ArgumentError, "unexpected reconciliation status: #{status}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openreceive"
|
|
7
|
+
|
|
8
|
+
module OpenReceive
|
|
9
|
+
module Server
|
|
10
|
+
class RequestHandler
|
|
11
|
+
# Spec-declared caps, mirroring the OpenAPI request schemas and the JS
|
|
12
|
+
# handler exactly (the second settlement engine must not drift).
|
|
13
|
+
MAX_REFERENCE_LENGTH = 200
|
|
14
|
+
MAX_MEMO_LENGTH = 500
|
|
15
|
+
MAX_BODY_BYTES = 64 * 1024
|
|
16
|
+
# Declared fields per route (additionalProperties: false, snake_case
|
|
17
|
+
# only — camelCase aliases are rejected, matching JS).
|
|
18
|
+
ROUTE_BODY_FIELDS = {
|
|
19
|
+
"checkout.prepare" => %w[reference],
|
|
20
|
+
"checkout.create" => %w[reference memo metadata],
|
|
21
|
+
"payment.check" => %w[reference payment_hash],
|
|
22
|
+
"swap.quote" => %w[reference pay_in_asset],
|
|
23
|
+
"swap.create" => %w[reference pay_in_asset memo metadata],
|
|
24
|
+
"swap.read" => %w[reference payment_hash],
|
|
25
|
+
"swap.refund" => %w[reference payment_hash refund_address]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# `client_ip` (a proc receiving the framework request) attributes payer
|
|
29
|
+
# IPs for rate limiting and for stamping committed attempt rows; when it
|
|
30
|
+
# returns nil the limiter fails open for that request.
|
|
31
|
+
def initialize(service:, authorize:, resolve_checkout:, on_checkout_created:, on_paid:,
|
|
32
|
+
rate_limit: nil, client_ip: nil)
|
|
33
|
+
raise ArgumentError, "authorize is required" if authorize.nil?
|
|
34
|
+
raise ArgumentError, "resolve_checkout is required" if resolve_checkout.nil?
|
|
35
|
+
raise ArgumentError, "on_checkout_created is required" if on_checkout_created.nil?
|
|
36
|
+
raise ArgumentError, "on_paid is required" if on_paid.nil?
|
|
37
|
+
@service = service
|
|
38
|
+
@authorize = authorize
|
|
39
|
+
@resolve_checkout = resolve_checkout
|
|
40
|
+
@on_checkout_created = on_checkout_created
|
|
41
|
+
@on_paid = on_paid
|
|
42
|
+
@rate_limit = rate_limit
|
|
43
|
+
@client_ip = client_ip
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def prepare_checkout(raw_body:, request:, request_id:)
|
|
47
|
+
handle(request_id) do
|
|
48
|
+
body = parse(raw_body, "checkout.prepare", request: request)
|
|
49
|
+
reference = required_reference(body)
|
|
50
|
+
guard("checkout.prepare", request, { reference: reference })
|
|
51
|
+
resolved = resolve_host("checkout.prepare", request, reference, body)
|
|
52
|
+
prepared = @service.prepare_checkout("amount" => required_amount(resolved))
|
|
53
|
+
success(200, prepared.merge("reference" => reference), request_id)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def create_checkout(raw_body:, request:, request_id:)
|
|
58
|
+
handle(request_id) do
|
|
59
|
+
body = parse(raw_body, "checkout.create", request: request)
|
|
60
|
+
reference = required_reference(body)
|
|
61
|
+
authorize!("checkout.create", request, { reference: reference })
|
|
62
|
+
resolved = resolve_host("checkout.create", request, reference, body)
|
|
63
|
+
# Rate limits meter minting only: re-serving an already-committed
|
|
64
|
+
# attempt costs no wallet call, so a capped payer can still re-fetch
|
|
65
|
+
# instructions they were already given (mirrors JS).
|
|
66
|
+
enforce_rate_limit!("checkout.create", request, { reference: reference }) unless resolved["payment_hash"]
|
|
67
|
+
checkout = if resolved["payment_hash"]
|
|
68
|
+
committed_checkout(reference, resolved)
|
|
69
|
+
else
|
|
70
|
+
@service.create_checkout(
|
|
71
|
+
"reference" => reference, "amount" => required_amount(resolved),
|
|
72
|
+
"memo" => validated_memo(body), "metadata" => body["metadata"]
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
commit(checkout, nil, request) unless resolved["payment_hash"]
|
|
76
|
+
success(201, { "checkout" => checkout }, request_id)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Storage-aware callers (the Rails engine) pass `reconcile_pass` — the
|
|
81
|
+
# request-level gated reconcile result ({ "reason" => "ran", "checks" }
|
|
82
|
+
# or a skip reason) — plus `attempt_status`, a proc mapping a payment
|
|
83
|
+
# hash to its persisted { "status", "paid_at"? }. The requested hash is
|
|
84
|
+
# then served from the pass (winner) or the host row (gate_busy / outside
|
|
85
|
+
# the pending set / disabled) with `details` omitted — never a second
|
|
86
|
+
# per-invoice wallet walk and never a second gate claim. Row `attention`
|
|
87
|
+
# serves as `pending` on the wire (operator state, not payer
|
|
88
|
+
# information); the row path never emits `not_found`. Storage-agnostic
|
|
89
|
+
# callers (Server::RackApp) omit both and run a one-attempt
|
|
90
|
+
# reconcile_payments walk.
|
|
91
|
+
def check_payment(raw_body:, request:, request_id:, reconcile_pass: nil, attempt_status: nil)
|
|
92
|
+
handle(request_id) do
|
|
93
|
+
body = parse(raw_body, "payment.check", request: request)
|
|
94
|
+
reference = required_reference(body)
|
|
95
|
+
# Payer input is shape-validated BEFORE any host hook runs (mirrors
|
|
96
|
+
# JS): guard/resolve never see an un-vetted selector.
|
|
97
|
+
requested_hash = required_payment_hash(body["payment_hash"])
|
|
98
|
+
guard("payment.check", request, { reference: reference, payment_hash: requested_hash })
|
|
99
|
+
resolved = resolve_host("payment.check", request, reference, body)
|
|
100
|
+
hash = selected_payment_hash(resolved, requested_hash)
|
|
101
|
+
checkout = committed_checkout(reference, resolved)
|
|
102
|
+
public_checked =
|
|
103
|
+
if reconcile_pass.nil?
|
|
104
|
+
checked_via_wallet(hash, checkout)
|
|
105
|
+
else
|
|
106
|
+
checked_from_pass(hash, reconcile_pass, attempt_status)
|
|
107
|
+
end
|
|
108
|
+
# Catalog warms on the first check; clients keep "Loading currencies…"
|
|
109
|
+
# until payment_methods is present (even as an empty Lightning-only
|
|
110
|
+
# list). Amount-aware, like the JS handler: limits are evaluated
|
|
111
|
+
# against this attempt's committed invoice amount.
|
|
112
|
+
payment_methods = @service.list_swap_options(amount_msats: checkout["amount_msats"])
|
|
113
|
+
success(200, public_checked.merge("payment_methods" => payment_methods), request_id)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def quote_swap(raw_body:, request:, request_id:)
|
|
118
|
+
handle(request_id) do
|
|
119
|
+
body = parse(raw_body, "swap.quote", request: request)
|
|
120
|
+
reference = required_reference(body)
|
|
121
|
+
asset = required(body["pay_in_asset"], "pay_in_asset")
|
|
122
|
+
guard("swap.quote", request, { reference: reference })
|
|
123
|
+
resolved = resolve_host("swap.quote", request, reference, body, asset)
|
|
124
|
+
success(200, @service.quote_swap("amount" => required_amount(resolved), "pay_in_asset" => asset), request_id)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def create_swap(raw_body:, request:, request_id:)
|
|
129
|
+
handle(request_id) do
|
|
130
|
+
body = parse(raw_body, "swap.create", request: request)
|
|
131
|
+
reference = required_reference(body)
|
|
132
|
+
asset = required(body["pay_in_asset"], "pay_in_asset")
|
|
133
|
+
authorize!("swap.create", request, { reference: reference })
|
|
134
|
+
resolved = resolve_host("swap.create", request, reference, body, asset)
|
|
135
|
+
enforce_rate_limit!("swap.create", request, { reference: reference }) unless resolved["payment_hash"]
|
|
136
|
+
swap = if resolved["payment_hash"]
|
|
137
|
+
data = required_swap_data(resolved["swap_data"])
|
|
138
|
+
status = @service.get_swap(
|
|
139
|
+
reference: reference, payment_hash: resolved["payment_hash"], swap_data: data
|
|
140
|
+
)
|
|
141
|
+
status.merge(
|
|
142
|
+
"checkout" => committed_checkout(reference, resolved),
|
|
143
|
+
"swap_data" => data
|
|
144
|
+
)
|
|
145
|
+
else
|
|
146
|
+
# Explicit, validated fields only: the raw payer body must
|
|
147
|
+
# never reach the service (an "expiry_seconds" key would beat
|
|
148
|
+
# the provider-mandated shadow-invoice expiry, and duplicate
|
|
149
|
+
# order keys would split authorization from minting).
|
|
150
|
+
@service.create_swap(
|
|
151
|
+
"reference" => reference,
|
|
152
|
+
"amount" => required_amount(resolved),
|
|
153
|
+
"pay_in_asset" => asset,
|
|
154
|
+
"memo" => validated_memo(body),
|
|
155
|
+
"metadata" => body["metadata"]
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
commit(swap.fetch("checkout"), swap["swap_data"], request) unless resolved["payment_hash"]
|
|
159
|
+
success(201, { "swap" => public_swap(swap) }, request_id)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def get_swap(raw_body:, request:, request_id:)
|
|
164
|
+
swap_action("swap.read", raw_body, request, request_id) do |reference, hash, data, _body|
|
|
165
|
+
@service.get_swap(reference: reference, payment_hash: hash, swap_data: data)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def refund_swap(raw_body:, request:, request_id:)
|
|
170
|
+
swap_action("swap.refund", raw_body, request, request_id) do |reference, hash, data, body|
|
|
171
|
+
@service.refund_swap(
|
|
172
|
+
reference: reference,
|
|
173
|
+
payment_hash: hash,
|
|
174
|
+
swap_data: data,
|
|
175
|
+
refund_address: required(body["refund_address"], "refund_address")
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def read_rates(query_string:, request:, request_id:)
|
|
181
|
+
handle(request_id) do
|
|
182
|
+
pairs = begin
|
|
183
|
+
URI.decode_www_form(query_string.to_s)
|
|
184
|
+
rescue ArgumentError
|
|
185
|
+
[]
|
|
186
|
+
end
|
|
187
|
+
raw = pairs.filter_map { |key, value| value if key == "currencies" }.first
|
|
188
|
+
currencies = parse_rates_currencies(raw)
|
|
189
|
+
success(200, @service.list_rates(currencies.nil? ? {} : { "currencies" => currencies }), request_id)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The payer's ?currencies filter. Shape is checked HERE, at the wire
|
|
194
|
+
# boundary and with the same message as the JS handler's ratesCurrencies,
|
|
195
|
+
# so a malformed entry is a 400 in both engines rather than falling
|
|
196
|
+
# through to the service's rates-unavailable path.
|
|
197
|
+
def parse_rates_currencies(raw)
|
|
198
|
+
return nil if raw.nil?
|
|
199
|
+
|
|
200
|
+
currencies = raw.split(",").map(&:strip).reject(&:empty?)
|
|
201
|
+
if currencies.empty? || currencies.any? { |value| !/\A[A-Za-z]{3}\z/.match?(value) }
|
|
202
|
+
raise ValidationError,
|
|
203
|
+
"currencies must be a comma-separated list of three-letter currency codes."
|
|
204
|
+
end
|
|
205
|
+
currencies
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def error_response(error, request_id)
|
|
209
|
+
# Only an error carrying a code from the canonical contract enum keeps
|
|
210
|
+
# its status/code/message on the wire; anything else — a leaked library
|
|
211
|
+
# exception with its own #code included — is redacted to an opaque 500,
|
|
212
|
+
# exactly like the JS errorResponse fallthrough.
|
|
213
|
+
code = error.respond_to?(:code) ? error.code : nil
|
|
214
|
+
unless Nwc::ERROR_CODES.include?(code)
|
|
215
|
+
report_unexpected_error(error, request_id)
|
|
216
|
+
return [500, headers(request_id),
|
|
217
|
+
{ "code" => "INTERNAL", "message" => "Internal server error.", "request_id" => request_id }]
|
|
218
|
+
end
|
|
219
|
+
retryable = error.respond_to?(:retryable) ? error.retryable : nil
|
|
220
|
+
if error.respond_to?(:status) && !error.status.nil?
|
|
221
|
+
status = error.status
|
|
222
|
+
else
|
|
223
|
+
# A canonical code without a status is the wallet shape: a retryable
|
|
224
|
+
# outage is a 503, an upstream refusal a 502 (mirrors the JS
|
|
225
|
+
# isWalletErrorShape mapping).
|
|
226
|
+
retryable = Nwc::RETRYABLE_ERROR_CODES.include?(code) if retryable.nil?
|
|
227
|
+
status = retryable ? 503 : 502
|
|
228
|
+
end
|
|
229
|
+
body = { "code" => code, "message" => error.message, "request_id" => request_id }
|
|
230
|
+
body["retryable"] = retryable unless retryable.nil?
|
|
231
|
+
body["details"] = error.details if error.respond_to?(:details) && error.details.is_a?(Hash)
|
|
232
|
+
response_headers = headers(request_id)
|
|
233
|
+
# Mirrors the JS handler: a Retry-After hint (whole seconds, minimum 1)
|
|
234
|
+
# rides along with retryable throttling errors.
|
|
235
|
+
if error.respond_to?(:retry_after_seconds) && !error.retry_after_seconds.nil?
|
|
236
|
+
response_headers = response_headers.merge(
|
|
237
|
+
"retry-after" => [1, error.retry_after_seconds.ceil].max.to_s
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
[status, response_headers, body.compact]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private
|
|
244
|
+
|
|
245
|
+
# Status refresh with no request-level pass: one-attempt reconcile_payments,
|
|
246
|
+
# delivering settlement inline. A truncated walk raises WalletUnavailableError
|
|
247
|
+
# (retryable 503) rather than reporting not_found.
|
|
248
|
+
def checked_via_wallet(hash, checkout)
|
|
249
|
+
checked = @service.reconcile_payments(
|
|
250
|
+
"attempts" => [{ "payment_hash" => hash, "created_at" => checkout.fetch("created_at") }]
|
|
251
|
+
).first
|
|
252
|
+
if checked.nil?
|
|
253
|
+
raise OpenReceive::WalletUnavailableError,
|
|
254
|
+
"payment reconciliation did not complete: the wallet history " \
|
|
255
|
+
"walk ended before this invoice could be confirmed"
|
|
256
|
+
end
|
|
257
|
+
if checked["status"] == "settled" && checked["paid_at"]
|
|
258
|
+
@on_paid.call(
|
|
259
|
+
"payment_hash" => checked.fetch("payment_hash"),
|
|
260
|
+
"paid_at" => checked.fetch("paid_at"),
|
|
261
|
+
"details" => checked["details"]
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
details = checked["details"]
|
|
265
|
+
public_checked = checked.reject { |key, _| key == "details" }
|
|
266
|
+
public_checked["details"] = public_payment_details(details) unless details.nil?
|
|
267
|
+
public_checked
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Status refresh from the request-level gated pass. The winner serves the
|
|
271
|
+
# requested hash straight from the pass results (settlement was already
|
|
272
|
+
# delivered inside the pass); every other outcome serves the persisted
|
|
273
|
+
# row via attempt_status with `details` omitted — `details` stays
|
|
274
|
+
# contract-optional and no wallet snapshot is persisted just to make the
|
|
275
|
+
# two paths uniform.
|
|
276
|
+
def checked_from_pass(hash, reconcile_pass, attempt_status)
|
|
277
|
+
if reconcile_pass["reason"] == "ran"
|
|
278
|
+
checked = Array(reconcile_pass["checks"]).find do |check|
|
|
279
|
+
check["payment_hash"].to_s.downcase == hash.to_s.downcase
|
|
280
|
+
end
|
|
281
|
+
# A `not_found` pass result falls through to the row. A wallet that
|
|
282
|
+
# ignores `unpaid: true` omits a live invoice from the scan, and
|
|
283
|
+
# serving not_found here would flap a pending attempt between
|
|
284
|
+
# gate-winning and gate-busy requests.
|
|
285
|
+
unless checked.nil? || checked["status"].to_s == "not_found"
|
|
286
|
+
details = checked["details"]
|
|
287
|
+
public_checked = checked.reject { |key, _| key == "details" }
|
|
288
|
+
public_checked["details"] = public_payment_details(details) unless details.nil?
|
|
289
|
+
return public_checked
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
row = attempt_status&.call(hash)
|
|
293
|
+
# resolve_host selected this hash from the same repository moments ago.
|
|
294
|
+
raise NotFoundError, "Payment attempt not found for this reference." if row.nil?
|
|
295
|
+
|
|
296
|
+
# Row `attention` serves as `pending` on the wire (operator state, not
|
|
297
|
+
# payer information); the row path never emits `not_found`.
|
|
298
|
+
status = row["status"].to_s == "attention" ? "pending" : row["status"].to_s
|
|
299
|
+
public_checked = { "payment_hash" => hash.to_s.downcase, "status" => status }
|
|
300
|
+
public_checked["paid_at"] = Integer(row["paid_at"]) if row["paid_at"]
|
|
301
|
+
public_checked
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def swap_action(action, raw_body, request, request_id)
|
|
305
|
+
handle(request_id) do
|
|
306
|
+
body = parse(raw_body, action, request: request)
|
|
307
|
+
reference = required_reference(body)
|
|
308
|
+
# Shape-validated before guard/resolve, matching JS: host hooks
|
|
309
|
+
# never receive an un-vetted payer selector.
|
|
310
|
+
requested_hash = required_payment_hash(body["payment_hash"])
|
|
311
|
+
guard(action, request, { reference: reference, payment_hash: requested_hash })
|
|
312
|
+
resolved = resolve_host(action, request, reference, body)
|
|
313
|
+
hash = selected_payment_hash(resolved, requested_hash)
|
|
314
|
+
success(200, yield(reference, hash, required_swap_data(resolved["swap_data"]), body), request_id)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def resolve_host(action, request, reference, body, pay_in_asset = nil)
|
|
319
|
+
args = { action: action, request: request, reference: reference, input: body }
|
|
320
|
+
args[:pay_in_asset] = pay_in_asset unless pay_in_asset.nil?
|
|
321
|
+
OpenReceive.stringify(@resolve_checkout.call(**args))
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def guard(action, request, resource)
|
|
325
|
+
enforce_rate_limit!(action, request, resource)
|
|
326
|
+
authorize!(action, request, resource)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Create actions call these separately: authorize first, then the rate
|
|
330
|
+
# limit only once the host has resolved that a new attempt must be
|
|
331
|
+
# minted (reuse is exempt) — the same split as the JS handler.
|
|
332
|
+
def enforce_rate_limit!(action, request, resource)
|
|
333
|
+
return if @rate_limit.nil?
|
|
334
|
+
context = { action: action, request: request, resource: resource }
|
|
335
|
+
raise RateLimitedError unless @rate_limit.call(context)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def authorize!(action, request, resource)
|
|
339
|
+
context = { action: action, request: request, resource: resource }
|
|
340
|
+
raise ForbiddenError, "Not authorized for this action." unless @authorize.call(context)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def commit(checkout, swap_data = nil, request = nil)
|
|
344
|
+
client_ip = @client_ip&.call(request)
|
|
345
|
+
@on_checkout_created.call(
|
|
346
|
+
reference: checkout.fetch("reference"),
|
|
347
|
+
payment_hash: checkout.fetch("payment_hash"),
|
|
348
|
+
checkout: checkout,
|
|
349
|
+
swap_data: swap_data,
|
|
350
|
+
client_ip: client_ip
|
|
351
|
+
)
|
|
352
|
+
rescue StandardError => e
|
|
353
|
+
# Meaningful repository refusals ("already paid", "live attempt for
|
|
354
|
+
# the same method") carry their own status/code and pass through
|
|
355
|
+
# untouched. Anything else is infrastructure failing to persist
|
|
356
|
+
# (database down, bug): retryable 503, never a payer-blaming conflict
|
|
357
|
+
# — mirrors the JS handler's commit().
|
|
358
|
+
raise e if e.respond_to?(:status) && e.respond_to?(:code)
|
|
359
|
+
raise HostPersistenceError
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def public_swap(swap)
|
|
363
|
+
swap.reject { |key, _| key == "swap_data" }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Payer-facing subset of a settlement's wallet details, whitelisted
|
|
367
|
+
# field-for-field from the JS handler's publicPaymentDetails: the raw
|
|
368
|
+
# wallet transaction carries the preimage, full invoice, and wallet
|
|
369
|
+
# metadata — none of which belong in a browser-polled response.
|
|
370
|
+
# The keys a normalized NwcTransaction actually carries. `state`,
|
|
371
|
+
# `amount` and `fees_paid` were never on it (they normalize to
|
|
372
|
+
# transaction_state / amount_msats / fees_paid_msats), so whitelisting
|
|
373
|
+
# them silently omitted the very fields this list exists to expose. The
|
|
374
|
+
# JS pick list is now the same, field for field.
|
|
375
|
+
# Never widen this to preimage or invoice: those stay server-side.
|
|
376
|
+
PUBLIC_TRANSACTION_FIELDS = %w[
|
|
377
|
+
payment_hash transaction_state amount_msats fees_paid_msats
|
|
378
|
+
created_at settled_at expires_at
|
|
379
|
+
].freeze
|
|
380
|
+
|
|
381
|
+
def public_payment_details(details)
|
|
382
|
+
data = OpenReceive.stringify(details)
|
|
383
|
+
result = {}
|
|
384
|
+
transaction = data["transaction"]
|
|
385
|
+
if transaction.respond_to?(:each_pair)
|
|
386
|
+
rows = OpenReceive.as_string_keys(transaction)
|
|
387
|
+
result["transaction"] = PUBLIC_TRANSACTION_FIELDS.each_with_object({}) do |field, picked|
|
|
388
|
+
picked[field] = rows[field] unless rows[field].nil?
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
result["observed_at"] = data["observed_at"]
|
|
392
|
+
result["paid_at_source"] = data["paid_at_source"] unless data["paid_at_source"].nil?
|
|
393
|
+
result
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def handle(request_id)
|
|
397
|
+
yield
|
|
398
|
+
rescue StandardError, NotImplementedError => e
|
|
399
|
+
error_response(e, request_id)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Redacting an unexpected exception must not also swallow it: the host's
|
|
403
|
+
# error reporter (Rails.error, which feeds Sentry/Honeybadger/the Rails
|
|
404
|
+
# error subscribers) or logger receives it before the opaque 500 goes on
|
|
405
|
+
# the wire. The fallback log line carries class and origin only — never
|
|
406
|
+
# the message, which could quote request bodies, NWC URIs, invoices, or
|
|
407
|
+
# preimages.
|
|
408
|
+
def report_unexpected_error(error, request_id)
|
|
409
|
+
if defined?(::Rails) && ::Rails.respond_to?(:error) && ::Rails.error
|
|
410
|
+
::Rails.error.report(error, handled: true, source: "openreceive")
|
|
411
|
+
else
|
|
412
|
+
origin = Array(error.backtrace).first
|
|
413
|
+
line = "[openreceive] unexpected #{error.class} (request_id=#{request_id})" \
|
|
414
|
+
"#{origin.nil? ? '' : " at #{origin}"}"
|
|
415
|
+
logger = defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
|
|
416
|
+
logger.nil? ? warn(line) : logger.error(line)
|
|
417
|
+
end
|
|
418
|
+
rescue StandardError
|
|
419
|
+
nil
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def parse(raw, route = nil, request: nil)
|
|
423
|
+
assert_not_cross_site!(request)
|
|
424
|
+
assert_json_content_type!(request)
|
|
425
|
+
text = raw.to_s
|
|
426
|
+
raise PayloadTooLargeError if text.bytesize > MAX_BODY_BYTES
|
|
427
|
+
value = text.strip.empty? ? {} : JSON.parse(text)
|
|
428
|
+
raise ValidationError, "Request body must be a JSON object." unless value.is_a?(Hash)
|
|
429
|
+
assert_declared_fields!(value, route)
|
|
430
|
+
value
|
|
431
|
+
rescue JSON::ParserError
|
|
432
|
+
raise ValidationError, "Request body must be a JSON object."
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# The body-bearing routes accept `application/json` only, checked before
|
|
436
|
+
# authorize or any host hook. This is the CSRF-equivalent on
|
|
437
|
+
# cookie-authenticated mounts: a cross-site HTML form cannot set a JSON
|
|
438
|
+
# content type (only urlencoded, multipart, or text/plain), and a
|
|
439
|
+
# cross-origin fetch that does is non-simple and CORS-preflighted — which
|
|
440
|
+
# the library never answers — so a forged request with the victim's
|
|
441
|
+
# session can never carry a JSON body here. Reads the content type from a
|
|
442
|
+
# Rack env hash (CONTENT_TYPE) or a framework request object (Rails
|
|
443
|
+
# #content_type). Parameters and charset are ignored.
|
|
444
|
+
def assert_json_content_type!(request)
|
|
445
|
+
content_type =
|
|
446
|
+
if request.is_a?(Hash)
|
|
447
|
+
request["CONTENT_TYPE"]
|
|
448
|
+
elsif request.respond_to?(:content_type)
|
|
449
|
+
request.content_type
|
|
450
|
+
elsif request.respond_to?(:get_header)
|
|
451
|
+
request.get_header("CONTENT_TYPE")
|
|
452
|
+
end
|
|
453
|
+
return if content_type.to_s.split(";").first.to_s.strip.downcase == "application/json"
|
|
454
|
+
|
|
455
|
+
raise UnsupportedMediaTypeError
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Browsers label every request with its initiator's relation to the
|
|
459
|
+
# target (Sec-Fetch-Site), and a forged request from another site is
|
|
460
|
+
# always "cross-site" — including a no-cors fetch, which the content-type
|
|
461
|
+
# gate alone cannot see. The mounted routes serve the host's own pages,
|
|
462
|
+
# so a cross-site POST is refused before the body is read. "same-site"
|
|
463
|
+
# (a sibling subdomain) and an absent header (non-browser clients, old
|
|
464
|
+
# browsers — the content-type gate covers those) pass. Mirrors the JS
|
|
465
|
+
# handler's assertNotCrossSite.
|
|
466
|
+
def assert_not_cross_site!(request)
|
|
467
|
+
site =
|
|
468
|
+
if request.is_a?(Hash)
|
|
469
|
+
request["HTTP_SEC_FETCH_SITE"]
|
|
470
|
+
elsif request.respond_to?(:get_header)
|
|
471
|
+
request.get_header("HTTP_SEC_FETCH_SITE")
|
|
472
|
+
elsif request.respond_to?(:headers)
|
|
473
|
+
request.headers["Sec-Fetch-Site"]
|
|
474
|
+
end
|
|
475
|
+
return unless site.to_s.strip.downcase == "cross-site"
|
|
476
|
+
|
|
477
|
+
raise ForbiddenError, "Cross-site requests are not accepted."
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def assert_declared_fields!(body, route)
|
|
481
|
+
allowed = ROUTE_BODY_FIELDS[route]
|
|
482
|
+
return if allowed.nil?
|
|
483
|
+
# A payer-supplied amount is the one undeclared field worth naming: the
|
|
484
|
+
# generic "unexpected field" message reads like a typo when the caller
|
|
485
|
+
# is actually reaching for the price authority. Every route in
|
|
486
|
+
# ROUTE_BODY_FIELDS passes through here, so this is THE gate — routes
|
|
487
|
+
# do not repeat it.
|
|
488
|
+
reject_payer_amount(body)
|
|
489
|
+
body.each_key do |key|
|
|
490
|
+
unless allowed.include?(key)
|
|
491
|
+
raise ValidationError, "Unexpected request field for this route: #{key}."
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def required(value, field)
|
|
497
|
+
text = value.to_s.strip
|
|
498
|
+
raise ValidationError, "#{field} is required." if text.empty?
|
|
499
|
+
text
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def required_reference(body)
|
|
503
|
+
reference = required(body["reference"], "reference")
|
|
504
|
+
if reference.length > MAX_REFERENCE_LENGTH
|
|
505
|
+
raise ValidationError, "reference must be #{MAX_REFERENCE_LENGTH} characters or fewer."
|
|
506
|
+
end
|
|
507
|
+
reference
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def validated_memo(body)
|
|
511
|
+
memo = body["memo"]
|
|
512
|
+
if memo.is_a?(String) && memo.length > MAX_MEMO_LENGTH
|
|
513
|
+
raise ValidationError, "memo must be #{MAX_MEMO_LENGTH} characters or fewer."
|
|
514
|
+
end
|
|
515
|
+
memo
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def required_amount(resolved)
|
|
519
|
+
amount = resolved["amount"]
|
|
520
|
+
if amount.nil?
|
|
521
|
+
# A host order without an amount is a host-integration bug, not a
|
|
522
|
+
# payer mistake: 500 INTERNAL with the JS handler's exact message.
|
|
523
|
+
raise InternalHostError, "The host resolved this order without an amount."
|
|
524
|
+
end
|
|
525
|
+
amount
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def required_payment_hash(value)
|
|
529
|
+
hash = required(value, "payment_hash").downcase
|
|
530
|
+
unless /\A[0-9a-f]{64}\z/.match?(hash)
|
|
531
|
+
raise ValidationError, "payment_hash must be 64 hexadecimal characters."
|
|
532
|
+
end
|
|
533
|
+
hash
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def required_swap_data(value)
|
|
537
|
+
raise NotFoundError, "The host order has no swap data." if value.nil?
|
|
538
|
+
unless value.is_a?(Hash)
|
|
539
|
+
raise ValidationError, "The host order's swap data is not a valid swap_data object."
|
|
540
|
+
end
|
|
541
|
+
value
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def selected_payment_hash(resolved, requested_hash)
|
|
545
|
+
selected = host_payment_hash(resolved["payment_hash"])
|
|
546
|
+
return selected if selected == requested_hash
|
|
547
|
+
|
|
548
|
+
raise NotFoundError, "The selected payment attempt does not belong to this order."
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# A payment hash the HOST resolver returned. A missing or malformed value
|
|
552
|
+
# here is a host integration bug, never payer input: it surfaces as a 500
|
|
553
|
+
# naming the host, not as a payer-blaming 400. Same ruling and the same
|
|
554
|
+
# wire message as the JS handler's hostPaymentHash.
|
|
555
|
+
def host_payment_hash(value)
|
|
556
|
+
hash = value.is_a?(String) ? value.strip.downcase : nil
|
|
557
|
+
return hash if !hash.nil? && /\A[0-9a-f]{64}\z/.match?(hash)
|
|
558
|
+
|
|
559
|
+
raise InternalHostError,
|
|
560
|
+
"The host resolver returned a missing or malformed payment hash for this reference."
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def reject_payer_amount(body)
|
|
564
|
+
return unless body.key?("amount") || body.key?("amount_msats")
|
|
565
|
+
raise ValidationError, "This route does not accept a payer-supplied amount; the host resolves its order price."
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def committed_checkout(reference, resolved)
|
|
569
|
+
checkout = resolved["checkout"]
|
|
570
|
+
unless checkout.is_a?(Hash)
|
|
571
|
+
raise ConflictError, "The host payment attempt has no checkout snapshot."
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
data = OpenReceive.as_string_keys(checkout)
|
|
575
|
+
# Both hashes come from the host snapshot and the host resolver, so a
|
|
576
|
+
# missing or malformed one is host data, not payer input.
|
|
577
|
+
hash = host_payment_hash(data["payment_hash"])
|
|
578
|
+
selected = host_payment_hash(resolved["payment_hash"])
|
|
579
|
+
checkout_order = required(data["reference"], "reference")
|
|
580
|
+
if hash != selected || checkout_order != reference
|
|
581
|
+
raise ConflictError, "The selected payment attempt is not a reusable pending checkout."
|
|
582
|
+
end
|
|
583
|
+
data
|
|
584
|
+
rescue ArgumentError, TypeError
|
|
585
|
+
raise ConflictError, "The selected payment attempt is not a reusable pending checkout."
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def success(status, body, request_id)
|
|
589
|
+
[status, headers(request_id), body]
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def headers(request_id)
|
|
593
|
+
# Lowercase keys: the Rack 3 SPEC requires them; Rails normalizes.
|
|
594
|
+
{ "content-type" => "application/json; charset=utf-8", "x-request-id" => request_id }.compact
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|