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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +21 -0
- data/README.md +62 -0
- data/lib/openreceive/core.rb +611 -0
- data/lib/openreceive/keccak256.rb +94 -0
- data/lib/openreceive/nwc_ruby.rb +72 -0
- data/lib/openreceive/rates.rb +498 -0
- data/lib/openreceive/swap_address.rb +147 -0
- data/lib/openreceive/version.rb +5 -0
- data/lib/openreceive.rb +7 -0
- metadata +70 -0
|
@@ -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
|