aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-03 23:02:57 -0800
committerAdam Soltys <[email protected]>2026-03-03 23:02:57 -0800
commit63fad61716c013b6663df789ed96f6e294630642 (patch)
tree94b26a7df691d857c5648824699565f8f7340e52
parent55ae58daf23028fa0f647f7bb2b4b0f8cd751de7 (diff)
downloadcoinos-server-63fad61716c013b6663df789ed96f6e294630642.tar.gz
coinos-server-63fad61716c013b6663df789ed96f6e294630642.zip
vault wallet fixes
-rw-r--r--lib/ark.ts70
-rw-r--r--lib/bip353.ts2
-rw-r--r--lib/invoices.ts1
-rw-r--r--lib/payments.ts192
-rw-r--r--routes/payments.ts2
-rw-r--r--routes/users.ts19
6 files changed, 157 insertions, 129 deletions
diff --git a/lib/ark.ts b/lib/ark.ts
index 9736b419..dd35ca21 100644
--- a/lib/ark.ts
+++ b/lib/ark.ts
@@ -22,6 +22,13 @@ let lastRefresh = 0;
const REFRESH_COOLDOWN = 30_000;
const failedBoardingOutpoints = new Set<string>();
+let recoveryFailures = 0;
+let renewalFailures = 0;
+const MAX_FAILURES_BEFORE_BACKOFF = 3;
+const BACKOFF_CYCLES = 30; // skip ~30 cycles (30 min at 60s interval)
+let recoverySkipUntil = 0;
+let renewalSkipUntil = 0;
+
const withTimeout = <T>(promise: Promise<T>, ms: number, label: string) =>
Promise.race([
promise,
@@ -49,26 +56,42 @@ export const refreshArkWallet = async (force = false) => {
const manager = new VtxoManager(w);
- // Recover swept/expired VTXOs
- if (balance.recoverable > 0) {
+ // Recover swept/expired VTXOs (with backoff on repeated failures)
+ if (balance.recoverable > 0 && now >= recoverySkipUntil) {
try {
const txid = await withTimeout(manager.recoverVtxos(), 60_000, "ark recovery");
l("ark recovered swept vtxos, txid:", txid);
+ recoveryFailures = 0;
} catch (e: any) {
- warn("ark vtxo recovery failed:", e.message);
+ recoveryFailures++;
+ if (recoveryFailures >= MAX_FAILURES_BEFORE_BACKOFF) {
+ recoverySkipUntil = Date.now() + BACKOFF_CYCLES * 60_000;
+ warn("ark vtxo recovery failed", recoveryFailures, "times, backing off 30m");
+ } else {
+ warn("ark vtxo recovery failed:", e.message);
+ }
}
}
- // Renew VTXOs approaching expiry (SDK default: 3 days)
- try {
- const expiring = await manager.getExpiringVtxos();
- if (expiring.length > 0) {
- l("ark renewing", expiring.length, "expiring vtxos");
- const txid = await withTimeout(manager.renewVtxos(), 60_000, "ark renewal");
- l("ark renewed vtxos, txid:", txid);
+ // Renew VTXOs approaching expiry (with backoff on repeated failures)
+ if (now >= renewalSkipUntil) {
+ try {
+ const expiring = await manager.getExpiringVtxos();
+ if (expiring.length > 0) {
+ l("ark renewing", expiring.length, "expiring vtxos");
+ const txid = await withTimeout(manager.renewVtxos(), 60_000, "ark renewal");
+ l("ark renewed vtxos, txid:", txid);
+ renewalFailures = 0;
+ }
+ } catch (e: any) {
+ renewalFailures++;
+ if (renewalFailures >= MAX_FAILURES_BEFORE_BACKOFF) {
+ renewalSkipUntil = Date.now() + BACKOFF_CYCLES * 60_000;
+ warn("ark vtxo renewal failed", renewalFailures, "times, backing off 30m");
+ } else {
+ warn("ark vtxo renewal failed:", e.message);
+ }
}
- } catch (e: any) {
- warn("ark vtxo renewal failed:", e.message);
}
// Onboard confirmed boarding UTXOs
@@ -97,8 +120,8 @@ export const refreshArkWallet = async (force = false) => {
failedBoardingOutpoints.clear();
break;
} catch (e: any) {
- if (!/not enough intent confirmations/i.test(e.message))
- failedBoardingOutpoints.add(outpointKey(utxo));
+ const retriable = /not enough intent confirmations/i.test(e.message);
+ if (!retriable) failedBoardingOutpoints.add(outpointKey(utxo));
warn("ark onboard failed:", utxo.value, "sats:", e.message);
}
}
@@ -114,25 +137,6 @@ export const refreshArkWallet = async (force = false) => {
setTimeout(refreshArkWallet, 60_000);
setInterval(() => refreshArkWallet(), 60_000);
-// Log ark wallet balances every 60s
-setInterval(async () => {
- try {
- const w = await getWallet();
- const balance = await w.getBalance();
- l(
- "ark balance — available:",
- balance.available,
- "recoverable:",
- balance.recoverable,
- "boarding:",
- balance.boarding.confirmed,
- "pending:",
- balance.boarding.unconfirmed,
- );
- } catch (e: any) {
- warn("ark balance check failed:", e.message);
- }
-}, 60_000);
export const sendArk = async (address: string, amount: number) => {
const timeout = new Promise((_, reject) =>
diff --git a/lib/bip353.ts b/lib/bip353.ts
index 8b00635b..2e6e1473 100644
--- a/lib/bip353.ts
+++ b/lib/bip353.ts
@@ -18,8 +18,6 @@ const createDnsRecord = async (username: string, bolt12: string) => {
const name = `${username}.user._bitcoin-payment.${config.hostname}`;
const content = `bitcoin:?lno=${bolt12}`;
- const { zoneId } = config.cloudflare;
-
const listRes = await fetch(
`${cfApi}/zones/${zoneId}/dns_records?type=TXT&name=${name}`,
{ headers: cfHeaders() },
diff --git a/lib/invoices.ts b/lib/invoices.ts
index 15de0687..c5194ce3 100644
--- a/lib/invoices.ts
+++ b/lib/invoices.ts
@@ -100,6 +100,7 @@ export const generate = async ({ invoice, user }) => {
account.nextIndex = nextIndex + 1;
await s(`account:${aid}`, account);
await s(`invoice:${hash}`, id);
+ await db.sAdd("watching", hash);
} else if (type === PaymentType.lightning) {
let r;
if (bolt11) {
diff --git a/lib/payments.ts b/lib/payments.ts
index f2e4fcab..bdaf2b1d 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -1341,6 +1341,7 @@ export const syncBitcoinVault = async (account, user) => {
const internalAddrs = deriveAddresses(pubkey, fingerprint, count, true);
const allAddrs = [...externalAddrs, ...internalAddrs];
const addressSet = new Set(allAddrs);
+ const changeSet = new Set(internalAddrs);
const utxos = await getAddressUtxos(allAddrs);
const confirmedUtxos = utxos.filter((u) => u.status.confirmed && u.value >= 300);
@@ -1351,59 +1352,12 @@ export const syncBitcoinVault = async (account, user) => {
const { rate, currency } = await getUserRate(user);
const newPayments = [];
+ const externalSet = new Set(externalAddrs);
- // Process confirmed UTXOs (incoming)
- for (const u of confirmedUtxos) {
- const ref = `${u.txid}:${u.vout}`;
- const existing = await getPayment(ref);
- if (existing?.aid === id) {
- if (!existing.confirmed) {
- existing.confirmed = true;
- await s(`payment:${existing.id}`, existing);
- }
- continue;
- }
-
- if (existing) {
- existing.aid = id;
- existing.confirmed = true;
- 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 : Date.now();
- const p = {
- id: v4(),
- aid: id,
- amount: u.value,
- fee: 0,
- hash: u.address,
- confirmed: true,
- rate,
- currency,
- type: PaymentType.bitcoin,
- uid,
- ref,
- created,
- };
-
- await db
- .multi()
- .set(`payment:${ref}`, p.id)
- .set(`payment:${p.id}`, JSON.stringify(p))
- .lPush(`${id}:payments`, p.id)
- .set(`${id}:payments:last`, p.created)
- .exec();
-
- newPayments.push(p);
- }
-
- // Process pending (unconfirmed) UTXOs
+ // Scan pending UTXOs for unconfirmed incoming (external only)
for (const u of pendingUtxos) {
+ if (!externalSet.has(u.address)) continue;
+
const ref = `${u.txid}:${u.vout}`;
const existing = await getPayment(ref);
if (existing?.aid === id) continue;
@@ -1444,7 +1398,7 @@ export const syncBitcoinVault = async (account, user) => {
newPayments.push(p);
}
- // Process outgoing transactions
+ // Process all transactions from history (incoming + outgoing)
const txsById = new Map();
for (const address of allAddrs) {
const txs = await getAddressTxs(address);
@@ -1455,20 +1409,10 @@ export const syncBitcoinVault = async (account, user) => {
for (const tx of txsById.values()) {
if (!tx.status?.confirmed) continue;
- const existing = await getPayment(tx.txid);
- 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;
+ let outputsToExternal = 0;
let totalIn = 0;
let totalOut = 0;
@@ -1484,40 +1428,108 @@ export const syncBitcoinVault = async (account, user) => {
const value = Number.parseInt(vout.value) || 0;
totalOut += value;
if (addressSet.has(vout.scriptpubkey_address)) outputsToUs += value;
+ if (externalSet.has(vout.scriptpubkey_address)) outputsToExternal += value;
}
- if (!inputsFromUs) continue;
+ const created = tx.status?.block_time ? tx.status.block_time * 1000 : Date.now();
- const net = outputsToUs - inputsFromUs;
- if (net >= 0) continue;
+ if (inputsFromUs) {
+ // Outgoing transaction
+ const net = outputsToUs - inputsFromUs;
+ if (net >= 0) continue;
- const amount = totalOut - outputsToUs;
- if (amount <= 0) continue;
+ const amount = totalOut - outputsToUs;
+ if (amount <= 0) continue;
- const fee = Math.max(0, totalIn - totalOut);
- const created = tx.status?.block_time ? tx.status.block_time * 1000 : Date.now();
+ const existing = await getPayment(tx.txid);
+ 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 p = {
- id: v4(),
- aid: id,
- amount: -amount,
- fee,
- hash: tx.txid,
- confirmed: true,
- rate,
- currency,
- type: PaymentType.bitcoin,
- uid,
- created,
- };
+ const fee = Math.max(0, totalIn - totalOut);
+ const p = {
+ id: v4(),
+ aid: id,
+ amount: -amount,
+ fee,
+ hash: tx.txid,
+ confirmed: true,
+ rate,
+ currency,
+ type: PaymentType.bitcoin,
+ uid,
+ created,
+ };
- await db
- .multi()
- .set(`payment:${tx.txid}`, p.id)
- .set(`payment:${p.id}`, JSON.stringify(p))
- .lPush(`${id}:payments`, p.id)
- .set(`${id}:payments:last`, p.created)
- .exec();
+ await db
+ .multi()
+ .set(`payment:${tx.txid}`, p.id)
+ .set(`payment:${p.id}`, JSON.stringify(p))
+ .lPush(`${id}:payments`, p.id)
+ .set(`${id}:payments:last`, p.created)
+ .exec();
+ } else if (outputsToExternal) {
+ // Incoming transaction (to external addresses, not from us)
+ for (const vout of tx.vout || []) {
+ const value = Number.parseInt(vout.value) || 0;
+ if (value < 300) continue;
+ if (!externalSet.has(vout.scriptpubkey_address)) continue;
+
+ const voutIndex = tx.vout.indexOf(vout);
+ const ref = `${tx.txid}:${voutIndex}`;
+ const existing = await getPayment(ref);
+ if (existing?.aid === id) {
+ if (!existing.confirmed) {
+ existing.confirmed = true;
+ await s(`payment:${existing.id}`, existing);
+ }
+ continue;
+ }
+
+ if (existing) {
+ existing.aid = id;
+ existing.confirmed = true;
+ await db
+ .multi()
+ .set(`payment:${existing.id}`, JSON.stringify(existing))
+ .lPush(`${id}:payments`, existing.id)
+ .exec();
+ continue;
+ }
+
+ const p = {
+ id: v4(),
+ aid: id,
+ amount: value,
+ fee: 0,
+ hash: vout.scriptpubkey_address,
+ confirmed: true,
+ rate,
+ currency,
+ type: PaymentType.bitcoin,
+ uid,
+ ref,
+ created,
+ };
+
+ await db
+ .multi()
+ .set(`payment:${ref}`, p.id)
+ .set(`payment:${p.id}`, JSON.stringify(p))
+ .lPush(`${id}:payments`, p.id)
+ .set(`${id}:payments:last`, p.created)
+ .exec();
+
+ newPayments.push(p);
+ }
+ }
}
return { confirmedTotal, pendingTotal, newPayments };
diff --git a/routes/payments.ts b/routes/payments.ts
index 524f6f4d..f58304cd 100644
--- a/routes/payments.ts
+++ b/routes/payments.ts
@@ -1167,7 +1167,7 @@ export default {
const allPayments = await gfAll(paymentKeys);
let balance = 0;
for (const pay of allPayments) {
- if (pay) balance += pay.amount;
+ if (pay && pay.confirmed !== false) balance += pay.amount - (pay.fee || 0);
}
balance = Math.max(balance, 0);
diff --git a/routes/users.ts b/routes/users.ts
index 027d836e..78fddbc9 100644
--- a/routes/users.ts
+++ b/routes/users.ts
@@ -899,15 +899,18 @@ export default {
const paymentKeys = paymentIds.map((pid) => `payment:${pid}`);
const payments = await gfAll(paymentKeys);
let sum = 0;
+ let pending = 0;
for (const pay of payments) {
- if (pay) sum += pay.amount;
+ if (!pay) continue;
+ if (pay.confirmed === false) pending += pay.amount;
+ else sum += pay.amount - (pay.fee || 0);
}
account.balance = Math.max(sum, 0);
+ account.pending = Math.max(pending, 0);
} else {
account.balance = await getBalance(aid);
+ account.pending = await getPending(aid);
}
-
- account.pending = await getPending(aid);
accounts.push(account);
}
@@ -943,6 +946,16 @@ export default {
await createBalanceAccount(id);
+ if (pubkey && type === "bitcoin") {
+ const accountIds = await db.lRange(`${uid}:accounts`, 0, -1);
+ for (const accId of accountIds) {
+ const acc = await g(`account:${accId}`);
+ if (acc?.pubkey === pubkey) {
+ return bail(c, "A vault with this key already exists");
+ }
+ }
+ }
+
if (type === "ark") {
const accountIds = await db.lRange(`${user.id}:accounts`, 0, -1);
for (const accId of accountIds) {