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
- .collect_error_records(value, seen = []) ⇒ Object
- .error_code_from_records(records) ⇒ Object
- .error_message_from(records, raw, code) ⇒ Object
- .first_boolean(records, key) ⇒ Object
- .first_string(records, keys) ⇒ Object
- .list_transactions_request(request) ⇒ Object
- .make_invoice_request(request) ⇒ Object
- .normalize_error_code(value) ⇒ Object
- .normalize_list_transactions_response(response) ⇒ Object
- .normalize_make_invoice_response(response) ⇒ Object
- .normalize_transaction(transaction) ⇒ Object
-
.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"? }.
- .optional_integer(value) ⇒ Object
-
.optional_payment_hash(value) ⇒ Object
ABSENT means absent — a row minted by another app through the same wallet legitimately carries no hash.
-
.parse_uri(uri) ⇒ Object
Mirrors JS parseNwcUri: same error codes for the same failures so both engines pass the shared nwc-uri-parse vectors.
- .present?(value) ⇒ Boolean
-
.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).
-
.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".
- .unwrap(value) ⇒ Object
- .valid_relay_url?(relay) ⇒ Boolean
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. } 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 (records, raw, code) = first_string(records, %w[message description reason]) return if && normalize_error_code() != 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
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" => (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.
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
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
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 |