aboutsummaryrefslogtreecommitdiffstats
path: root/lib/ecash.ts
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-07 23:00:33 -0700
committerAdam Soltys <[email protected]>2026-08-07 23:29:27 -0700
commitbfba77d22a85ffb021a8a0299326c3833c2cafaf (patch)
treea7177b143c12c56b30913c8c77a0a53bfd59b020 /lib/ecash.ts
parent8e7ffe3e33e36396fa7d2c17486e1707a2da4f43 (diff)
downloadcoinos-server-bfba77d22a85ffb021a8a0299326c3833c2cafaf.tar.gz
coinos-server-bfba77d22a85ffb021a8a0299326c3833c2cafaf.zip
ecash: serialize the shared cash-key read-modify-write (S13)
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 <[email protected]>
Diffstat (limited to 'lib/ecash.ts')
-rw-r--r--lib/ecash.ts47
1 files changed, 36 insertions, 11 deletions
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<void> = Promise.resolve();
+const withCashLock = async <T>(fn: () => Promise<T>): Promise<T> => {
+ 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) {