From 1dc51829c39422495e289ba11f8668352abdba41 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 14 Jun 2026 12:33:54 -0700 Subject: withdraw-lock: refuse external sends when /locks/.locked is present Port of master 9142b4db. Out-of-band emergency stop: nobal.sh (or a human) creates a sentinel file on the host, bind-mounted at /locks in the container; debit() checks for the file on every external send and refuses with "External withdrawals temporarily disabled". /locks/ALL.locked is a global override. Lives outside the db so a db-write vector can't disable it. Internal sends are exempt. Co-Authored-By: Claude Opus 4.8 --- lib/payments.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) (limited to 'lib') diff --git a/lib/payments.ts b/lib/payments.ts index e346e5f0..7445300b 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -1,4 +1,5 @@ import config from "$config"; +import { existsSync } from "fs"; import api from "$lib/api"; import changeid from "$lib/changeid"; import { db, g, gf, s, sa } from "$lib/db"; @@ -65,6 +66,20 @@ const { URL } = process.env; const dust = 547; +// External-withdrawal lockfiles. Out-of-band emergency stop: nobal.sh (or a +// human) can `touch /home/adam/locks/.locked` on the host, which is +// bind-mounted into this container at /locks. Existence of /locks/.locked +// (or /locks/ALL.locked) blocks all external sends of that type. Lives outside +// db so it can't be disabled by a db-write vector (May 18 /gateway lesson). +// +// Internal payments are never blocked by these — receives and internal +// transfers between coinos users keep working with the site fully online. +const isWithdrawLocked = (type: string): string | null => { + if (existsSync("/locks/ALL.locked")) return "ALL"; + if (existsSync(`/locks/${type}.locked`)) return type; + return null; +}; + const resolveBip353 = async (name: string, domain: string) => { const qname = `${name}.user._bitcoin-payment.${domain}`; const res = await fetch( @@ -179,6 +194,16 @@ export const debit = async ({ const frozen = (await g("hardfreeze")) || ((await g("freeze")) && type !== PaymentType.internal); const skipServerLimit = type === PaymentType.fund || type === PaymentType.internal; + // Out-of-band withdrawal lock (file-based, can't be defeated by db writes). + // Internal sends are exempt — they don't touch external wallets. + if (type !== PaymentType.internal) { + const lockedKind = isWithdrawLocked(type); + if (lockedKind) { + warn("Blocking", user.username, amount, hash, user.id, type, "withdraw-lock", lockedKind); + fail("External withdrawals temporarily disabled"); + } + } + if (frozen || (userLimit != null && amount > userLimit && !whitelisted) || (!skipServerLimit && serverLimit != null && amount > serverLimit)) { warn("Blocking", user.username, amount, hash, user.id, type, frozen, userLimit, serverLimit); fail("Problem sending payment"); -- cgit v1.2.3