From 808e7535d33ffca4c1bf859d8bf0afe134f3e1d7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 5 Feb 2026 18:45:08 -0800 Subject: cross-protocol forwarding with race condition fixes Co-Authored-By: Claude Opus 4.6 --- lib/invoices.ts | 1 + lib/payments.ts | 73 ++++++++++++++++++++++++++++++++++++++-------------- routes/users.ts | 2 +- test/forward.test.ts | 16 +++--------- 4 files changed, 60 insertions(+), 32 deletions(-) diff --git a/lib/invoices.ts b/lib/invoices.ts index 2d9d82ad..e461c1a7 100644 --- a/lib/invoices.ts +++ b/lib/invoices.ts @@ -129,6 +129,7 @@ export const generate = async ({ invoice, user }) => { address_type ||= "bech32"; hash = await bc.getNewAddress({ address_type }); text = bip21(hash, invoice); + await db.sAdd("watching", hash); } else if (type === PaymentType.liquid) { address_type ||= "blech32"; hash = await lq.getNewAddress({ address_type }); diff --git a/lib/payments.ts b/lib/payments.ts index 526ae5a2..0fd87559 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -46,6 +46,7 @@ import { sleep, t, } from "$lib/utils"; +import { sendArk } from "$lib/ark"; import { callWebhook } from "$lib/webhooks"; import rpc from "@coinos/rpc"; import { selectUTXO, p2wpkh } from "@scure/btc-signer"; @@ -316,25 +317,24 @@ export const completePayment = async (inv, p, user) => { let withdrawal; if (p.confirmed) { if (inv.forward && !inv.forwarded) { - try { - // Skip forward if destination is the same user's own invoice + const lockKey = `forward:${inv.id}`; + const locked = await db.setNX(lockKey, "1"); + if (locked) { + await db.expire(lockKey, 300); try { - const destInv = await getInvoice(inv.forward); - if (destInv?.uid === id) { - inv.forwarded = true; - await s(`invoice:${inv.id}`, inv); - callWebhook(inv, p); - return; + inv.forwarded = true; + await s(`invoice:${inv.id}`, inv); + + // Skip forward if destination is the same user's own Lightning invoice + if (inv.forward.startsWith("ln")) { + const destInv = await getInvoice(inv.forward).catch(() => null); + if (destInv?.uid === id) return; } - } catch {} - inv.forwarded = true; - await s(`invoice:${inv.id}`, inv); - const w = await pay({ amount: p.amount, to: inv.forward, user }); - callWebhook(inv, p); - return w; - } catch (e) { - warn(username, "forward failed", inv.forward, e.message); + await pay({ amount: p.amount, to: inv.forward, user }); + } catch (e) { + warn(username, "forward failed", inv.forward, e.message); + } } } else if (autowithdraw && p.type !== "ark") { try { @@ -442,6 +442,19 @@ const pay = async ({ aid = undefined, amount, to, user }) => { sender: user, }); + if (to.startsWith("ark") || to.startsWith("tark")) { + const fee = Math.max(5, Math.round(amount * 0.02)); + amount -= fee; + const txid = await sendArk(to, amount); + return debit({ + hash: txid, + amount: amount + fee, + fee, + user, + type: PaymentType.ark, + }); + } + const fee = Math.max(5, Math.round(amount * 0.02)); if (lnurl) { amount -= fee; @@ -974,7 +987,10 @@ export const build = async ({ // Non-custodial bitcoin account — use esplora if (aid !== user.id && type === PaymentType.bitcoin) { - return buildNonCustodial({ aid, amount, address, feeRate, subtract, user }); + const account = await g(`account:${aid}`); + if (account?.pubkey) { + return buildNonCustodial({ aid, amount, address, feeRate, subtract, user }); + } } const node = rpc(config[type]); @@ -1160,7 +1176,17 @@ export const importAccountHistory = async (account) => { for (const u of confirmedUtxos) { const existing = await getPayment(`${u.txid}:${u.vout}`); - if (existing) continue; + if (existing?.aid === id) continue; + + if (existing) { + existing.aid = id; + await db + .multi() + .set(`payment:${existing.id}`, JSON.stringify(existing)) + .lPush(`${id}:payments`, existing.id) + .exec(); + continue; + } const created = u.status?.block_time ? u.status.block_time * 1000 @@ -1200,7 +1226,16 @@ export const importAccountHistory = async (account) => { for (const tx of txsById.values()) { if (!tx.status?.confirmed) continue; const existing = await getPayment(tx.txid); - if (existing) continue; + if (existing?.aid === id) continue; + if (existing) { + existing.aid = id; + await db + .multi() + .set(`payment:${existing.id}`, JSON.stringify(existing)) + .lPush(`${id}:payments`, existing.id) + .exec(); + continue; + } let inputsFromUs = 0; let outputsToUs = 0; diff --git a/routes/users.ts b/routes/users.ts index 0a3cebeb..f30d77ea 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -885,7 +885,7 @@ export default { nextIndex: 0, arkAddress, accountIndex, - importedAt: seed ? Date.now() : undefined, + importedAt: undefined, }; await createBalanceAccount(id); diff --git a/test/forward.test.ts b/test/forward.test.ts index 55e88a3e..c387c489 100644 --- a/test/forward.test.ts +++ b/test/forward.test.ts @@ -117,15 +117,9 @@ describe("completePayment", () => { const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 }); const p = { amount: 1000, confirmed: true, type: "ark", uid: custodialUid, created: Date.now() }; - const w: any = await completePayment(inv, p, user); - - expect(w).toBeTruthy(); - expect(w.amount).toBeLessThan(0); - expect(w.type).toBe("lightning"); - - // Debit stays in custodial list - expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(w.id); + await completePayment(inv, p, user); + expect(mockLnXpay).toHaveBeenCalled(); expect(inv.forwarded).toBe(true); expect(mockCallWebhook).toHaveBeenCalled(); }); @@ -135,11 +129,9 @@ describe("completePayment", () => { const inv = makeInvoice({ aid: custodialUid, forward: bolt11 }); const p = { amount: 1000, confirmed: true, type: "ark", uid: custodialUid, created: Date.now() }; - const w: any = await completePayment(inv, p, user); + await completePayment(inv, p, user); - expect(w).toBeTruthy(); - expect(w.amount).toBeLessThan(0); - expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(w.id); + expect(mockLnXpay).toHaveBeenCalled(); expect(inv.forwarded).toBe(true); }); -- cgit v1.2.3