aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-05 18:45:08 -0800
committerAdam Soltys <[email protected]>2026-02-06 09:00:22 -0800
commit808e7535d33ffca4c1bf859d8bf0afe134f3e1d7 (patch)
tree87ee9ec7f8e658cf78c33b82f35c991612cbba38
parent4e9c0c5198c7884cfd97cb4495f6156632e3124a (diff)
downloadcoinos-server-808e7535d33ffca4c1bf859d8bf0afe134f3e1d7.tar.gz
coinos-server-808e7535d33ffca4c1bf859d8bf0afe134f3e1d7.zip
cross-protocol forwarding with race condition fixes
Co-Authored-By: Claude Opus 4.6 <[email protected]>
-rw-r--r--lib/invoices.ts1
-rw-r--r--lib/payments.ts73
-rw-r--r--routes/users.ts2
-rw-r--r--test/forward.test.ts16
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);
});