openreceive 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,611 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "json"
5
+ require "uri"
6
+
7
+ require_relative "version"
8
+
9
+ module OpenReceive
10
+ NWC_CODE_HELP_URL = "https://openreceive.org/get_a_nwc_code_to_receive_payments"
11
+ NWC_METADATA_MAX_BYTES = 3900
12
+ MIN_AMOUNT_MSATS = 1000
13
+ MAX_AMOUNT_MSATS = 9_007_199_254_740_991
14
+ # Mirrors JS OPENRECEIVE_TRANSACTION_PAGE_LIMIT: the page size every
15
+ # wallet-history walk requests.
16
+ TRANSACTION_PAGE_LIMIT = 20
17
+ HEX_64_PATTERN = /\A[0-9a-fA-F]{64}\z/
18
+
19
+ class NwcUriParseError < StandardError
20
+ attr_reader :code, :redacted
21
+
22
+ def initialize(code, message, uri = nil)
23
+ super(message)
24
+ @code = code
25
+ @redacted = uri.nil? ? nil : Nwc.redact_uri(uri)
26
+ end
27
+ end
28
+
29
+ class WalletUnavailableError < StandardError
30
+ attr_reader :status, :code
31
+
32
+ def initialize(message = "NWC wallet service is unavailable.")
33
+ super(message)
34
+ @status = 503
35
+ @code = "WALLET_UNAVAILABLE"
36
+ end
37
+ end
38
+
39
+ module Money
40
+ module_function
41
+
42
+ def quote_fiat_to_sats(fiat_value:, btc_fiat_price:)
43
+ fiat = decimal(fiat_value, "fiat.value")
44
+ price = decimal(btc_fiat_price, "btc_fiat_price")
45
+ raise ArgumentError, "btc_fiat_price must be greater than zero" unless price.positive?
46
+
47
+ ((fiat * 100_000_000) / price).ceil
48
+ end
49
+
50
+ def quote_fiat_to_msats(fiat_value:, btc_fiat_price:)
51
+ # Bounded like every other amount path (and like the JS quote): a large
52
+ # enough fiat value at a low enough price otherwise produces an
53
+ # amount_msats past the wire contract's 2^53-1 ceiling.
54
+ bounded_msats(quote_fiat_to_sats(fiat_value: fiat_value, btc_fiat_price: btc_fiat_price) * 1000)
55
+ end
56
+
57
+ def direct_to_msats(currency:, value:)
58
+ amount = decimal(value, "amount.value")
59
+ sats =
60
+ case currency
61
+ when "BTC" then amount * 100_000_000
62
+ when "SAT", "SATS" then amount
63
+ else raise ArgumentError, "amount.currency must be BTC, SAT, or SATS"
64
+ end
65
+ raise ArgumentError, "amount must resolve to whole satoshis" unless sats.frac.zero?
66
+
67
+ bounded_msats(sats.to_i * 1000)
68
+ end
69
+
70
+ def bounded_msats(value)
71
+ amount = Integer(value)
72
+ unless amount.between?(MIN_AMOUNT_MSATS, MAX_AMOUNT_MSATS)
73
+ raise ArgumentError, "amount_msats is outside the safe range"
74
+ end
75
+ amount
76
+ end
77
+
78
+ def decimal(value, field)
79
+ text = value.to_s
80
+ raise ArgumentError, "#{field} must be a positive decimal string" unless /\A[0-9]+(?:\.[0-9]+)?\z/.match?(text)
81
+ parsed = BigDecimal(text)
82
+ raise ArgumentError, "#{field} must be greater than zero" unless parsed.positive?
83
+ parsed
84
+ end
85
+ end
86
+
87
+ module Settlement
88
+ module_function
89
+
90
+ def settled?(transaction)
91
+ data = OpenReceive.stringify(transaction)
92
+ data["settled_at"].to_i.positive? || state?(data, "settled")
93
+ end
94
+
95
+ def status(transaction)
96
+ data = OpenReceive.stringify(transaction)
97
+ return "settled" if settled?(data)
98
+ return "expired" if state?(data, "expired")
99
+ return "failed" if state?(data, "failed")
100
+ "pending"
101
+ end
102
+
103
+ # Raw wallet states compare case-insensitively, like the JS
104
+ # isTransactionState rule.
105
+ def state?(data, expected)
106
+ [data["state"], data["transaction_state"]].any? do |value|
107
+ value.is_a?(String) && value.downcase == expected
108
+ end
109
+ end
110
+ end
111
+
112
+ module Nwc
113
+ module_function
114
+
115
+ # The transaction states OpenReceive recognizes (mirrors the JS
116
+ # TransactionState union).
117
+ TRANSACTION_STATES = %w[pending settled expired failed accepted].freeze
118
+
119
+ def make_invoice_request(request)
120
+ data = OpenReceive.stringify(request)
121
+ if present?(data["description"]) && present?(data["description_hash"])
122
+ raise ArgumentError, "description and description_hash cannot both be set"
123
+ end
124
+ if data.key?("description_hash") && !HEX_64_PATTERN.match?(data["description_hash"].to_s)
125
+ raise ArgumentError, "description_hash must be 64 hex characters"
126
+ end
127
+ result = { "amount" => Money.bounded_msats(data.fetch("amount_msats")) }
128
+ result["description"] = data["description"] if data.key?("description")
129
+ result["description_hash"] = data["description_hash"] if data.key?("description_hash")
130
+ result["expiry"] = Integer(data["expiry"]) if data.key?("expiry")
131
+ if data.key?("metadata")
132
+ raise ArgumentError, "metadata is too large" if JSON.generate(data["metadata"]).bytesize > NWC_METADATA_MAX_BYTES
133
+ result["metadata"] = data["metadata"]
134
+ end
135
+ result
136
+ end
137
+
138
+ def normalize_make_invoice_response(response)
139
+ data = OpenReceive.stringify(unwrap(response))
140
+ {
141
+ "invoice" => data.fetch("invoice"),
142
+ "payment_hash" => (data["payment_hash"] || data["paymentHash"]).to_s.downcase,
143
+ "amount_msats" => Integer(data["amount_msats"] || data["amount"]),
144
+ "created_at" => optional_integer(data["created_at"] || data["createdAt"]),
145
+ "expires_at" => optional_integer(data["expires_at"] || data["expiresAt"])
146
+ }.compact
147
+ end
148
+
149
+ def list_transactions_request(request)
150
+ data = OpenReceive.stringify(request)
151
+ result = {}
152
+ %w[from until offset limit].each { |key| result[key] = Integer(data[key]) if data.key?(key) }
153
+ result["type"] = data["type"] if data.key?("type")
154
+ result["unpaid"] = data["unpaid"] if data.key?("unpaid")
155
+ # Mirrors JS: limit must be a positive integer; no hard page cap here
156
+ # (OpenReceive's own scans use PAGE_LIMIT, but the mapper passes callers'
157
+ # limits through).
158
+ raise ArgumentError, "limit must be a positive integer" if result.key?("limit") && result["limit"] <= 0
159
+ result
160
+ end
161
+
162
+ def normalize_list_transactions_response(response)
163
+ unwrapped = unwrap(response)
164
+ data = OpenReceive.stringify(unwrapped)
165
+ rows =
166
+ if data["transactions"].is_a?(Array)
167
+ data["transactions"]
168
+ elsif unwrapped.is_a?(Array)
169
+ unwrapped
170
+ elsif unwrapped.nil? || (unwrapped.respond_to?(:each_pair) && data.empty?)
171
+ # A genuinely empty reply is an empty scan.
172
+ []
173
+ else
174
+ # A non-empty reply in a shape we do not recognize must NOT read as
175
+ # an empty scan: an empty-looking scan at/after expiry+grace closes
176
+ # pending attempts as expired. Fail the scan loudly instead.
177
+ raise ArgumentError, "list_transactions returned an unrecognized result shape"
178
+ end
179
+ # One quirky wallet row must never reject the whole scan: reconciliation
180
+ # depends on every pass succeeding, and a rejected scan can neither
181
+ # settle nor close pending attempts (a permanent livelock while the bad
182
+ # row stays inside the scan window). Bad rows are skipped and counted.
183
+ # Mirrors the JS normalizeListTransactionsResult policy.
184
+ transactions = []
185
+ skipped_rows = 0
186
+ rows.each do |row|
187
+ transactions << normalize_transaction(row)
188
+ rescue StandardError
189
+ skipped_rows += 1
190
+ end
191
+ # ALL rows unusable is the unrecognized-shape case wearing a different
192
+ # hat: a non-empty page that yields nothing is indistinguishable from an
193
+ # empty wallet, and an empty-looking scan at expiry+grace closes pending
194
+ # attempts as expired.
195
+ if transactions.empty? && skipped_rows.positive?
196
+ raise ArgumentError, "list_transactions returned no usable rows"
197
+ end
198
+
199
+ result = { "transactions" => transactions }
200
+ result["skipped_rows"] = skipped_rows if skipped_rows.positive?
201
+ result
202
+ end
203
+
204
+ def normalize_transaction(transaction)
205
+ data = OpenReceive.stringify(transaction)
206
+ {
207
+ "type" => data["type"],
208
+ "invoice" => data["invoice"],
209
+ "payment_hash" => optional_payment_hash(data["payment_hash"] || data["paymentHash"]),
210
+ "amount_msats" => optional_integer(data["amount_msats"] || data["amount"]),
211
+ "transaction_state" => transaction_state(data),
212
+ "created_at" => optional_integer(data["created_at"] || data["createdAt"]),
213
+ "expires_at" => optional_integer(data["expires_at"] || data["expiresAt"]),
214
+ "settled_at" => optional_integer(data["settled_at"] || data["settledAt"]),
215
+ "fees_paid_msats" => optional_integer(data["fees_paid"] || data["feesPaid"]),
216
+ "preimage" => data["preimage"]
217
+ }.compact
218
+ end
219
+
220
+ # Mirrors the JS normalizeNwcTransaction state mapping: recognized states
221
+ # pass through lowercased, and a wallet that signals settlement only via
222
+ # boolean settled/paid flags maps to "settled".
223
+ def transaction_state(data)
224
+ raw = data["transaction_state"] || data["transactionState"] || data["state"]
225
+ normalized = raw.downcase if raw.is_a?(String)
226
+ return normalized if TRANSACTION_STATES.include?(normalized)
227
+ "settled" if data["settled"] == true || data["paid"] == true
228
+ end
229
+
230
+ # Mirrors JS parseNwcUri: same error codes for the same failures so both
231
+ # engines pass the shared nwc-uri-parse vectors.
232
+ def parse_uri(uri)
233
+ raise NwcUriParseError.new("invalid_uri", "Invalid NWC URI.", nil) unless uri.is_a?(String) && !uri.strip.empty?
234
+ parsed = URI.parse(uri)
235
+ raise NwcUriParseError.new("invalid_scheme", "NWC URI must use nostr+walletconnect.", uri) unless parsed.scheme == "nostr+walletconnect"
236
+ # Opaque form (`nostr+walletconnect:<pubkey>?...`, no slashes): Ruby's
237
+ # URI keeps "<pubkey>?query" whole in #opaque with #query nil, while
238
+ # JS's WHATWG URL exposes it as pathname + searchParams — split it here
239
+ # so both engines accept the same URIs.
240
+ if parsed.host.to_s.empty? && !parsed.opaque.nil?
241
+ wallet, separator, query = parsed.opaque.to_s.partition("?")
242
+ query = parsed.query if separator.empty?
243
+ else
244
+ wallet = parsed.host.to_s.empty? ? parsed.path.to_s.sub(%r{\A/+}, "") : parsed.host
245
+ query = parsed.query
246
+ end
247
+ raise NwcUriParseError.new("missing_wallet_pubkey", "NWC URI is missing the wallet public key.", uri) if wallet.to_s.empty?
248
+ raise NwcUriParseError.new("invalid_wallet_pubkey", "NWC wallet public key must be 64 hex characters.", uri) unless HEX_64_PATTERN.match?(wallet)
249
+ pairs = URI.decode_www_form(query.to_s)
250
+ relays = pairs.filter_map { |key, value| value if key == "relay" }
251
+ secrets = pairs.filter_map { |key, value| value if key == "secret" }
252
+ raise NwcUriParseError.new("missing_relay", "NWC URI must include at least one relay.", uri) if relays.empty?
253
+ relays.each do |relay|
254
+ raise NwcUriParseError.new("invalid_relay", "NWC relay URLs must be valid wss URLs.", uri) unless valid_relay_url?(relay)
255
+ end
256
+ raise NwcUriParseError.new("missing_secret", "NWC URI is missing the client secret.", uri) if secrets.empty? || secrets.first.to_s.empty?
257
+ unless secrets.length == 1 && HEX_64_PATTERN.match?(secrets.first)
258
+ raise NwcUriParseError.new("invalid_secret", "NWC client secret must be 64 hex characters.", uri)
259
+ end
260
+ lud16 = pairs.filter_map { |key, value| value if key == "lud16" }.first
261
+ result = { wallet_pubkey: wallet, relays: relays, client_secret: secrets.first, redacted: redact_uri(uri) }
262
+ result[:lud16] = lud16 unless lud16.nil? || lud16.empty?
263
+ result
264
+ rescue URI::InvalidURIError
265
+ raise NwcUriParseError.new("invalid_uri", "Invalid NWC URI.", uri)
266
+ end
267
+
268
+ def valid_relay_url?(relay)
269
+ parsed = URI.parse(relay.to_s)
270
+ parsed.scheme == "wss" && !parsed.host.to_s.empty?
271
+ rescue URI::InvalidURIError
272
+ false
273
+ end
274
+
275
+ # Redacts every query pair whose PERCENT-DECODED key is "secret" (JS
276
+ # decodes keys first, so %73ecret= must not slip past redaction). Other
277
+ # pairs keep their original bytes.
278
+ def redact_uri(uri)
279
+ text = uri.to_s
280
+ query_start = text.index("?")
281
+ return text if query_start.nil?
282
+ fragment_start = text.index("#", query_start + 1)
283
+ query_end = fragment_start.nil? ? text.length : fragment_start
284
+ query = text[(query_start + 1)...query_end]
285
+ redacted = query.split("&", -1).map do |pair|
286
+ separator = pair.index("=")
287
+ key = separator.nil? ? pair : pair[0...separator]
288
+ decoded_key = begin
289
+ URI.decode_www_form_component(key)
290
+ rescue ArgumentError
291
+ key
292
+ end
293
+ decoded_key.downcase == "secret" && !separator.nil? ? "#{key}=[REDACTED]" : pair
294
+ end.join("&")
295
+ "#{text[0..query_start]}#{redacted}#{text[query_end..]}"
296
+ end
297
+
298
+ # Canonical OpenReceive error codes (mirrors the JS generated contract).
299
+ ERROR_CODES = %w[
300
+ NOT_IMPLEMENTED RESTRICTED UNAUTHORIZED FORBIDDEN RATE_LIMITED QUOTA_EXCEEDED
301
+ INTERNAL UNSUPPORTED_ENCRYPTION
302
+ OTHER NOT_FOUND TIMEOUT INVALID_REQUEST WALLET_UNAVAILABLE
303
+ INVOICE_EXPIRED UNSUPPORTED_METHOD CONFLICT
304
+ ].freeze
305
+ RETRYABLE_ERROR_CODES = %w[RATE_LIMITED QUOTA_EXCEEDED TIMEOUT WALLET_UNAVAILABLE INTERNAL].freeze
306
+ # Wallet/library spellings that map onto canonical codes (mirrors JS
307
+ # NWC_ERROR_CODE_ALIASES).
308
+ ERROR_CODE_ALIASES = {
309
+ "ABORT_ERROR" => "TIMEOUT",
310
+ "BAD_REQUEST" => "INVALID_REQUEST",
311
+ "CONNECTION_ERROR" => "WALLET_UNAVAILABLE",
312
+ "EXPIRED" => "INVOICE_EXPIRED",
313
+ "FETCH_ERROR" => "WALLET_UNAVAILABLE",
314
+ "FORBIDDEN" => "RESTRICTED",
315
+ "INVOICE_NOT_FOUND" => "NOT_FOUND",
316
+ "INVALID_PARAMETER" => "INVALID_REQUEST",
317
+ "INVALID_PARAMETERS" => "INVALID_REQUEST",
318
+ "INVALID_PARAMS" => "INVALID_REQUEST",
319
+ "METHOD_NOT_FOUND" => "UNSUPPORTED_METHOD",
320
+ "NETWORK_ERROR" => "WALLET_UNAVAILABLE",
321
+ "NIP47_NETWORK_ERROR" => "WALLET_UNAVAILABLE",
322
+ "NOSTR_NETWORK_ERROR" => "WALLET_UNAVAILABLE",
323
+ "NOT_AUTHORIZED" => "UNAUTHORIZED",
324
+ "NOT_SUPPORTED" => "UNSUPPORTED_METHOD",
325
+ "NOTFOUND" => "NOT_FOUND",
326
+ "PERMISSION_DENIED" => "RESTRICTED",
327
+ "RELAY_CONNECTION_ERROR" => "WALLET_UNAVAILABLE",
328
+ "REQUEST_TIMEOUT" => "TIMEOUT",
329
+ "SERVICE_UNAVAILABLE" => "WALLET_UNAVAILABLE",
330
+ "TIMED_OUT" => "TIMEOUT",
331
+ "TIMEOUT_ERROR" => "TIMEOUT",
332
+ "UNKNOWN_METHOD" => "UNSUPPORTED_METHOD",
333
+ "UNSUPPORTED" => "UNSUPPORTED_METHOD",
334
+ "UNSUPPORTED_ENCRYPTION_MODE" => "UNSUPPORTED_ENCRYPTION",
335
+ "WALLET_OFFLINE" => "WALLET_UNAVAILABLE",
336
+ "WALLET_UNREACHABLE" => "WALLET_UNAVAILABLE"
337
+ }.freeze
338
+ ERROR_MESSAGES = {
339
+ "NOT_IMPLEMENTED" => "NWC wallet service does not implement this method.",
340
+ "RESTRICTED" => "NWC wallet service restricted this request.",
341
+ "UNAUTHORIZED" => "NWC wallet service rejected authorization.",
342
+ "FORBIDDEN" => "The host application did not authorize this request.",
343
+ "RATE_LIMITED" => "NWC wallet service rate limited this request.",
344
+ "QUOTA_EXCEEDED" => "NWC wallet service quota was exceeded.",
345
+ "INTERNAL" => "NWC wallet service returned an internal error.",
346
+ "UNSUPPORTED_ENCRYPTION" => "NWC wallet service does not support the required encryption mode.",
347
+ "OTHER" => "NWC wallet service returned an unknown error.",
348
+ "NOT_FOUND" => "NWC wallet service could not find the requested resource.",
349
+ "TIMEOUT" => "NWC wallet service request timed out.",
350
+ "INVALID_REQUEST" => "OpenReceive sent an invalid NWC wallet request.",
351
+ "WALLET_UNAVAILABLE" => "NWC wallet service is unavailable.",
352
+ "INVOICE_EXPIRED" => "NWC wallet reported that the invoice is expired.",
353
+ "UNSUPPORTED_METHOD" => "NWC wallet service does not support the requested method.",
354
+ "CONFLICT" => "NWC wallet service reported a conflicting request."
355
+ }.freeze
356
+
357
+ # Normalize any wallet/library failure into the canonical error body shape
358
+ # shared with JS (spec/test-vectors/error-normalization.json):
359
+ # { "code", "message", "retryable", "request_id"?, "details"? }.
360
+ def normalize_wallet_error(raw)
361
+ records = collect_error_records(raw)
362
+ code = error_code_from_records(records) ||
363
+ (raw.is_a?(String) ? normalize_error_code(raw) : nil) ||
364
+ "OTHER"
365
+ {
366
+ "code" => code,
367
+ "message" => error_message_from(records, raw, code),
368
+ "retryable" => first_boolean(records, "retryable") { RETRYABLE_ERROR_CODES.include?(code) },
369
+ "request_id" => first_string(records, %w[request_id requestId]),
370
+ "details" => records.filter_map { |record| record["details"] if record["details"].is_a?(Hash) }.first
371
+ }.compact
372
+ end
373
+
374
+ def normalize_error_code(value)
375
+ return nil unless value.is_a?(String) && !value.strip.empty?
376
+ normalized = value.strip
377
+ .gsub(/([a-z0-9])([A-Z])/, '\1_\2')
378
+ .gsub(/[^a-zA-Z0-9]+/, "_")
379
+ .gsub(/\A_+|_+\z/, "")
380
+ .upcase
381
+ # Aliases first (mirrors JS): a wallet's own "FORBIDDEN" is a wallet
382
+ # restriction (RESTRICTED), never the host application's FORBIDDEN.
383
+ ERROR_CODE_ALIASES[normalized] || (normalized if ERROR_CODES.include?(normalized))
384
+ end
385
+
386
+ def error_code_from_records(records)
387
+ records.each do |record|
388
+ direct = %w[code error_code errorCode type].filter_map { |key| normalize_error_code(record[key]) }.first
389
+ return direct if direct && direct != "OTHER"
390
+ name = normalize_error_code(record["name"])
391
+ return name if name && name != "OTHER"
392
+ return direct unless direct.nil?
393
+ end
394
+ nil
395
+ end
396
+
397
+ def error_message_from(records, raw, code)
398
+ message = first_string(records, %w[message description reason])
399
+ return message if message && normalize_error_code(message) != code
400
+ if raw.is_a?(String) && normalize_error_code(raw).nil? && !raw.strip.empty?
401
+ return raw.strip
402
+ end
403
+ ERROR_MESSAGES.fetch(code)
404
+ end
405
+
406
+ def collect_error_records(value, seen = [])
407
+ return [] if value.nil? || seen.include?(value.object_id)
408
+ seen << value.object_id
409
+ records = []
410
+ if value.is_a?(Exception)
411
+ record = { "name" => value.class.name.split("::").last, "message" => value.message }
412
+ record["code"] = value.code if value.respond_to?(:code)
413
+ records << record
414
+ records.concat(collect_error_records(value.cause, seen)) if value.cause
415
+ elsif value.respond_to?(:each_pair)
416
+ record = OpenReceive.as_string_keys(value)
417
+ records << record
418
+ %w[error cause data].each do |key|
419
+ records.concat(collect_error_records(record[key], seen)) if record[key]
420
+ end
421
+ end
422
+ records
423
+ end
424
+
425
+ def first_string(records, keys)
426
+ records.each do |record|
427
+ keys.each do |key|
428
+ value = record[key]
429
+ return value if value.is_a?(String) && !value.empty?
430
+ end
431
+ end
432
+ nil
433
+ end
434
+
435
+ def first_boolean(records, key)
436
+ records.each do |record|
437
+ value = record[key]
438
+ return value if value == true || value == false
439
+ end
440
+ yield
441
+ end
442
+
443
+ def unwrap(value)
444
+ data = OpenReceive.stringify(value)
445
+ data.key?("result") ? data["result"] : value
446
+ end
447
+
448
+ def optional_integer(value)
449
+ value.nil? ? nil : Integer(value)
450
+ end
451
+
452
+ # ABSENT means absent — a row minted by another app through the same wallet
453
+ # legitimately carries no hash. PRESENT but not a 64-hex string is a row we
454
+ # do not understand; it raises so the scan skips and counts it, mirroring
455
+ # the JS normalizeNwcTransaction ruling.
456
+ def optional_payment_hash(value)
457
+ return nil if value.nil? || value == ""
458
+
459
+ hash = value.to_s.downcase
460
+ raise ArgumentError, "payment_hash must be 64 hexadecimal characters" unless /\A[0-9a-f]{64}\z/.match?(hash)
461
+
462
+ hash
463
+ end
464
+
465
+ def present?(value)
466
+ !value.nil? && value != ""
467
+ end
468
+ end
469
+
470
+ # Ruby port of the JS core wallet-history walk (listIncomingTransactions in
471
+ # packages/js/core/src/payments.ts): pages list_transactions until every
472
+ # expected hash is seen, the wallet runs out of rows, or the page cap is
473
+ # reached. A walk that ended before the wallet ran out of rows is TRUNCATED —
474
+ # a hash such a walk did not see is unproven, never proven absent.
475
+ module Payments
476
+ module_function
477
+
478
+ # `client` must respond to list_transactions(request) with an OpenReceive
479
+ # request hash; raw NIP-47 and already-normalized responses both work.
480
+ # Returns { rows: { "<payment_hash>" => <normalized transaction> },
481
+ # truncated: true | false }.
482
+ def list_incoming_transactions(client:, expected:, from: nil, until_time: nil, max_pages: nil, include_unpaid: false)
483
+ pages = normalize_max_pages(max_pages)
484
+ outstanding = Array(expected).map { |value| normalize_payment_hash(value) }
485
+ scan_from = from.nil? ? nil : normalize_unix(from, "from")
486
+ scan_until = until_time.nil? ? nil : normalize_unix(until_time, "until")
487
+ rows = {}
488
+ offset = 0
489
+ previous_page = nil
490
+ # Proven false the moment the wallet runs out of rows or every expected
491
+ # hash is accounted for; otherwise the walk hit its cap with rows still
492
+ # to come.
493
+ truncated = true
494
+ pages.times do
495
+ request = { "type" => "incoming", "limit" => TRANSACTION_PAGE_LIMIT, "offset" => offset }
496
+ request["unpaid"] = true if include_unpaid
497
+ request["from"] = scan_from unless scan_from.nil?
498
+ request["until"] = scan_until unless scan_until.nil?
499
+ page = Nwc.normalize_list_transactions_response(client.list_transactions(request)).fetch("transactions")
500
+ page.each do |row|
501
+ next unless row["type"].nil? || row["type"] == "incoming"
502
+ payment_hash = row_payment_hash(row)
503
+ next if payment_hash.nil?
504
+ rows[payment_hash] = row
505
+ outstanding.delete(payment_hash)
506
+ end
507
+ if outstanding.empty? || page.length < TRANSACTION_PAGE_LIMIT
508
+ truncated = false
509
+ break
510
+ end
511
+ # A wallet that ignores `offset` serves the same page forever; stop
512
+ # instead of paging to the cap, and keep the scan marked incomplete.
513
+ page_key = page.map { |row| row["payment_hash"].to_s }.join(",")
514
+ break if page_key == previous_page
515
+ previous_page = page_key
516
+ offset += TRANSACTION_PAGE_LIMIT
517
+ end
518
+ { rows: rows, truncated: truncated }
519
+ end
520
+
521
+ def normalize_max_pages(value)
522
+ return 10_000 if value.nil?
523
+ pages = Integer(value)
524
+ raise ArgumentError, "max_pages must be a positive integer" unless pages.positive?
525
+ pages
526
+ end
527
+
528
+ def normalize_payment_hash(value)
529
+ normalized = value.to_s.strip.downcase
530
+ unless /\A[0-9a-f]{64}\z/.match?(normalized)
531
+ raise ArgumentError, "payment_hash must be 64 hexadecimal characters"
532
+ end
533
+ normalized
534
+ end
535
+
536
+ # The scan key for one wallet row, or nil when the row can never match an
537
+ # attempt — a wallet-supplied row with a missing or malformed hash is
538
+ # skipped rather than rejected, so one quirky row cannot livelock
539
+ # reconciliation.
540
+ def row_payment_hash(row)
541
+ payment_hash = row["payment_hash"].to_s.strip.downcase
542
+ /\A[0-9a-f]{64}\z/.match?(payment_hash) ? payment_hash : nil
543
+ end
544
+
545
+ def normalize_unix(value, field)
546
+ number = Integer(value)
547
+ raise ArgumentError, "#{field} must be a non-negative integer" if number.negative?
548
+ number
549
+ end
550
+ end
551
+
552
+ module_function
553
+
554
+ # Wire payloads reach both engines with string keys, symbol keys, or as a
555
+ # wallet SDK value object. Both helpers flatten that to string keys; they
556
+ # differ only in what they do with a non-hash. `stringify` tolerates it and
557
+ # returns {} — use it on anything a third party supplied. `as_string_keys`
558
+ # does not — use it where the caller has already proven the value is a hash,
559
+ # so a missing hash fails loudly at the source instead of as a KeyError later.
560
+ def stringify(value)
561
+ return {} unless value.respond_to?(:each_pair)
562
+ as_string_keys(value)
563
+ end
564
+
565
+ def as_string_keys(hash)
566
+ hash.each_pair.to_h { |key, item| [key.to_s, item] }
567
+ end
568
+
569
+ def quote_fiat_to_msats(fiat_value:, btc_fiat_price:)
570
+ Money.quote_fiat_to_msats(fiat_value: fiat_value, btc_fiat_price: btc_fiat_price)
571
+ end
572
+
573
+ def settled?(transaction)
574
+ Settlement.settled?(transaction)
575
+ end
576
+
577
+ def parse_nwc_uri(uri)
578
+ Nwc.parse_uri(uri)
579
+ end
580
+
581
+ def redact_nwc_uri(uri)
582
+ Nwc.redact_uri(uri)
583
+ end
584
+
585
+ def make_invoice_nip47_request(request)
586
+ Nwc.make_invoice_request(request)
587
+ end
588
+
589
+ def normalize_make_invoice_response(response)
590
+ Nwc.normalize_make_invoice_response(response)
591
+ end
592
+
593
+ def list_transactions_nip47_request(request)
594
+ Nwc.list_transactions_request(request)
595
+ end
596
+
597
+ def normalize_list_transactions_response(response)
598
+ Nwc.normalize_list_transactions_response(response)
599
+ end
600
+
601
+ def list_incoming_transactions(client:, expected:, from: nil, until_time: nil, max_pages: nil, include_unpaid: false)
602
+ Payments.list_incoming_transactions(
603
+ client: client,
604
+ expected: expected,
605
+ from: from,
606
+ until_time: until_time,
607
+ max_pages: max_pages,
608
+ include_unpaid: include_unpaid
609
+ )
610
+ end
611
+ end