Add wallet budget enforcement for NWC payments

This commit is contained in:
thePR0M3TH3AN committed 2025-10-29 06:52:30 -04:00
1 parent 7d41964bf5
commit 98ea36bd93
6 files changed
+745 -13

No files matched your search

+9 -4
View File
@@ -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
+5 -1
View File
@@ -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) {
+461 -6
View File
@@ -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() {
+21
View File
@@ -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;
+6 -2
View File
@@ -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) {
+243
View File
@@ -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);