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.
@@ -0,0 +1,775 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openreceive"
4
+ require "openreceive/server/errors"
5
+ require "openreceive/server/wallet_info"
6
+
7
+ module OpenReceive
8
+ module Server
9
+ class Service
10
+ PAGE_LIMIT = OpenReceive::TRANSACTION_PAGE_LIMIT
11
+ # Upper bound on wallet history pages per scan (mirrors JS maxPages): a
12
+ # wallet/relay that keeps returning full pages must not hang the scan.
13
+ MAX_PAGES = 10_000
14
+ INVOICE_EXPIRY_SECONDS = 600
15
+ # Default shadow-invoice expiry when a swap provider does not report its
16
+ # own (mirrors the JS default).
17
+ SWAP_INVOICE_EXPIRY_SECONDS = 600
18
+ # Maximum seconds the wallet's returned expiry may deviate from the
19
+ # requested expiry before checkout creation fails closed.
20
+ INVOICE_EXPIRY_TOLERANCE_SECONDS = 60
21
+
22
+ # NIP-47 method names that let a connection move funds out of the wallet
23
+ # (mirrors the JS preflight, including the keysend variants). Preflight
24
+ # compares against already-normalized names from WalletInfo.summarize.
25
+ SPEND_METHODS = WalletInfo::SPEND_METHODS
26
+
27
+ attr_reader :price_currencies
28
+
29
+ # `swap_providers: nil` (the default) auto-builds FixedFloat-compatible
30
+ # providers from LSC_URI_PRIMARY / LSC_URI_BACKUP, exactly like the JS
31
+ # createOpenReceive. Pass an explicit array (possibly empty) to override.
32
+ # `price_provider: nil` (the default) uses the built-in cached live
33
+ # price feed (with OPENRECEIVE_PRICE_FEED_*_URL overrides), mirroring
34
+ # the JS default; pass a provider to override, or `false` to run
35
+ # without rates entirely (the JS `priceProviders: []`): fiat amounts
36
+ # and GET /rates then fail with their not-configured errors.
37
+ # `logger:` is an optional standard Logger-shaped sink (debug/info/
38
+ # warn/error) for operational events such as swap-provider API calls.
39
+ def initialize(nwc_client:, price_provider: nil, swap_providers: nil, price_currencies: ["USD"],
40
+ clock: -> { Time.now.to_i }, allow_spend_capable_wallet: false, env: ENV,
41
+ logger: nil)
42
+ @nwc = nwc_client
43
+ @clock = clock
44
+ @env = env
45
+ @logger = logger
46
+ @price_currencies = Array(price_currencies || ["USD"]).map { |value| value.to_s.upcase }
47
+ @price_provider = price_provider == false ? nil : price_provider || default_price_provider(env)
48
+ @swap_providers =
49
+ if swap_providers.nil?
50
+ Swap.providers_from_environment(env, now: @clock)
51
+ else
52
+ Array(swap_providers)
53
+ end
54
+ attach_swap_provider_runtime!
55
+ # The override relaxes only the spend refusal: receive-readiness and
56
+ # encryption are still enforced, exactly as in the JS preflight.
57
+ wallet_preflight!(
58
+ allow_spend_capable: allow_spend_capable_wallet || spend_override_from_env?
59
+ )
60
+ end
61
+
62
+ def prepare_checkout(input)
63
+ validating_input do
64
+ data = stringify(input)
65
+ amount_msats, fiat_quote = resolve_amount(data.fetch("amount"))
66
+ {
67
+ "amount_msats" => amount_msats,
68
+ "fiat_quote" => fiat_quote,
69
+ "payment_methods" => list_swap_options(amount_msats: amount_msats)
70
+ }
71
+ end
72
+ end
73
+
74
+ # Amount-aware swap pay-in options for the shared browser widget
75
+ # (mirrors the JS service listSwapOptions + resolveSwapProviderCatalog):
76
+ # exactly one live provider's catalog — primary when healthy, otherwise
77
+ # the first backup that answers — mapped over the full OpenReceive asset
78
+ # list with amount-vs-limit availability.
79
+ def list_swap_options(amount_msats:)
80
+ return [] if @swap_providers.empty?
81
+
82
+ normalized_amount = normalize_swap_amount_msats(amount_msats)
83
+ catalog = resolve_swap_provider_catalog
84
+ # Providers ARE configured (checked above), so an empty catalog means
85
+ # every one of them failed its fetch — an outage, not a configuration
86
+ # gap. Mirrors the JS listSwapOptions ruling.
87
+ catalog_unreachable = catalog.empty?
88
+ Swap::Assets.list_info.map do |asset|
89
+ swap_catalog_option(
90
+ asset, normalized_amount, catalog[asset.fetch("pay_in_asset")],
91
+ catalog_unreachable: catalog_unreachable
92
+ )
93
+ end
94
+ end
95
+
96
+ def create_checkout(input)
97
+ # Payer-input validation only: once the wallet has minted, a parse
98
+ # failure is the wallet's response violating the receive contract, not
99
+ # a 400 the payer caused — so this rescue must not cover the wallet
100
+ # call or its normalization.
101
+ reference, expiry, required_expiry, fiat_quote, request = validating_input do
102
+ data = stringify(input)
103
+ reference = required_string(data["reference"], "reference")
104
+ amount_msats, fiat_quote = resolve_amount(data.fetch("amount"))
105
+ # A caller-supplied expiry_seconds is a FLOOR (only the swap path sets
106
+ # it); the library default is a request the wallet may clamp.
107
+ required_expiry = !data["expiry_seconds"].nil?
108
+ expiry = Integer(data["expiry_seconds"] || INVOICE_EXPIRY_SECONDS)
109
+ metadata = stringify(data["metadata"] || {}).merge("reference" => reference)
110
+ # NIP-47 caps invoice metadata; reject before any wallet call with
111
+ # the JS service's exact message instead of surfacing the wallet
112
+ # client's own failure as a 502.
113
+ if JSON.generate(metadata).bytesize > OpenReceive::NWC_METADATA_MAX_BYTES
114
+ raise ValidationError, "metadata is too large for NIP-47."
115
+ end
116
+ request = {
117
+ "amount_msats" => amount_msats,
118
+ "expiry" => expiry,
119
+ "metadata" => metadata
120
+ }
121
+ request["description"] = data["memo"] if data["memo"]
122
+ request["description_hash"] = data["description_hash"] if data["description_hash"]
123
+ [reference, expiry, required_expiry, fiat_quote, request]
124
+ end
125
+ response = call_nwc(:make_invoice, request)
126
+ begin
127
+ wallet = OpenReceive.normalize_make_invoice_response(response)
128
+ created_at = wallet["created_at"] || @clock.call
129
+ # The ledger row stores the wallet's OWN expires_at, so reuse
130
+ # buffering, reconciliation, and the expiry+grace close rule all stay
131
+ # consistent with the real invoice even when the wallet clamps expiry
132
+ # to its own min/max. A deviation is therefore a warning on the plain
133
+ # checkout path — refusing would lock every such wallet out entirely.
134
+ #
135
+ # A caller-supplied expiry is a FLOOR: only the swap path sets one,
136
+ # because the shadow invoice must outlive the provider order. A short
137
+ # invoice fails there. Mirrors the JS create_checkout ruling.
138
+ requested_expires_at = created_at + expiry
139
+ expires_at = wallet["expires_at"] || requested_expires_at
140
+ shortfall = requested_expires_at - expires_at
141
+ if (expires_at - requested_expires_at).abs > INVOICE_EXPIRY_TOLERANCE_SECONDS
142
+ # The detailed diagnostic is logged, never sent: the wire carries
143
+ # the same short form as the JS service.
144
+ if required_expiry && shortfall > INVOICE_EXPIRY_TOLERANCE_SECONDS
145
+ @logger&.error(
146
+ "checkout.invoice_expiry.rejected: The wallet did not honor the " \
147
+ "required invoice expiry (required #{expiry}s, got " \
148
+ "#{expires_at - created_at}s). Use a wallet whose make_invoice honors expiry."
149
+ )
150
+ raise WalletContractError,
151
+ "Error with the backing NWC wallet: it did not honor the requested invoice expiry."
152
+ end
153
+ @logger&.warn(
154
+ "checkout.invoice_expiry.adjusted: The wallet clamped the requested " \
155
+ "invoice expiry (requested #{expiry}s, got #{expires_at - created_at}s); " \
156
+ "the wallet's own expiry is recorded on the attempt."
157
+ )
158
+ end
159
+ {
160
+ "reference" => reference,
161
+ "payment_hash" => wallet.fetch("payment_hash"),
162
+ "bolt11" => wallet.fetch("invoice"),
163
+ "amount_msats" => wallet.fetch("amount_msats"),
164
+ "created_at" => created_at,
165
+ "expires_at" => expires_at,
166
+ "fiat_quote" => fiat_quote
167
+ }
168
+ rescue KeyError, ArgumentError, TypeError
169
+ # Never blames the payer, and never puts the raw parse failure
170
+ # (`key not found: "invoice"`) on the wire.
171
+ raise WalletContractError
172
+ end
173
+ end
174
+
175
+ # Optional bounds for request-path passes: "max_pages" caps each
176
+ # wallet-history walk (the gated opportunistic pass sends 50, mirroring
177
+ # the JS OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES; default MAX_PAGES), and
178
+ # "deadline" is a monotonic-clock instant checked between page fetches —
179
+ # never mid-request — so a slow wallet cannot hang user-facing requests.
180
+ def reconcile_payments(input)
181
+ data = stringify(input)
182
+ attempts = Array(data.fetch("attempts"))
183
+ return [] if attempts.empty?
184
+
185
+ expected = attempts.to_h do |attempt|
186
+ row = stringify(attempt)
187
+ [normalize_payment_hash(row.fetch("payment_hash") { row.fetch("paymentHash") }),
188
+ Integer(row.fetch("created_at") { row.fetch("createdAt") })]
189
+ end
190
+ overlap = Integer(data.fetch("overlap_seconds", 60))
191
+ # A negative overlap would SHRINK both window ends instead of padding
192
+ # them, hiding exactly the rows the padding exists to catch. Mirrors
193
+ # the JS reconcilePaymentAttempts guard.
194
+ raise ArgumentError, "overlap_seconds must be a non-negative integer" if overlap.negative?
195
+
196
+ from = [expected.values.min - overlap, 0].max
197
+ # Both ends of the window are padded: `from` against a wallet clock
198
+ # that lags, `until` against one that runs ahead — an unpadded `until`
199
+ # on the host clock hides an invoice the wallet just stamped into the
200
+ # future.
201
+ until_time = Integer(data["until"] || (@clock.call + overlap))
202
+ bounds = { max_pages: data["max_pages"], deadline: data["deadline"] }.compact
203
+ settled = scan_incoming_transactions(
204
+ expected: expected.keys, from: from, until_time: until_time, **bounds
205
+ )
206
+ by_hash = settled.fetch(:rows).dup
207
+ missing = expected.keys.reject { |hash| by_hash.key?(hash) }
208
+ truncated = false
209
+ unless missing.empty?
210
+ inclusive = scan_incoming_transactions(
211
+ expected: missing, from: from, until_time: until_time, unpaid: true, **bounds
212
+ )
213
+ truncated = settled.fetch(:truncated) || inclusive.fetch(:truncated)
214
+ inclusive.fetch(:rows).each { |hash, row| by_hash[hash] ||= row }
215
+ end
216
+ # A hash the walk could not decide is OMITTED rather than reported
217
+ # not_found: when the page cap, the pass deadline, or a wallet that
218
+ # ignored `offset` cut the walk short, absence is unproven, and
219
+ # reporting not_found would let a caller close a paid attempt. Omitted
220
+ # hashes are simply retried next pass (mirrors the JS
221
+ # reconcilePaymentAttempts).
222
+ expected.keys.filter_map do |hash|
223
+ if by_hash.key?(hash)
224
+ payment_result(hash, by_hash.fetch(hash))
225
+ elsif !truncated
226
+ { "payment_hash" => hash, "status" => "not_found" }
227
+ end
228
+ end
229
+ end
230
+
231
+ def quote_swap(input)
232
+ data = stringify(input)
233
+ asset = parse_pay_in_asset(data["pay_in_asset"])
234
+ amount_msats, = validating_input { resolve_amount(data.fetch("amount")) }
235
+ provider = select_provider(asset)
236
+ quote = stringify(call_provider(provider, :quote,
237
+ "pay_in_asset" => asset, "invoice_amount_msats" => amount_msats))
238
+ {
239
+ "provider" => quote.fetch("provider"),
240
+ "pay_asset" => quote.fetch("pay_asset"),
241
+ "available" => quote.fetch("available"),
242
+ "pay_amount" => quote["pay_amount"],
243
+ "minimum_pay_amount" => quote["minimum_pay_amount"],
244
+ "maximum_pay_amount" => quote["maximum_pay_amount"],
245
+ "minimum_invoice_amount_msats" => quote["minimum_invoice_amount_msats"],
246
+ "maximum_invoice_amount_msats" => quote["maximum_invoice_amount_msats"],
247
+ "unavailable_reason" => quote["unavailable_reason"],
248
+ "unavailable_message" => quote["unavailable_message"]
249
+ }.compact
250
+ end
251
+
252
+ def create_swap(input)
253
+ data = stringify(input)
254
+ asset = parse_pay_in_asset(data["pay_in_asset"])
255
+ amount = begin
256
+ data.fetch("amount")
257
+ rescue KeyError => e
258
+ raise ValidationError, e.message
259
+ end
260
+ provider = select_provider(asset)
261
+ expiry = provider.respond_to?(:invoice_expiry_seconds) ? provider.invoice_expiry_seconds(pay_in_asset: asset) : SWAP_INVOICE_EXPIRY_SECONDS
262
+ # The shadow-invoice expiry is provider-mandated: build the checkout
263
+ # input explicitly from validated fields so no payer-supplied key (e.g.
264
+ # "expiry_seconds") can override it or smuggle a different order id.
265
+ checkout = create_checkout(
266
+ "reference" => data["reference"],
267
+ "amount" => amount,
268
+ "memo" => data["memo"],
269
+ "metadata" => data["metadata"],
270
+ "expiry_seconds" => expiry
271
+ )
272
+ order = stringify(call_provider(provider, :create_swap,
273
+ "pay_in_asset" => asset,
274
+ "bolt11" => checkout.fetch("bolt11"),
275
+ "invoice_amount_msats" => checkout.fetch("amount_msats")))
276
+ swap_data = {
277
+ "version" => 1,
278
+ "provider_order" => order.reject { |key, _| key == "raw" }
279
+ }
280
+ public_swap(order, checkout.fetch("payment_hash"), checkout.fetch("reference")).merge(
281
+ "checkout" => checkout,
282
+ "swap_data" => swap_data
283
+ )
284
+ end
285
+
286
+ def get_swap(reference:, payment_hash:, swap_data:)
287
+ recovery = normalize_swap_data(swap_data)
288
+ provider_name = recovery.fetch("provider_order").fetch("provider")
289
+ provider = provider_by_name(provider_name)
290
+ current = stringify(call_provider(provider, :get_status, recovery.fetch("provider_order")))
291
+ public_swap(current, normalize_payment_hash(payment_hash), required_string(reference, "reference"))
292
+ rescue KeyError => e
293
+ raise ValidationError, e.message
294
+ end
295
+
296
+ def refund_swap(reference:, payment_hash:, swap_data:, refund_address:)
297
+ recovery = normalize_swap_data(swap_data)
298
+ hash = normalize_payment_hash(payment_hash)
299
+ host_reference = required_string(reference, "reference")
300
+ address = normalize_refund_address(
301
+ refund_address, recovery.dig("provider_order", "pay_in_asset")
302
+ )
303
+ provider_name = recovery.fetch("provider_order").fetch("provider")
304
+ provider = provider_by_name(provider_name)
305
+ current = stringify(call_provider(provider, :get_status, recovery.fetch("provider_order")))
306
+ unless current["state"] == "refund_required"
307
+ raise ConflictError, "Swap cannot be refunded from provider state #{current['state']}."
308
+ end
309
+ call_provider(provider, :request_refund, current, address)
310
+ get_swap(reference: host_reference, payment_hash: hash, swap_data: recovery)
311
+ rescue KeyError => e
312
+ raise ValidationError, e.message
313
+ end
314
+
315
+ def list_rates(input = {})
316
+ raise NotImplementedHttpError, "No price provider is configured for rates." if @price_provider.nil?
317
+ currencies = Array(stringify(input)["currencies"] || @price_currencies).map { |value| value.to_s.strip.upcase }
318
+ currencies.each do |currency|
319
+ unless /\A[A-Z]{3}\z/.match?(currency)
320
+ # Same message as the JS service's payer currencies path; the wire
321
+ # shape check already fired in the request handler.
322
+ raise ValidationError, "Invalid currencies entry: #{currency}."
323
+ end
324
+ unless @price_currencies.include?(currency)
325
+ raise ValidationError,
326
+ "fiat.currency must be one of the configured priceCurrencies: " \
327
+ "#{@price_currencies.join(', ')}."
328
+ end
329
+ end
330
+ { "bitcoin" => currencies.to_h { |currency| [currency.downcase, btc_fiat_price_or_unavailable(currency)] } }
331
+ end
332
+
333
+ private
334
+
335
+ # EVERY feed-side failure (network, HTTP, malformed or incomplete
336
+ # response) maps to the payer-facing retryable 503, exactly like the JS
337
+ # service's ratesUnavailableError — the feed being unable to price a
338
+ # configured currency is an outage, never payer input.
339
+ def btc_fiat_price_or_unavailable(currency)
340
+ @price_provider.btc_fiat_price(currency).to_s
341
+ rescue ServiceError, ValidationError
342
+ raise
343
+ rescue StandardError
344
+ raise ServiceError.new(
345
+ 503, "INTERNAL",
346
+ "Exchange rates are temporarily unavailable — please try again in a moment.",
347
+ retryable: true
348
+ )
349
+ end
350
+
351
+ # Fail-closed boot preflight, mirroring the JS client preflight: a
352
+ # connection that can report capabilities must be receive-ready and speak
353
+ # an encryption mode we implement, and — unless the host overrides —
354
+ # must not advertise spend methods.
355
+ #
356
+ # A read failure is NOT treated as transient. Booting blind only defers
357
+ # the failure to the first customer checkout, where it costs a lost sale
358
+ # instead of a loud boot error, so an info method that cannot answer
359
+ # fails the boot.
360
+ def wallet_preflight!(allow_spend_capable:)
361
+ raw_info = read_wallet_info
362
+ # The client exposes no info method at all: there is nothing to
363
+ # preflight, so custom NWC adapters keep booting as before.
364
+ return if raw_info.nil?
365
+
366
+ summary = WalletInfo.summarize(raw_info)
367
+ unless summary.fetch("receive_checkout_ready")
368
+ raise WalletPreflightError,
369
+ "the wallet does not advertise make_invoice and list_transactions."
370
+ end
371
+ if summary.fetch("encryption").nil?
372
+ raise WalletPreflightError,
373
+ "the wallet supports no encryption mode OpenReceive speaks (NIP-04 or NIP-44 v2)."
374
+ end
375
+ return if allow_spend_capable
376
+
377
+ spend = summary.fetch("methods").select { |method| SPEND_METHODS.include?(method) }
378
+ raise SpendCapableWalletError, spend unless spend.empty?
379
+ end
380
+
381
+ def read_wallet_info
382
+ if @nwc.respond_to?(:preflight)
383
+ @nwc.preflight
384
+ elsif @nwc.respond_to?(:get_info)
385
+ @nwc.get_info
386
+ elsif @nwc.respond_to?(:getInfo)
387
+ @nwc.getInfo
388
+ elsif @nwc.respond_to?(:get_wallet_service_info)
389
+ @nwc.get_wallet_service_info
390
+ elsif @nwc.respond_to?(:getWalletServiceInfo)
391
+ @nwc.getWalletServiceInfo
392
+ end
393
+ rescue OpenReceive::NwcUriParseError
394
+ # A malformed connection string is a config error in its own right;
395
+ # surface it as itself rather than as a preflight failure.
396
+ raise
397
+ rescue StandardError => e
398
+ raise WalletPreflightError, "could not read wallet info (#{e.class}: #{e.message})."
399
+ end
400
+
401
+ def spend_override_from_env?
402
+ raw = @env["OPENRECEIVE_ALLOW_SPEND_CAPABLE_NWC"].to_s.strip.downcase
403
+ return false if raw.empty?
404
+ return true if %w[1 true yes].include?(raw)
405
+ unless %w[0 false no].include?(raw)
406
+ # Fail closed (no override), but never silently: a typo like "truee"
407
+ # must not read as "unset".
408
+ warn "[openreceive] Unrecognized OPENRECEIVE_ALLOW_SPEND_CAPABLE_NWC value " \
409
+ "#{raw.inspect}; treating it as disabled. Use 1/true/yes to enable."
410
+ end
411
+ false
412
+ end
413
+
414
+ def normalize_swap_data(value)
415
+ data = stringify(value)
416
+ unless data["version"] == 1 && data["provider_order"].is_a?(Hash) &&
417
+ !data.dig("provider_order", "provider").to_s.empty? &&
418
+ !data.dig("provider_order", "provider_order_id").to_s.empty?
419
+ # Same wire message as the JS service's readSwapData.
420
+ raise ValidationError, "swapData is invalid."
421
+ end
422
+ data
423
+ end
424
+
425
+ def parse_pay_in_asset(value)
426
+ unless Swap::Assets.pay_in_asset?(value)
427
+ raise ValidationError, "payInAsset is not supported."
428
+ end
429
+ value
430
+ end
431
+
432
+ # Mirrors the JS service's normalizeAmountMsats: Lightning invoices are
433
+ # whole sats; round up so catalog limits match create.
434
+ def normalize_swap_amount_msats(value)
435
+ amount = begin
436
+ Integer(value)
437
+ rescue ArgumentError, TypeError
438
+ nil
439
+ end
440
+ if amount.nil? || amount < 1000
441
+ raise ValidationError, "amountMsats must be an integer >= 1000."
442
+ end
443
+ ((amount + 999) / 1000) * 1000
444
+ end
445
+
446
+ # Fiat pricing defaults to the LIVE feed with env URL overrides, exactly
447
+ # like the JS createOpenReceive default (there is deliberately no
448
+ # implicit static-mock fallback).
449
+ def default_price_provider(env)
450
+ overrides = OpenReceive::Rates.read_price_feed_url_overrides(env)
451
+ OpenReceive::Rates.create_cached_live_price_feed(
452
+ currencies: @price_currencies,
453
+ clock: @clock,
454
+ primary_url: overrides[:primary_url],
455
+ fallback_url: overrides[:fallback_url]
456
+ )
457
+ end
458
+
459
+ # Mirrors the JS createOpenReceive provider wiring: one shared transient
460
+ # cache and one per-provider weight budget, attached to every provider
461
+ # that supports them (host-supplied or auto-built).
462
+ def attach_swap_provider_runtime!
463
+ return if @swap_providers.empty?
464
+
465
+ cache = Swap::TransientSwapCache.new(@clock)
466
+ @swap_providers.each do |provider|
467
+ provider.attach_swap_cache(cache) if provider.respond_to?(:attach_swap_cache)
468
+ if provider.respond_to?(:attach_weight_budget)
469
+ provider.attach_weight_budget(
470
+ Swap::SwapProviderWeightBudget.new(provider.name, @clock)
471
+ )
472
+ end
473
+ end
474
+ end
475
+
476
+ # Use exactly one live provider's catalog: primary when healthy,
477
+ # otherwise the first backup that answers. Never merge catalogs.
478
+ def resolve_swap_provider_catalog
479
+ @swap_providers.each do |provider|
480
+ begin
481
+ catalog =
482
+ if provider.respond_to?(:pay_in_asset_catalog)
483
+ Array(call_provider(provider, :pay_in_asset_catalog))
484
+ else
485
+ Array(call_provider(provider, :supported_pay_in_assets)).map do |asset|
486
+ { "pay_asset" => asset.to_s }
487
+ end
488
+ end
489
+ rescue StandardError
490
+ # Catalog/rates feed down for this provider — try the next entry.
491
+ next
492
+ end
493
+ by_asset = {}
494
+ catalog.each do |item|
495
+ row = stringify(item)
496
+ by_asset[row["pay_asset"]] = row.merge("provider" => provider.name)
497
+ end
498
+ return by_asset
499
+ end
500
+ {}
501
+ end
502
+
503
+ # `catalog_unreachable` separates a transient provider outage from a
504
+ # configuration gap. It matters because the unavailable label is cached
505
+ # per amount for up to 60s: telling a payer "not configured" during a
506
+ # provider blip outlasts the blip.
507
+ def swap_catalog_option(asset, amount_msats, provider_asset, catalog_unreachable: false)
508
+ if provider_asset.nil?
509
+ reason, message =
510
+ if catalog_unreachable
511
+ ["provider_unreachable", "The swap provider is temporarily unreachable."]
512
+ else
513
+ ["provider_unconfigured", "Automated swaps are not configured for this asset."]
514
+ end
515
+ return {
516
+ "pay_in_asset" => asset.fetch("pay_in_asset"),
517
+ "label" => asset.fetch("label"),
518
+ "network_label" => asset.fetch("network_label"),
519
+ "provider" => "",
520
+ "available" => false,
521
+ "unavailable_reason" => reason,
522
+ "unavailable_message" => message
523
+ }
524
+ end
525
+
526
+ minimum_msats = provider_asset["minimum_invoice_amount_msats"]
527
+ maximum_msats = provider_asset["maximum_invoice_amount_msats"]
528
+ limit_reason =
529
+ if amount_msats.positive? && !minimum_msats.nil? && amount_msats < minimum_msats
530
+ "amount_too_small"
531
+ elsif amount_msats.positive? && !maximum_msats.nil? && amount_msats > maximum_msats
532
+ "amount_too_large"
533
+ end
534
+ unavailable_reason =
535
+ limit_reason ||
536
+ (provider_asset["available"] == false ? provider_asset["unavailable_reason"] : nil)
537
+ unavailable_message =
538
+ case limit_reason
539
+ when "amount_too_small" then "This invoice is below the provider minimum."
540
+ when "amount_too_large" then "This invoice is above the provider maximum."
541
+ else
542
+ provider_asset["available"] == false ? provider_asset["unavailable_message"] : nil
543
+ end
544
+
545
+ option = {
546
+ "pay_in_asset" => asset.fetch("pay_in_asset"),
547
+ "label" => asset.fetch("label"),
548
+ "network_label" => asset.fetch("network_label"),
549
+ "provider" => provider_asset.fetch("provider"),
550
+ "available" => unavailable_reason.nil? && provider_asset["available"] != false
551
+ }
552
+ option["unavailable_reason"] = unavailable_reason unless unavailable_reason.nil?
553
+ option["unavailable_message"] = unavailable_message unless unavailable_message.nil?
554
+ unless provider_asset["minimum_pay_amount"].nil?
555
+ option["minimum_pay_amount"] = provider_asset["minimum_pay_amount"]
556
+ end
557
+ unless provider_asset["maximum_pay_amount"].nil?
558
+ option["maximum_pay_amount"] = provider_asset["maximum_pay_amount"]
559
+ end
560
+ option["minimum_invoice_amount_msats"] = minimum_msats unless minimum_msats.nil?
561
+ option["maximum_invoice_amount_msats"] = maximum_msats unless maximum_msats.nil?
562
+ option
563
+ end
564
+
565
+ def resolve_amount(input)
566
+ amount = stringify(input)
567
+ if amount.key?("sats")
568
+ return [OpenReceive::Money.direct_to_msats(currency: "SATS", value: amount.fetch("sats")), nil]
569
+ end
570
+ currency = required_string(amount["currency"], "amount.currency").upcase
571
+ value = required_string(amount["value"], "amount.value")
572
+ return [OpenReceive::Money.direct_to_msats(currency: currency, value: value), nil] if %w[BTC SAT SATS].include?(currency)
573
+ raise ValidationError, "price provider is not configured" if @price_provider.nil?
574
+ unless @price_currencies.include?(currency)
575
+ raise ValidationError,
576
+ "fiat.currency must be one of the configured priceCurrencies: " \
577
+ "#{@price_currencies.join(', ')}."
578
+ end
579
+ price = btc_fiat_price_or_unavailable(currency)
580
+ msats = OpenReceive.quote_fiat_to_msats(fiat_value: value, btc_fiat_price: price)
581
+ [msats, { "fiat" => { "currency" => currency, "value" => value }, "btc_fiat_price" => price, "amount_msats" => msats, "as_of" => @clock.call }]
582
+ end
583
+
584
+ def payment_result(hash, transaction)
585
+ status = OpenReceive::Settlement.status(transaction)
586
+ observed_at = @clock.call
587
+ paid_at = status == "settled" ? (transaction["settled_at"] || observed_at) : nil
588
+ details = { "transaction" => transaction, "observed_at" => observed_at }
589
+ details["paid_at_source"] = transaction["settled_at"] ? "settled_at" : "observed_at" if status == "settled"
590
+ {
591
+ "payment_hash" => hash,
592
+ "status" => status,
593
+ "paid_at" => paid_at,
594
+ "details" => details
595
+ }.compact
596
+ end
597
+
598
+ # Adapts call_nwc to the shared core walk's client contract and enforces
599
+ # the request-path deadline: checked only between page fetches, never
600
+ # mid-request, so a slow wallet bounds the scan instead of interrupting
601
+ # work already in flight. Once the monotonic deadline passes, the
602
+ # previous page is replayed instead of fetching another — the core walk
603
+ # recognizes the repeat and ends the scan marked truncated, so a
604
+ # deadline-cut walk can never prove an invoice absent.
605
+ class ScanClient
606
+ def initialize(service, deadline)
607
+ @service = service
608
+ @deadline = deadline
609
+ @previous = nil
610
+ end
611
+
612
+ def list_transactions(request)
613
+ if @previous && @deadline &&
614
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
615
+ return @previous
616
+ end
617
+ @previous = @service.send(:call_nwc, :list_transactions, request)
618
+ end
619
+ end
620
+ private_constant :ScanClient
621
+
622
+ # One wallet-history walk through the shared core scan (the JS
623
+ # listIncomingTransactions port): bounded like the JS scan, so a wallet
624
+ # that keeps returning full pages must not hang payments/check, swap
625
+ # creation, or reconciliation. Returns { rows:, truncated: }.
626
+ def scan_incoming_transactions(expected:, from:, until_time:, unpaid: false, max_pages: nil, deadline: nil)
627
+ OpenReceive.list_incoming_transactions(
628
+ client: ScanClient.new(self, deadline),
629
+ expected: expected,
630
+ from: from,
631
+ until_time: until_time,
632
+ max_pages: max_pages || MAX_PAGES,
633
+ include_unpaid: unpaid
634
+ )
635
+ end
636
+
637
+ # A refund is the last chance to recover a mis-sent deposit, so the
638
+ # address is checked against the order's own pay-in network with its
639
+ # checksum — a false accept here sends the payer's money somewhere
640
+ # unrecoverable. Mirrors the JS normalizeRefundAddress exactly.
641
+ def normalize_refund_address(value, pay_in_asset)
642
+ normalized = value.to_s.strip
643
+ if normalized.empty? || normalized.length > 300
644
+ raise ValidationError, "refundAddress is invalid."
645
+ end
646
+ if pay_in_asset.is_a?(String) &&
647
+ !OpenReceive::SwapAddress.valid_for_pay_in_asset?(pay_in_asset, normalized)
648
+ raise ValidationError, "refundAddress is not a valid #{pay_in_asset} address."
649
+ end
650
+ normalized
651
+ end
652
+
653
+ def select_provider(asset)
654
+ # Primary-only while healthy. Backup is consulted only when primary is
655
+ # down (raises), never to fill gaps for assets the primary simply does
656
+ # not list. Status/code/message mirror the JS selectProvider exactly.
657
+ @swap_providers.each do |provider|
658
+ begin
659
+ supported = call_provider(provider, :supported_pay_in_assets)
660
+ return provider if Array(supported).include?(asset)
661
+
662
+ # Healthy provider that omits this asset — do not fall through.
663
+ raise unsupported_swap_asset_error(asset)
664
+ rescue ServiceError
665
+ raise
666
+ rescue StandardError
667
+ # Provider request failed — try the next configured LSC connection.
668
+ next
669
+ end
670
+ end
671
+ raise unsupported_swap_asset_error(asset)
672
+ end
673
+
674
+ def unsupported_swap_asset_error(asset)
675
+ ServiceError.new(503, "INTERNAL", "No configured swap provider supports #{asset}.")
676
+ end
677
+
678
+ def provider_by_name(name)
679
+ @swap_providers.find { |provider| provider.name == name } ||
680
+ raise(ServiceError.new(503, "INTERNAL", "Swap provider #{name} is not configured."))
681
+ end
682
+
683
+ def public_swap(order, hash, reference)
684
+ {
685
+ "payment_hash" => hash,
686
+ "reference" => reference,
687
+ "provider" => order.fetch("provider"),
688
+ "pay_in_asset" => order.fetch("pay_in_asset"),
689
+ "deposit_address" => order.fetch("deposit_address"),
690
+ "deposit_memo" => order["deposit_memo"],
691
+ "deposit_amount" => order.fetch("deposit_amount"),
692
+ "provider_state" => order.fetch("state"),
693
+ "provider_expires_at" => order.fetch("expires_at"),
694
+ "deposit_tx_id" => order["deposit_tx_id"],
695
+ "payout_tx_id" => order["payout_tx_id"],
696
+ "refund_tx_id" => order["refund_tx_id"],
697
+ "refund_reason" => order["refund_reason"],
698
+ "refund_amount" => order["refund_amount"],
699
+ "attention" => order["attention"],
700
+ # Everything below explains the attempt to the payer: why they send
701
+ # more than the cart total, what actually landed on the deposit, and
702
+ # why an attempt needs an operator. `provider_token` stays server-only.
703
+ "attention_reason" => order["attention_reason"],
704
+ "deposit_received_amount" => order["deposit_received_amount"],
705
+ "emergency_repeat" => order["emergency_repeat"],
706
+ "provider_order_id" => order["provider_order_id"],
707
+ "fee" => order["fee"]
708
+ }.compact
709
+ end
710
+
711
+ # Positional-vs-keyword dispatch is decided from Method#parameters, never
712
+ # by rescuing ArgumentError and calling again: a retry after an
713
+ # ArgumentError raised INSIDE a state-changing RPC (make_invoice,
714
+ # create_swap) would invoke it a second time — two invoices for one
715
+ # attempt. Wallet failures normalize to the shared error vocabulary.
716
+ def call_nwc(method, input)
717
+ if keyword_style?(@nwc.method(method))
718
+ @nwc.public_send(method, **input.transform_keys(&:to_sym))
719
+ else
720
+ @nwc.public_send(method, input)
721
+ end
722
+ rescue ValidationError, WalletContractError, NotImplementedHttpError
723
+ raise
724
+ rescue StandardError => e
725
+ raise WalletFailureError, OpenReceive::Nwc.normalize_wallet_error(e)
726
+ end
727
+
728
+ def call_provider(provider, method, *args)
729
+ if args.length == 1 && args.first.is_a?(Hash) && keyword_style?(provider.method(method))
730
+ provider.public_send(method, **args.first.transform_keys(&:to_sym))
731
+ else
732
+ provider.public_send(method, *args)
733
+ end
734
+ end
735
+
736
+ def keyword_style?(callable)
737
+ parameters = callable.parameters
738
+ return false if parameters.any? { |type, _| %i[req opt rest].include?(type) }
739
+ parameters.any? { |type, _| %i[keyreq key keyrest].include?(type) }
740
+ rescue NameError
741
+ false
742
+ end
743
+
744
+ def stringify(value)
745
+ OpenReceive.stringify(value)
746
+ end
747
+
748
+ # The payer-input parse boundary: a missing field (KeyError) or a
749
+ # malformed one (ArgumentError) inside the block is a 400, not a 500.
750
+ # Wrap only the parse — a wallet or provider call that raises the same
751
+ # classes is not the payer's fault and must stay outside the block.
752
+ def validating_input
753
+ yield
754
+ rescue KeyError, ArgumentError => e
755
+ raise ValidationError, e.message
756
+ end
757
+
758
+ # Same message as the JS service's requests.ts ("reference is required."),
759
+ # trailing period included: a direct-service caller sees identical text on
760
+ # both engines, not only through the handler (whose own `required` check
761
+ # fires first on the mounted routes).
762
+ def required_string(value, field)
763
+ text = value.to_s.strip
764
+ raise ValidationError, "#{field} is required." if text.empty?
765
+ text
766
+ end
767
+
768
+ def normalize_payment_hash(value)
769
+ hash = required_string(value, "payment_hash").downcase
770
+ raise ValidationError, "payment_hash must be 64 hexadecimal characters" unless /\A[0-9a-f]{64}\z/.match?(hash)
771
+ hash
772
+ end
773
+ end
774
+ end
775
+ end