diff options
| author | Adam Soltys <[email protected]> | 2026-03-03 23:02:57 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-03 23:02:57 -0800 |
| commit | 63fad61716c013b6663df789ed96f6e294630642 (patch) | |
| tree | 94b26a7df691d857c5648824699565f8f7340e52 | |
| parent | 55ae58daf23028fa0f647f7bb2b4b0f8cd751de7 (diff) | |
| download | coinos-server-63fad61716c013b6663df789ed96f6e294630642.tar.gz coinos-server-63fad61716c013b6663df789ed96f6e294630642.zip | |
vault wallet fixes
| -rw-r--r-- | lib/ark.ts | 70 | ||||
| -rw-r--r-- | lib/bip353.ts | 2 | ||||
| -rw-r--r-- | lib/invoices.ts | 1 | ||||
| -rw-r--r-- | lib/payments.ts | 192 | ||||
| -rw-r--r-- | routes/payments.ts | 2 | ||||
| -rw-r--r-- | routes/users.ts | 19 |
6 files changed, 157 insertions, 129 deletions
@@ -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) { |
