aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--lib/invoices.ts9
-rw-r--r--lib/payments.ts6
-rw-r--r--routes/payments.ts12
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;