diff options
| author | Adam Soltys <[email protected]> | 2026-08-22 08:08:04 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-22 08:08:04 -0700 |
| commit | 0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f (patch) | |
| tree | f288d01fdc82b2df7ba9fd3f6ef6f8792ef49880 /lib/payments.ts | |
| parent | 38bfeeecdafb678d2a495c2865d1ff4dd6255ac9 (diff) | |
| download | coinos-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.ts | 18 |
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")) { |
