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,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
|