Module: OpenReceive::Nwc

Defined in:
lib/openreceive/core.rb

Constant Summary collapse

TRANSACTION_STATES =

The transaction states OpenReceive recognizes (mirrors the JS TransactionState union).

%w[pending settled expired failed accepted].freeze
ERROR_CODES =

Canonical OpenReceive error codes (mirrors the JS generated contract).

%w[
  NOT_IMPLEMENTED RESTRICTED UNAUTHORIZED FORBIDDEN RATE_LIMITED QUOTA_EXCEEDED
  INTERNAL UNSUPPORTED_ENCRYPTION
  OTHER NOT_FOUND TIMEOUT INVALID_REQUEST WALLET_UNAVAILABLE
  INVOICE_EXPIRED UNSUPPORTED_METHOD CONFLICT
].freeze
RETRYABLE_ERROR_CODES =
%w[RATE_LIMITED QUOTA_EXCEEDED TIMEOUT WALLET_UNAVAILABLE INTERNAL].freeze
ERROR_CODE_ALIASES =

Wallet/library spellings that map onto canonical codes (mirrors JS NWC_ERROR_CODE_ALIASES).

{
  "ABORT_ERROR" => "TIMEOUT",
  "BAD_REQUEST" => "INVALID_REQUEST",
  "CONNECTION_ERROR" => "WALLET_UNAVAILABLE",
  "EXPIRED" => "INVOICE_EXPIRED",
  "FETCH_ERROR" => "WALLET_UNAVAILABLE",
  "FORBIDDEN" => "RESTRICTED",
  "INVOICE_NOT_FOUND" => "NOT_FOUND",
  "INVALID_PARAMETER" => "INVALID_REQUEST",
  "INVALID_PARAMETERS" => "INVALID_REQUEST",
  "INVALID_PARAMS" => "INVALID_REQUEST",
  "METHOD_NOT_FOUND" => "UNSUPPORTED_METHOD",
  "NETWORK_ERROR" => "WALLET_UNAVAILABLE",
  "NIP47_NETWORK_ERROR" => "WALLET_UNAVAILABLE",
  "NOSTR_NETWORK_ERROR" => "WALLET_UNAVAILABLE",
  "NOT_AUTHORIZED" => "UNAUTHORIZED",
  "NOT_SUPPORTED" => "UNSUPPORTED_METHOD",
  "NOTFOUND" => "NOT_FOUND",
  "PERMISSION_DENIED" => "RESTRICTED",
  "RELAY_CONNECTION_ERROR" => "WALLET_UNAVAILABLE",
  "REQUEST_TIMEOUT" => "TIMEOUT",
  "SERVICE_UNAVAILABLE" => "WALLET_UNAVAILABLE",
  "TIMED_OUT" => "TIMEOUT",
  "TIMEOUT_ERROR" => "TIMEOUT",
  "UNKNOWN_METHOD" => "UNSUPPORTED_METHOD",
  "UNSUPPORTED" => "UNSUPPORTED_METHOD",
  "UNSUPPORTED_ENCRYPTION_MODE" => "UNSUPPORTED_ENCRYPTION",
  "WALLET_OFFLINE" => "WALLET_UNAVAILABLE",
  "WALLET_UNREACHABLE" => "WALLET_UNAVAILABLE"
}.freeze
ERROR_MESSAGES =
{
  "NOT_IMPLEMENTED" => "NWC wallet service does not implement this method.",
  "RESTRICTED" => "NWC wallet service restricted this request.",
  "UNAUTHORIZED" => "NWC wallet service rejected authorization.",
  "FORBIDDEN" => "The host application did not authorize this request.",
  "RATE_LIMITED" => "NWC wallet service rate limited this request.",
  "QUOTA_EXCEEDED" => "NWC wallet service quota was exceeded.",
  "INTERNAL" => "NWC wallet service returned an internal error.",
  "UNSUPPORTED_ENCRYPTION" => "NWC wallet service does not support the required encryption mode.",
  "OTHER" => "NWC wallet service returned an unknown error.",
  "NOT_FOUND" => "NWC wallet service could not find the requested resource.",
  "TIMEOUT" => "NWC wallet service request timed out.",
  "INVALID_REQUEST" => "OpenReceive sent an invalid NWC wallet request.",
  "WALLET_UNAVAILABLE" => "NWC wallet service is unavailable.",
  "INVOICE_EXPIRED" => "NWC wallet reported that the invoice is expired.",
  "UNSUPPORTED_METHOD" => "NWC wallet service does not support the requested method.",
  "CONFLICT" => "NWC wallet service reported a conflicting request."
}.freeze

Class Method Summary collapse

Class Method Details

.collect_error_records(value, seen = []) ⇒ Object



406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
# File 'lib/openreceive/core.rb', line 406

def collect_error_records(value, seen = [])
  return [] if value.nil? || seen.include?(value.object_id)
  seen << value.object_id
  records = []
  if value.is_a?(Exception)
    record = { "name" => value.class.name.split("::").last, "message" => value.message }
    record["code"] = value.code if value.respond_to?(:code)
    records << record
    records.concat(collect_error_records(value.cause, seen)) if value.cause
  elsif value.respond_to?(:each_pair)
    record = OpenReceive.as_string_keys(value)
    records << record
    %w[error cause data].each do |key|
      records.concat(collect_error_records(record[key], seen)) if record[key]
    end
  end
  records
end

.error_code_from_records(records) ⇒ Object



386
387
388
389
390
391
392
393
394
395
# File 'lib/openreceive/core.rb', line 386

def error_code_from_records(records)
  records.each do |record|
    direct = %w[code error_code errorCode type].filter_map { |key| normalize_error_code(record[key]) }.first
    return direct if direct && direct != "OTHER"
    name = normalize_error_code(record["name"])
    return name if name && name != "OTHER"
    return direct unless direct.nil?
  end
  nil
end

.error_message_from(records, raw, code) ⇒ Object



397
398
399
400
401
402
403
404
# File 'lib/openreceive/core.rb', line 397

def error_message_from(records, raw, code)
  message = first_string(records, %w[message description reason])
  return message if message && normalize_error_code(message) != code
  if raw.is_a?(String) && normalize_error_code(raw).nil? && !raw.strip.empty?
    return raw.strip
  end
  ERROR_MESSAGES.fetch(code)
end

.first_boolean(records, key) ⇒ Object



435
436
437
438
439
440
441
# File 'lib/openreceive/core.rb', line 435

def first_boolean(records, key)
  records.each do |record|
    value = record[key]
    return value if value == true || value == false
  end
  yield
end

.first_string(records, keys) ⇒ Object



425
426
427
428
429
430
431
432
433
# File 'lib/openreceive/core.rb', line 425

def first_string(records, keys)
  records.each do |record|
    keys.each do |key|
      value = record[key]
      return value if value.is_a?(String) && !value.empty?
    end
  end
  nil
end

.list_transactions_request(request) ⇒ Object

Raises:

  • (ArgumentError)


149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/openreceive/core.rb', line 149

def list_transactions_request(request)
  data = OpenReceive.stringify(request)
  result = {}
  %w[from until offset limit].each { |key| result[key] = Integer(data[key]) if data.key?(key) }
  result["type"] = data["type"] if data.key?("type")
  result["unpaid"] = data["unpaid"] if data.key?("unpaid")
  # Mirrors JS: limit must be a positive integer; no hard page cap here
  # (OpenReceive's own scans use PAGE_LIMIT, but the mapper passes callers'
  # limits through).
  raise ArgumentError, "limit must be a positive integer" if result.key?("limit") && result["limit"] <= 0
  result
end

.make_invoice_request(request) ⇒ Object



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/openreceive/core.rb', line 119

def make_invoice_request(request)
  data = OpenReceive.stringify(request)
  if present?(data["description"]) && present?(data["description_hash"])
    raise ArgumentError, "description and description_hash cannot both be set"
  end
  if data.key?("description_hash") && !HEX_64_PATTERN.match?(data["description_hash"].to_s)
    raise ArgumentError, "description_hash must be 64 hex characters"
  end
  result = { "amount" => Money.bounded_msats(data.fetch("amount_msats")) }
  result["description"] = data["description"] if data.key?("description")
  result["description_hash"] = data["description_hash"] if data.key?("description_hash")
  result["expiry"] = Integer(data["expiry"]) if data.key?("expiry")
  if data.key?("metadata")
    raise ArgumentError, "metadata is too large" if JSON.generate(data["metadata"]).bytesize > NWC_METADATA_MAX_BYTES
    result["metadata"] = data["metadata"]
  end
  result
end

.normalize_error_code(value) ⇒ Object



374
375
376
377
378
379
380
381
382
383
384
# File 'lib/openreceive/core.rb', line 374

def normalize_error_code(value)
  return nil unless value.is_a?(String) && !value.strip.empty?
  normalized = value.strip
                    .gsub(/([a-z0-9])([A-Z])/, '\1_\2')
                    .gsub(/[^a-zA-Z0-9]+/, "_")
                    .gsub(/\A_+|_+\z/, "")
                    .upcase
  # Aliases first (mirrors JS): a wallet's own "FORBIDDEN" is a wallet
  # restriction (RESTRICTED), never the host application's FORBIDDEN.
  ERROR_CODE_ALIASES[normalized] || (normalized if ERROR_CODES.include?(normalized))
end

.normalize_list_transactions_response(response) ⇒ Object



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/openreceive/core.rb', line 162

def normalize_list_transactions_response(response)
  unwrapped = unwrap(response)
  data = OpenReceive.stringify(unwrapped)
  rows =
    if data["transactions"].is_a?(Array)
      data["transactions"]
    elsif unwrapped.is_a?(Array)
      unwrapped
    elsif unwrapped.nil? || (unwrapped.respond_to?(:each_pair) && data.empty?)
      # A genuinely empty reply is an empty scan.
      []
    else
      # A non-empty reply in a shape we do not recognize must NOT read as
      # an empty scan: an empty-looking scan at/after expiry+grace closes
      # pending attempts as expired. Fail the scan loudly instead.
      raise ArgumentError, "list_transactions returned an unrecognized result shape"
    end
  # One quirky wallet row must never reject the whole scan: reconciliation
  # depends on every pass succeeding, and a rejected scan can neither
  # settle nor close pending attempts (a permanent livelock while the bad
  # row stays inside the scan window). Bad rows are skipped and counted.
  # Mirrors the JS normalizeListTransactionsResult policy.
  transactions = []
  skipped_rows = 0
  rows.each do |row|
    transactions << normalize_transaction(row)
  rescue StandardError
    skipped_rows += 1
  end
  # ALL rows unusable is the unrecognized-shape case wearing a different
  # hat: a non-empty page that yields nothing is indistinguishable from an
  # empty wallet, and an empty-looking scan at expiry+grace closes pending
  # attempts as expired.
  if transactions.empty? && skipped_rows.positive?
    raise ArgumentError, "list_transactions returned no usable rows"
  end

  result = { "transactions" => transactions }
  result["skipped_rows"] = skipped_rows if skipped_rows.positive?
  result
end

.normalize_make_invoice_response(response) ⇒ Object



138
139
140
141
142
143
144
145
146
147
# File 'lib/openreceive/core.rb', line 138

def normalize_make_invoice_response(response)
  data = OpenReceive.stringify(unwrap(response))
  {
    "invoice" => data.fetch("invoice"),
    "payment_hash" => (data["payment_hash"] || data["paymentHash"]).to_s.downcase,
    "amount_msats" => Integer(data["amount_msats"] || data["amount"]),
    "created_at" => optional_integer(data["created_at"] || data["createdAt"]),
    "expires_at" => optional_integer(data["expires_at"] || data["expiresAt"])
  }.compact
end

.normalize_transaction(transaction) ⇒ Object



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/openreceive/core.rb', line 204

def normalize_transaction(transaction)
  data = OpenReceive.stringify(transaction)
  {
    "type" => data["type"],
    "invoice" => data["invoice"],
    "payment_hash" => optional_payment_hash(data["payment_hash"] || data["paymentHash"]),
    "amount_msats" => optional_integer(data["amount_msats"] || data["amount"]),
    "transaction_state" => transaction_state(data),
    "created_at" => optional_integer(data["created_at"] || data["createdAt"]),
    "expires_at" => optional_integer(data["expires_at"] || data["expiresAt"]),
    "settled_at" => optional_integer(data["settled_at"] || data["settledAt"]),
    "fees_paid_msats" => optional_integer(data["fees_paid"] || data["feesPaid"]),
    "preimage" => data["preimage"]
  }.compact
end

.normalize_wallet_error(raw) ⇒ Object

Normalize any wallet/library failure into the canonical error body shape shared with JS (spec/test-vectors/error-normalization.json): { "code", "message", "retryable", "request_id"?, "details"? }.



360
361
362
363
364
365
366
367
368
369
370
371
372
# File 'lib/openreceive/core.rb', line 360

def normalize_wallet_error(raw)
  records = collect_error_records(raw)
  code = error_code_from_records(records) ||
         (raw.is_a?(String) ? normalize_error_code(raw) : nil) ||
         "OTHER"
  {
    "code" => code,
    "message" => error_message_from(records, raw, code),
    "retryable" => first_boolean(records, "retryable") { RETRYABLE_ERROR_CODES.include?(code) },
    "request_id" => first_string(records, %w[request_id requestId]),
    "details" => records.filter_map { |record| record["details"] if record["details"].is_a?(Hash) }.first
  }.compact
end

.optional_integer(value) ⇒ Object



448
449
450
# File 'lib/openreceive/core.rb', line 448

def optional_integer(value)
  value.nil? ? nil : Integer(value)
end

.optional_payment_hash(value) ⇒ Object

ABSENT means absent — a row minted by another app through the same wallet legitimately carries no hash. PRESENT but not a 64-hex string is a row we do not understand; it raises so the scan skips and counts it, mirroring the JS normalizeNwcTransaction ruling.

Raises:

  • (ArgumentError)


456
457
458
459
460
461
462
463
# File 'lib/openreceive/core.rb', line 456

def optional_payment_hash(value)
  return nil if value.nil? || value == ""

  hash = value.to_s.downcase
  raise ArgumentError, "payment_hash must be 64 hexadecimal characters" unless /\A[0-9a-f]{64}\z/.match?(hash)

  hash
end

.parse_uri(uri) ⇒ Object

Mirrors JS parseNwcUri: same error codes for the same failures so both engines pass the shared nwc-uri-parse vectors.



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/openreceive/core.rb', line 232

def parse_uri(uri)
  raise NwcUriParseError.new("invalid_uri", "Invalid NWC URI.", nil) unless uri.is_a?(String) && !uri.strip.empty?
  parsed = URI.parse(uri)
  raise NwcUriParseError.new("invalid_scheme", "NWC URI must use nostr+walletconnect.", uri) unless parsed.scheme == "nostr+walletconnect"
  # Opaque form (`nostr+walletconnect:<pubkey>?...`, no slashes): Ruby's
  # URI keeps "<pubkey>?query" whole in #opaque with #query nil, while
  # JS's WHATWG URL exposes it as pathname + searchParams — split it here
  # so both engines accept the same URIs.
  if parsed.host.to_s.empty? && !parsed.opaque.nil?
    wallet, separator, query = parsed.opaque.to_s.partition("?")
    query = parsed.query if separator.empty?
  else
    wallet = parsed.host.to_s.empty? ? parsed.path.to_s.sub(%r{\A/+}, "") : parsed.host
    query = parsed.query
  end
  raise NwcUriParseError.new("missing_wallet_pubkey", "NWC URI is missing the wallet public key.", uri) if wallet.to_s.empty?
  raise NwcUriParseError.new("invalid_wallet_pubkey", "NWC wallet public key must be 64 hex characters.", uri) unless HEX_64_PATTERN.match?(wallet)
  pairs = URI.decode_www_form(query.to_s)
  relays = pairs.filter_map { |key, value| value if key == "relay" }
  secrets = pairs.filter_map { |key, value| value if key == "secret" }
  raise NwcUriParseError.new("missing_relay", "NWC URI must include at least one relay.", uri) if relays.empty?
  relays.each do |relay|
    raise NwcUriParseError.new("invalid_relay", "NWC relay URLs must be valid wss URLs.", uri) unless valid_relay_url?(relay)
  end
  raise NwcUriParseError.new("missing_secret", "NWC URI is missing the client secret.", uri) if secrets.empty? || secrets.first.to_s.empty?
  unless secrets.length == 1 && HEX_64_PATTERN.match?(secrets.first)
    raise NwcUriParseError.new("invalid_secret", "NWC client secret must be 64 hex characters.", uri)
  end
  lud16 = pairs.filter_map { |key, value| value if key == "lud16" }.first
  result = { wallet_pubkey: wallet, relays: relays, client_secret: secrets.first, redacted: redact_uri(uri) }
  result[:lud16] = lud16 unless lud16.nil? || lud16.empty?
  result
rescue URI::InvalidURIError
  raise NwcUriParseError.new("invalid_uri", "Invalid NWC URI.", uri)
end

.present?(value) ⇒ Boolean

Returns:

  • (Boolean)


465
466
467
# File 'lib/openreceive/core.rb', line 465

def present?(value)
  !value.nil? && value != ""
end

.redact_uri(uri) ⇒ Object

Redacts every query pair whose PERCENT-DECODED key is "secret" (JS decodes keys first, so %73ecret= must not slip past redaction). Other pairs keep their original bytes.



278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/openreceive/core.rb', line 278

def redact_uri(uri)
  text = uri.to_s
  query_start = text.index("?")
  return text if query_start.nil?
  fragment_start = text.index("#", query_start + 1)
  query_end = fragment_start.nil? ? text.length : fragment_start
  query = text[(query_start + 1)...query_end]
  redacted = query.split("&", -1).map do |pair|
    separator = pair.index("=")
    key = separator.nil? ? pair : pair[0...separator]
    decoded_key = begin
      URI.decode_www_form_component(key)
    rescue ArgumentError
      key
    end
    decoded_key.downcase == "secret" && !separator.nil? ? "#{key}=[REDACTED]" : pair
  end.join("&")
  "#{text[0..query_start]}#{redacted}#{text[query_end..]}"
end

.transaction_state(data) ⇒ Object

Mirrors the JS normalizeNwcTransaction state mapping: recognized states pass through lowercased, and a wallet that signals settlement only via boolean settled/paid flags maps to "settled".



223
224
225
226
227
228
# File 'lib/openreceive/core.rb', line 223

def transaction_state(data)
  raw = data["transaction_state"] || data["transactionState"] || data["state"]
  normalized = raw.downcase if raw.is_a?(String)
  return normalized if TRANSACTION_STATES.include?(normalized)
  "settled" if data["settled"] == true || data["paid"] == true
end

.unwrap(value) ⇒ Object



443
444
445
446
# File 'lib/openreceive/core.rb', line 443

def unwrap(value)
  data = OpenReceive.stringify(value)
  data.key?("result") ? data["result"] : value
end

.valid_relay_url?(relay) ⇒ Boolean

Returns:

  • (Boolean)


268
269
270
271
272
273
# File 'lib/openreceive/core.rb', line 268

def valid_relay_url?(relay)
  parsed = URI.parse(relay.to_s)
  parsed.scheme == "wss" && !parsed.host.to_s.empty?
rescue URI::InvalidURIError
  false
end