diff options
| -rw-r--r-- | lib/invoices.ts | 9 | ||||
| -rw-r--r-- | lib/payments.ts | 6 | ||||
| -rw-r--r-- | routes/payments.ts | 12 |
3 files changed, 27 insertions, 0 deletions
diff --git a/lib/invoices.ts b/lib/invoices.ts index 0028dc91..835b1247 100644 --- a/lib/invoices.ts +++ b/lib/invoices.ts @@ -91,6 +91,15 @@ export const generate = async ({ invoice, user }) => { if (!id) id = v4(); else { + // An internal invoice's id becomes its hash, and credit() writes + // `payment:<hash>`. A free-form id like "<txid>:<vout>" therefore lets a + // payer overwrite an on-chain deposit's dedup pointer, so ids must be the + // uuids we hand out. + if ( + typeof id !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) + ) + fail("invoice not found"); // A caller-supplied id re-uses an existing record (tip/webhook update, // repeated bolt12 offer create). It must be the caller's own: the write // below replaces `invoice:<id>` wholesale with uid = this user, and the diff --git a/lib/payments.ts b/lib/payments.ts index 20025cd2..46682670 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -469,6 +469,12 @@ export const credit = async ({ let balanceKey = "balance"; if ([PaymentType.bitcoin, PaymentType.liquid].includes(type)) { const [txid, vout] = ref.split(":").slice(-2); + // Permanent first-credit claim per output. Unlike `payment:<txid>:<vout>`, + // nothing else writes or deletes this key. + if (!(await db.set(`credited:${txid}:${vout}`, id, { NX: true }))) { + warn("duplicate credit blocked", hash, ref); + return; + } p.confirmed = false; balanceKey = "pending"; await s(`payment:${txid}:${vout}`, id); diff --git a/routes/payments.ts b/routes/payments.ts index 66fd3386..cfb34af7 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -616,6 +616,18 @@ export default { const p = await getPayment(`${txid}:${vout}`); + // A pointer whose payment record is gone (account deleted, record + // overwritten) means this output was already credited once. Never + // treat it as a first sighting. + if ( + !p && + ((await db.exists(`payment:${txid}:${vout}`)) || + (await db.exists(`credited:${txid}:${vout}`))) + ) { + warn("blocked re-credit of seen output", txid, vout, sats(amount), address); + continue; + } + if (!p) { const invoice = await getInvoice(address); if (!hot && aid !== invoice?.aid) continue; |
