aboutsummaryrefslogtreecommitdiffstats
path: root/lib/payments.ts
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-22 08:08:04 -0700
committerAdam Soltys <[email protected]>2026-08-22 08:08:04 -0700
commit0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f (patch)
treef288d01fdc82b2df7ba9fd3f6ef6f8792ef49880 /lib/payments.ts
parent38bfeeecdafb678d2a495c2865d1ff4dd6255ac9 (diff)
downloadcoinos-server-0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f.tar.gz
coinos-server-0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f.zip
nwc: enforce per-connection spend budgets + flag-gated incident containment
Thread the remaining allowance through every spend path (pay_invoice, pay, pay_keysend, internal transfer) as maxTotal, and have debit() assert the spend stays within it after it reloads the invoice and captures the tip — so a delegated NWC credential can no longer exceed its configured budget, and a mutated tip can't slip a small-budget check and drain via the internal path. checkBudget now returns the remaining allowance alongside any error; a blank budget is treated as deliberately unlimited and a positive budget requires a known renewal period. Add operator-controlled containment for a suspected credential disclosure, all gated behind Redis flags with no hardcoded scope: nwc:disabled fail every method closed nwc:credential-cutoff retire connections created before an epoch nwc:quarantined block an individual disclosed connection nwc:guard:dormant-keysend quarantine the first keysend on a long-idle app Fix an ownership check that read the bare pubkey instead of app:<pubkey>, which let an authenticated user overwrite another connection's config, and validate budget/fee/renewal inputs. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
Diffstat (limited to 'lib/payments.ts')
-rw-r--r--lib/payments.ts18
1 files changed, 16 insertions, 2 deletions
diff --git a/lib/payments.ts b/lib/payments.ts
index 5df028b3..6329bfcf 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -2,6 +2,7 @@ import config from "$config";
import { existsSync } from "fs";
import api from "$lib/api";
import { archive, db, g, ga, gf, s, sa } from "$lib/db";
+import { assertWithinSpendLimit } from "$lib/spend-budget";
// External-withdrawal lockfiles. Out-of-band emergency stop: nobal.sh (or a
// human) can `touch /home/adam/locks/<type>.locked` on the host, which is
@@ -108,6 +109,7 @@ export const debit = async ({
user,
type = PaymentType.internal,
rate = undefined,
+ maxTotal = undefined,
}) => {
amount = Number.parseInt(amount);
@@ -234,6 +236,13 @@ export const debit = async ({
: 0;
if (aid !== uid) ourfee = 0;
+
+ // NWC and other delegated spenders can provide the exact allowance remaining
+ // for this call. Enforce it only after debit has reloaded the invoice and
+ // captured its tip, so a recipient cannot pass a small-invoice budget check,
+ // mutate the tip, and drain the account through the internal-payment path.
+ assertWithinSpendLimit({ amount, tip, fee, ourfee, maxTotal });
+
const frozenBalance =
!blacklisted || whitelisted ? 0 : await g(`balance:${uid}`);
@@ -476,7 +485,7 @@ export const credit = async ({
);
}
- m.set(`invoice:${inv.id}`, JSON.stringify(inv))
+ await m.set(`invoice:${inv.id}`, JSON.stringify(inv))
.set(`payment:${p.id}`, JSON.stringify(p))
.lPush(`${aid || uid}:payments`, p.id)
.incrBy(`${balanceKey}:${aid || uid}`, amount)
@@ -741,6 +750,7 @@ export const sendKeysend = async ({
memo = undefined,
user,
extratlvs = undefined,
+ maxTotal = undefined,
}) => {
fee = Math.max(Number.parseInt(fee || amount * 0.005), 5);
@@ -754,6 +764,7 @@ export const sendKeysend = async ({
memo,
user,
type: PaymentType.lightning,
+ maxTotal,
});
let outcome = "unknown";
@@ -848,6 +859,7 @@ export const sendLightning = async ({
fee = undefined,
memo = undefined,
payerNote = undefined,
+ maxTotal = undefined,
}) => {
let p;
@@ -928,6 +940,7 @@ export const sendLightning = async ({
memo,
user,
type: PaymentType.lightning,
+ maxTotal,
});
await db.sAdd("pending", pr);
@@ -1067,6 +1080,7 @@ export const sendInternal = async ({
memo = undefined,
recipient,
sender,
+ maxTotal = undefined,
}) => {
if (!invoice)
invoice = await generate({
@@ -1075,7 +1089,7 @@ export const sendInternal = async ({
});
const { hash } = invoice;
- const p = await debit({ hash, amount, memo, user: sender });
+ const p = await debit({ hash, amount, memo, user: sender, maxTotal });
await credit({ hash, amount, memo, ref: sender.id, tip: p.tip });
if (invoice.memo?.includes("9734")) {