mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add wallet budget enforcement for NWC payments
This commit is contained in:
1 parent
7d41964bf5
commit
98ea36bd93
6 files changed
+745
-13
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user