From 98ea36bd93c1a8ce32c537bfd4aeb4f26dc21d37 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 29 Oct 2025 06:52:30 -0400 Subject: [PATCH] Add wallet budget enforcement for NWC payments --- docs/nwc-compliance.md | 13 +- js/channelProfile.js | 6 +- js/payments/nwcClient.js | 467 +++++++++++++++++++++++++++++++++- js/payments/zapSharedState.js | 21 ++ js/ui/zapController.js | 8 +- tests/nwc-client.test.mjs | 243 ++++++++++++++++++ 6 files changed, 745 insertions(+), 13 deletions(-) diff --git a/docs/nwc-compliance.md b/docs/nwc-compliance.md index 8fc9fb7e..69524db2 100644 --- a/docs/nwc-compliance.md +++ b/docs/nwc-compliance.md @@ -8,13 +8,15 @@ specification so we can quickly spot regressions or missing features. * `parseNwcUri()` accepts all aliases we see in the wild (`nostr+walletconnect://`, `walletconnect://`, and `nwc://`) and rejects unknown schemes before continuing. It extracts the wallet pubkey, all relay query parameters, and the 32-byte secret required for signing. The helper lowercases the secret, reserializes the URI in canonical form, and - derives the client public key from the secret so downstream calls can sign events without leaking user keys. + derives the client public key from the secret so downstream calls can sign events without leaking user keys. It also + surfaces any connection `budget` (in msats) plus renewal metadata from the query parameters so callers can enforce + wallet allowances consistently. ## Connection lifecycle * `ensureActiveState()` caches the parsed connection and keeps WebSocket state so repeated actions reuse the same - subscription. When a new URI is supplied we tear down the previous socket, update the relay list, and reset - encryption state before reconnecting. + subscription. When a new URI is supplied we tear down the previous socket, update the relay list, reset encryption + state, and initialize a per-connection budget tracker (spent vs. total) before reconnecting. * `connectSocket()` opens a WebSocket to the relay, subscribes to kind `23195` responses authored by the wallet and addressed to the client pubkey, and hooks message/error/close callbacks so we can retry or surface failures. @@ -26,7 +28,10 @@ specification so we can quickly spot regressions or missing features. Nostr event with kind `23194`, `pubkey` set to the client key, a `p` tag pointing at the wallet, and an `encryption` tag mirroring the algorithm that was used. Events are signed with the secret from the connection URI so relays will accept them. -* `sendPayment()` constructs the `pay_invoice` request, attaches a unique `id`, and dispatches it over the relay. +* `sendPayment()` resolves the msat amount that will be charged (from explicit params or by decoding the invoice), + compares it against the remaining budget, and short-circuits with a `NWC_BUDGET_EXHAUSTED` error when the new spend would + exceed the allowance. Successful payments increment the tracked spend, and wallet responses that report allowance + exhaustion mark the tracker as depleted so follow-up requests fail fast. ## Response flow diff --git a/js/channelProfile.js b/js/channelProfile.js index 544f80e8..86fe3343 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -3014,8 +3014,12 @@ async function handleZapSend(event) { }) .find((message) => message); + const budgetReceipt = failureReceipts.find((receipt) => + isZapAllowanceExhaustedError(receipt?.error) + ); const isBudgetExceeded = - typeof failureMessage === "string" && /budget exceeded/i.test(failureMessage); + Boolean(budgetReceipt) || + (typeof failureMessage === "string" && /budget exceeded/i.test(failureMessage)); let warningMessage; if (isBudgetExceeded) { diff --git a/js/payments/nwcClient.js b/js/payments/nwcClient.js index cdc4d96c..752d8250 100644 --- a/js/payments/nwcClient.js +++ b/js/payments/nwcClient.js @@ -2,6 +2,7 @@ import { nostrToolsReady } from "../nostrToolsBootstrap.js"; import { userLogger } from "../utils/logger.js"; +import { isZapAllowanceExhaustedError } from "./zapSharedState.js"; const HEX64_REGEX = /^[0-9a-f]{64}$/i; const URI_SCHEMES = [ @@ -330,16 +331,24 @@ function parseNwcUri(uri) { const queryParams = {}; for (const [key, values] of additionalParams.entries()) { - if (!values || !values.length) { + if (!Array.isArray(values) || !values.length) { continue; } - if (values.length === 1) { - queryParams[key] = values[0]; + const sanitizedValues = values + .map((value) => sanitizeQueryValue(value)) + .filter(Boolean); + if (!sanitizedValues.length) { + continue; + } + if (sanitizedValues.length === 1) { + queryParams[key] = sanitizedValues[0]; } else { - queryParams[key] = values.slice(); + queryParams[key] = sanitizedValues.slice(); } } + const budgetMetadata = deriveBudgetMetadata(additionalParams); + return { normalizedUri: `nostr+walletconnect://${walletPubkey}?${normalizedParams.toString()}`, relays, @@ -347,6 +356,7 @@ function parseNwcUri(uri) { secretKey, clientPubkey, queryParams, + budget: budgetMetadata, }; } @@ -360,6 +370,238 @@ function normalizeSpaceSeparatedValues(value) { .filter(Boolean); } +function sanitizeQueryValue(value) { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + if (typeof value === "bigint") { + return value >= 0n ? value.toString() : null; + } + return null; +} + +function coercePositiveBigInt(value) { + if (typeof value === "bigint") { + return value >= 0n ? value : null; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return null; + } + const rounded = Math.round(value); + return rounded >= 0 ? BigInt(rounded) : null; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const normalized = trimmed.replace(/_/g, ""); + if (!/^-?\d+$/.test(normalized)) { + return null; + } + try { + const big = BigInt(normalized); + return big >= 0n ? big : null; + } catch (error) { + return null; + } + } + return null; +} + +function getFirstQueryValue(map, keys) { + if (!map || !keys || !keys.length) { + return null; + } + for (const key of keys) { + const values = map.get(key); + if (!Array.isArray(values) || !values.length) { + continue; + } + for (const candidate of values) { + const sanitized = sanitizeQueryValue(candidate); + if (sanitized) { + return sanitized; + } + } + } + return null; +} + +const BUDGET_RENEWAL_FIELD_ALIASES = { + "": "interval", + value: "interval", + interval: "interval", + period: "interval", + frequency: "frequency", + every: "every", + each: "every", + unit: "unit", + units: "unit", + ms: "milliseconds", + millis: "milliseconds", + milliseconds: "milliseconds", + second: "seconds", + seconds: "seconds", + sec: "seconds", + start: "startAt", + at: "startAt", + time: "startAt", + timestamp: "startAt", + date: "startAt", +}; + +function deriveBudgetRenewalMetadata(normalizedLookup) { + if (!normalizedLookup || !normalizedLookup.size) { + return null; + } + + const renewal = {}; + + for (const [rawKey, values] of normalizedLookup.entries()) { + if ( + !rawKey.startsWith("budget_renewal") && + !rawKey.startsWith("budgetrenewal") + ) { + continue; + } + if (!Array.isArray(values) || !values.length) { + continue; + } + const candidate = values.find((value) => sanitizeQueryValue(value)); + if (!candidate) { + continue; + } + + const normalizedKey = rawKey + .replace(/^budget_renewal_?/, "") + .replace(/^budgetrenewal_?/, ""); + const alias = BUDGET_RENEWAL_FIELD_ALIASES[normalizedKey]; + const field = alias || (normalizedKey || "interval"); + + if (!(field in renewal)) { + renewal[field] = sanitizeQueryValue(candidate); + } + } + + return Object.keys(renewal).length ? renewal : null; +} + +function deriveBudgetMetadata(additionalParams) { + if (!additionalParams || !(additionalParams instanceof Map)) { + return null; + } + + const normalizedLookup = new Map(); + + for (const [key, values] of additionalParams.entries()) { + if (!Array.isArray(values) || !values.length) { + continue; + } + const normalizedValues = values + .map((value) => sanitizeQueryValue(value)) + .filter(Boolean); + if (!normalizedValues.length) { + continue; + } + const lowerKey = typeof key === "string" ? key.toLowerCase() : ""; + if (!lowerKey) { + continue; + } + if (!normalizedLookup.has(lowerKey)) { + normalizedLookup.set(lowerKey, []); + } + normalizedLookup.get(lowerKey).push(...normalizedValues); + } + + const budgetValue = getFirstQueryValue(normalizedLookup, [ + "budget", + "budget_msat", + "budgetmsat", + "budget_msats", + "budgetmsats", + ]); + const totalMsats = coercePositiveBigInt(budgetValue); + const renewal = deriveBudgetRenewalMetadata(normalizedLookup); + + if (totalMsats !== null && totalMsats !== undefined) { + return { + totalMsats, + renewal, + raw: { budget: budgetValue }, + }; + } + + if (renewal) { + return { + totalMsats: null, + renewal, + raw: { budget: budgetValue }, + }; + } + + return null; +} + +function createBudgetTracker(budgetMetadata) { + if (!budgetMetadata || typeof budgetMetadata !== "object") { + return null; + } + + const total = coercePositiveBigInt(budgetMetadata.totalMsats); + if (total === null || total === undefined) { + return null; + } + + const tracker = { + totalMsats: total, + spentMsats: 0n, + renewal: budgetMetadata.renewal || null, + exhausted: false, + }; + + if (tracker.totalMsats === 0n) { + tracker.exhausted = true; + } + + return tracker; +} + +function getBudgetRemainingMsats(tracker) { + if (!tracker) { + return null; + } + const remaining = tracker.totalMsats - tracker.spentMsats; + return remaining > 0n ? remaining : 0n; +} + +function incrementBudgetSpend(tracker, amountMsats) { + if (!tracker || typeof amountMsats !== "bigint") { + return; + } + const next = tracker.spentMsats + (amountMsats >= 0n ? amountMsats : 0n); + tracker.spentMsats = next; + if (tracker.spentMsats >= tracker.totalMsats) { + tracker.spentMsats = tracker.totalMsats; + tracker.exhausted = true; + } +} + +function markBudgetTrackerExhausted(tracker) { + if (!tracker) { + return; + } + tracker.exhausted = true; + if (tracker.spentMsats > tracker.totalMsats) { + tracker.spentMsats = tracker.totalMsats; + } +} + function hexToBytesCompat(hex) { const tools = getNostrTools(); const candidate = tools?.utils?.hexToBytes; @@ -992,6 +1234,13 @@ function ensureActiveState(settings) { activeState.context.relayUrls = parsed.relays.slice(); activeState.context.relays = parsed.relays.slice(); activeState.context.queryParams = parsed.queryParams; + activeState.context.budget = parsed.budget || null; + if (!activeState.context.budgetTracker && parsed.budget) { + activeState.context.budgetTracker = createBudgetTracker(parsed.budget); + } else if (activeState.context.budgetTracker) { + activeState.context.budgetTracker.renewal = + parsed.budget?.renewal || null; + } } return activeState.context; } @@ -1010,6 +1259,8 @@ function ensureActiveState(settings) { clientPubkey: parsed.clientPubkey, uri: parsed.normalizedUri, queryParams: parsed.queryParams, + budget: parsed.budget || null, + budgetTracker: createBudgetTracker(parsed.budget), infoEvent: null, encryption: null, encryptionState: { unsupported: new Set() }, @@ -1134,6 +1385,173 @@ function sanitizeAmount(amount) { return Math.max(0, rounded); } +const WALLET_BUDGET_ERROR_CODES = new Set([ + "allowance_exceeded", + "allowance_exhausted", + "budget_exceeded", + "budget_exhausted", + "budget_limit_reached", + "budget_spent", + "quota_exceeded", + "spending_allowance_exceeded", + "spending_limit_reached", + "nwc_budget_exhausted", +]); + +const BOLT11_PICO_MULTIPLIERS = { + "": 12n, + m: 9n, + u: 6n, + n: 3n, + p: 0n, +}; + +function normalizeErrorCode(code) { + if (typeof code !== "string") { + return ""; + } + return code + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_"); +} + +function toMsatsFromSats(amount) { + if (!Number.isFinite(amount)) { + return null; + } + const rounded = Math.round(amount); + const clamped = Math.max(0, rounded); + return BigInt(clamped) * 1000n; +} + +function toMsatsFromValue(amount) { + if (typeof amount === "bigint") { + return amount >= 0n ? amount : 0n; + } + if (typeof amount === "number") { + if (!Number.isFinite(amount)) { + return null; + } + const rounded = Math.round(amount); + const clamped = Math.max(0, rounded); + return BigInt(clamped); + } + if (typeof amount === "string") { + const big = coercePositiveBigInt(amount); + if (big === null) { + return null; + } + return big; + } + return null; +} + +function decodeBolt11AmountMsats(invoice) { + if (typeof invoice !== "string") { + return null; + } + const trimmed = invoice.trim(); + if (!trimmed) { + return null; + } + const lower = trimmed.toLowerCase(); + if (!lower.startsWith("ln")) { + return null; + } + + let index = 2; + while (index < lower.length && /[a-z]/.test(lower[index])) { + index += 1; + } + + const digitsStart = index; + while (index < lower.length && /[0-9]/.test(lower[index])) { + index += 1; + } + + const digits = lower.slice(digitsStart, index); + if (!digits) { + return null; + } + + const unitChar = index < lower.length && /[munp]/.test(lower[index]) ? lower[index] : ""; + if (unitChar) { + index += 1; + } + + try { + const base = BigInt(digits); + const exponent = BOLT11_PICO_MULTIPLIERS[unitChar]; + if (typeof exponent === "undefined") { + return null; + } + const pico = base * 10n ** exponent; + if (pico % 10n !== 0n) { + return null; + } + return pico / 10n; + } catch (error) { + return null; + } +} + +function resolvePaymentAmountMsats({ invoice, params, amountSats }) { + const fromSats = Number.isFinite(amountSats) ? toMsatsFromSats(amountSats) : null; + if (fromSats !== null) { + return { msats: fromSats, source: "amountSats" }; + } + + const paramAmount = params && typeof params === "object" ? params.amount : null; + const fromParams = toMsatsFromValue(paramAmount); + if (fromParams !== null) { + return { msats: fromParams, source: "params.amount" }; + } + + const decoded = decodeBolt11AmountMsats(invoice); + if (decoded !== null) { + return { msats: decoded, source: "invoice" }; + } + + return { msats: null, source: null }; +} + +function isBudgetOrAllowanceError(error) { + const normalizedCode = normalizeErrorCode(error?.code); + if (normalizedCode && WALLET_BUDGET_ERROR_CODES.has(normalizedCode)) { + return true; + } + return isZapAllowanceExhaustedError(error); +} + +function createBudgetExceededError(tracker, chargeMsats, { remainingMsats }) { + const message = + "Budget exceeded. Increase your wallet zap limit or reduce the platform fee, then try again."; + const error = new Error(message); + error.code = "NWC_BUDGET_EXHAUSTED"; + + if (typeof chargeMsats === "bigint") { + error.requestedMsats = chargeMsats; + } + + if (tracker) { + error.budgetMsats = tracker.totalMsats; + if (typeof remainingMsats === "bigint") { + error.remainingMsats = remainingMsats >= 0n ? remainingMsats : 0n; + } else { + const remaining = getBudgetRemainingMsats(tracker); + if (typeof remaining === "bigint") { + error.remainingMsats = remaining; + } + } + if (tracker.renewal) { + error.budgetRenewal = tracker.renewal; + } + } + + return error; +} + function buildPayInvoiceParams({ invoice, amountSats, zapRequest, lnurl }) { const params = { invoice }; @@ -1163,14 +1581,51 @@ export async function sendPayment( const context = await ensureWallet({ settings }); const invoice = sanitizeInvoice(bolt11); const params = buildPayInvoiceParams({ invoice, amountSats, zapRequest, lnurl }); + const amountInfo = resolvePaymentAmountMsats({ + invoice, + params, + amountSats, + }); + const chargeMsats = amountInfo.msats; + const tracker = context?.budgetTracker || null; + + if (tracker) { + const remaining = getBudgetRemainingMsats(tracker); + const exhausted = tracker.exhausted || (typeof remaining === "bigint" && remaining === 0n); + const exceedsBudget = + typeof chargeMsats === "bigint" && typeof remaining === "bigint" + ? chargeMsats > remaining + : false; + + if (exhausted || exceedsBudget) { + const error = createBudgetExceededError(tracker, chargeMsats, { + remainingMsats: typeof remaining === "bigint" ? remaining : null, + }); + if (exhausted || (typeof remaining === "bigint" && remaining === 0n)) { + markBudgetTrackerExhausted(tracker); + } + throw error; + } + } + const payload = { id: `req-${Date.now()}-${++requestCounter}`, method: "pay_invoice", params, }; - const response = await sendWalletRequest(context, payload, { timeoutMs }); - return response; + try { + const response = await sendWalletRequest(context, payload, { timeoutMs }); + if (tracker && typeof chargeMsats === "bigint" && chargeMsats > 0n) { + incrementBudgetSpend(tracker, chargeMsats); + } + return response; + } catch (error) { + if (tracker && isBudgetOrAllowanceError(error)) { + markBudgetTrackerExhausted(tracker); + } + throw error; + } } export function getActiveWalletContext() { diff --git a/js/payments/zapSharedState.js b/js/payments/zapSharedState.js index f804d1a1..9ac0fef4 100644 --- a/js/payments/zapSharedState.js +++ b/js/payments/zapSharedState.js @@ -188,6 +188,19 @@ function extractErrorMessage(error) { return ""; } +const ALLOWANCE_ERROR_CODES = new Set([ + "allowance_exceeded", + "allowance_exhausted", + "budget_exceeded", + "budget_exhausted", + "budget_limit_reached", + "budget_spent", + "quota_exceeded", + "spending_allowance_exceeded", + "spending_limit_reached", + "nwc_budget_exhausted", +]); + const ALLOWANCE_ERROR_PATTERNS = [ "budget exceeded", "allowance exceeded", @@ -202,6 +215,14 @@ const ALLOWANCE_ERROR_PATTERNS = [ ]; export function isZapAllowanceExhaustedError(error) { + const code = + typeof error?.code === "string" + ? error.code.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_") + : ""; + if (code && ALLOWANCE_ERROR_CODES.has(code)) { + return true; + } + const message = extractErrorMessage(error); if (!message) { return false; diff --git a/js/ui/zapController.js b/js/ui/zapController.js index ac5a9056..8734fb94 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -303,9 +303,13 @@ export default class ZapController { }) .find((message) => message); + const budgetReceipt = failureReceipts.find((receipt) => + isZapAllowanceExhaustedError(receipt?.error) + ); const isBudgetExceeded = - typeof failureMessage === "string" && - /budget exceeded/i.test(failureMessage); + Boolean(budgetReceipt) || + (typeof failureMessage === "string" && + /budget exceeded/i.test(failureMessage)); let warningMessage; if (isBudgetExceeded) { diff --git a/tests/nwc-client.test.mjs b/tests/nwc-client.test.mjs index 8bc334ac..15eb186d 100644 --- a/tests/nwc-client.test.mjs +++ b/tests/nwc-client.test.mjs @@ -312,4 +312,247 @@ await (async () => { resetWalletClient(); })(); +await (async () => { + resetWalletClient(); + + const originalWebSocket = globalThis.WebSocket; + const RESPONSE_KIND = 23195; + + class FakeWebSocket { + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.CONNECTING; + this._listeners = new Map(); + this._responseSubscriptionId = null; + setTimeout(() => { + this.readyState = FakeWebSocket.OPEN; + this._emit("open"); + }, 0); + } + + addEventListener(type, handler) { + if (!this._listeners.has(type)) { + this._listeners.set(type, new Set()); + } + this._listeners.get(type).add(handler); + } + + removeEventListener(type, handler) { + const listeners = this._listeners.get(type); + if (listeners) { + listeners.delete(handler); + } + } + + send(payload) { + let parsed; + try { + parsed = JSON.parse(payload); + } catch (error) { + return; + } + + const [type] = parsed; + if (type === "REQ") { + const subscription = parsed[1]; + const filters = parsed[2]; + if (filters?.kinds && filters.kinds.includes(RESPONSE_KIND)) { + this._responseSubscriptionId = subscription; + } + return; + } + + if (type === "EVENT") { + const event = parsed[1]; + const state = __TESTING__.getActiveState(); + const context = state?.context || null; + const encryption = context?.encryption || null; + if (!context || !encryption || !this._responseSubscriptionId) { + return; + } + + Promise.resolve() + .then(() => encryption.decrypt(event.content)) + .then((plaintext) => { + const requestPayload = JSON.parse(plaintext); + const responsePayload = { + id: requestPayload.id, + result_type: "pay_invoice", + result: { preimage: "test-preimage" }, + }; + return encryption.encrypt(JSON.stringify(responsePayload)); + }) + .then((ciphertext) => { + if (!ciphertext) { + return; + } + const tools = window.NostrTools || {}; + const responseEvent = { + pubkey: context.walletPubkey, + kind: RESPONSE_KIND, + content: ciphertext, + tags: [ + ["p", context.clientPubkey], + ["e", event.id], + ], + created_at: Math.floor(Date.now() / 1000), + }; + if (typeof tools.getEventHash === "function") { + responseEvent.id = tools.getEventHash(responseEvent); + } else { + responseEvent.id = `resp-${Date.now()}`; + } + if (typeof tools.signEvent === "function") { + responseEvent.sig = tools.signEvent(responseEvent, context.secretKey); + } + const message = JSON.stringify([ + "EVENT", + this._responseSubscriptionId, + responseEvent, + ]); + setTimeout(() => { + this._emit("message", { data: message }); + }, 0); + }) + .catch(() => {}); + } + } + + close() { + this.readyState = FakeWebSocket.CLOSED; + this._emit("close", {}); + } + + _emit(type, event = {}) { + const listeners = this._listeners.get(type); + if (!listeners) { + return; + } + for (const handler of Array.from(listeners)) { + try { + handler(event); + } catch (error) { + // ignore + } + } + } + } + + FakeWebSocket.CONNECTING = 0; + FakeWebSocket.OPEN = 1; + FakeWebSocket.CLOSED = 3; + + globalThis.WebSocket = FakeWebSocket; + + let eventCounter = 0; + const encode = (value) => Buffer.from(value, "utf8").toString("base64"); + const decode = (value) => Buffer.from(value, "base64").toString("utf8"); + + window.NostrTools = { + getPublicKey() { + return "c".repeat(64); + }, + getEventHash(event) { + eventCounter += 1; + return `event-${eventCounter}`; + }, + signEvent(event) { + return `sig-${event.id || "unknown"}`; + }, + nip04: { + async encrypt(secretKey, pubkey, plaintext) { + return `nip04:${encode(plaintext)}`; + }, + async decrypt(secretKey, pubkey, ciphertext) { + const payload = + typeof ciphertext === "string" && ciphertext.startsWith("nip04:") + ? ciphertext.slice("nip04:".length) + : ciphertext; + return decode(payload); + }, + }, + nip44: { + v2: { + async encrypt(plaintext, key) { + return `nip44:${key}:${encode(plaintext)}`; + }, + async decrypt(ciphertext, key) { + if (typeof ciphertext !== "string") { + return ""; + } + const parts = ciphertext.split(":"); + const encoded = parts.slice(2).join(":"); + return decode(encoded); + }, + utils: { + getConversationKey(secretBytes, pubkey) { + return `key-${pubkey}-${secretBytes.length}`; + }, + }, + }, + }, + }; + + const walletPubkey = "b".repeat(64); + const secretKey = "a".repeat(64); + const relay = "wss://relay.budget.example"; + const budget = 1000n; + const uri = + `nostr+walletconnect://${walletPubkey}` + + `?relay=${encodeURIComponent(relay)}` + + `&secret=${secretKey}` + + `&budget=${budget.toString()}`; + + const context = ensureActiveState({ nwcUri: uri }); + context.infoEvent = { kind: 13194, tags: [["encryption", "nip04"]] }; + + try { + const first = await nwcModule.sendPayment("lnbc1budgettest", { + settings: { nwcUri: uri }, + amountSats: 1, + }); + + assert.ok(first); + assert.ok(context.budgetTracker); + assert.equal(context.budgetTracker.totalMsats, budget); + assert.equal(context.budgetTracker.spentMsats, 1000n); + + await assert.rejects( + () => + nwcModule.sendPayment("lnbc1budgettest", { + settings: { nwcUri: uri }, + amountSats: 1, + }), + (error) => { + assert.equal(error.code, "NWC_BUDGET_EXHAUSTED"); + assert.match(error.message || "", /Budget exceeded/i); + return true; + } + ); + assert.equal(context.budgetTracker.exhausted, true); + + const higherBudget = 5000n; + const upgradedUri = + `nostr+walletconnect://${walletPubkey}` + + `?relay=${encodeURIComponent(relay)}` + + `&secret=${secretKey}` + + `&budget=${higherBudget.toString()}`; + + const nextContext = ensureActiveState({ nwcUri: upgradedUri }); + nextContext.infoEvent = { kind: 13194, tags: [["encryption", "nip04"]] }; + + assert.ok(nextContext.budgetTracker); + assert.equal(nextContext.budgetTracker.totalMsats, higherBudget); + assert.equal(nextContext.budgetTracker.spentMsats, 0n); + assert.equal(nextContext.budgetTracker.exhausted, false); + } finally { + if (originalWebSocket) { + globalThis.WebSocket = originalWebSocket; + } else { + delete globalThis.WebSocket; + } + resetWalletClient(); + } +})(); + process.exit(0);