From bfba77d22a85ffb021a8a0299326c3833c2cafaf Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 7 Aug 2026 23:00:33 -0700 Subject: ecash: serialize the shared cash-key read-modify-write (S13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The house Cashu holdings live as one token under redis `cash`. claim() and mint() decoded the current proofs, swapped at the mint, then wrote back — with no lock. Two concurrent claims read the same proofs and the later write clobbered the earlier, so already-swapped proofs vanished from the token while both users stayed credited (an insolvency leak, triggerable via the unauthenticated /ecash/:id callback). Wrap both read-swap-write sequences in an in-process mutex. Co-Authored-By: Claude Opus 4.8 --- lib/ecash.ts | 47 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 11 deletions(-) (limited to 'lib/ecash.ts') diff --git a/lib/ecash.ts b/lib/ecash.ts index 3e40d230..c304a8b8 100644 --- a/lib/ecash.ts +++ b/lib/ecash.ts @@ -24,6 +24,28 @@ const enc = (proofs) => proofs, }); +// Serialize every read-modify-write of the shared `cash` house-wallet token. The +// mint swap sits between reading the current proofs and writing the merged set, +// so two concurrent claim()/mint() calls would each read the same `current` and +// the later write would clobber the earlier — the earlier call's freshly-swapped +// proofs vanish from the stored token while both users stay credited (an +// insolvency leak). This mutex is in-process; if the server ever runs +// multi-process, move to a redis lock (WATCH/MULTI or SET NX). +let cashLock: Promise = Promise.resolve(); +const withCashLock = async (fn: () => Promise): Promise => { + const prev = cashLock; + let release: () => void = () => {}; + cashLock = new Promise((res) => { + release = res; + }); + await prev; + try { + return await fn(); + } finally { + release(); + } +}; + const ext = async (mint) => { const issuer = new CashuMint(mint); const { pubkey: issuerPk } = await issuer.getInfo(); @@ -37,26 +59,29 @@ export async function get(id) { } export async function claim(token) { - const { proofs: current } = getDecodedToken(await g("cash")); const { mint } = getDecodedToken(token); if (await ext(mint)) fail("Unable to receive from other mints"); - const rcvd = await w.receive(token); - - await s("cash", enc([...current, ...rcvd])); - return rcvd.reduce((a, b) => a + b.amount, 0); + return withCashLock(async () => { + const { proofs: current } = getDecodedToken(await g("cash")); + const rcvd = await w.receive(token); + await s("cash", enc([...current, ...rcvd])); + return rcvd.reduce((a, b) => a + b.amount, 0); + }); } export async function mint(amount) { const { keysets } = await m.getKeySets(); const w = new CashuWallet(m, { keysets }); - const { proofs } = getDecodedToken(await g("cash")); - const { send, keep } = await w.send(amount, proofs); - const rcvd = await w.receive(enc(send)); - const change = enc(keep); - await s("cash", change); - return enc(rcvd); + return withCashLock(async () => { + const { proofs } = getDecodedToken(await g("cash")); + const { send, keep } = await w.send(amount, proofs); + const rcvd = await w.receive(enc(send)); + const change = enc(keep); + await s("cash", change); + return enc(rcvd); + }); } export async function check(token) { -- cgit v1.2.3