diff options
| author | Adam Soltys <[email protected]> | 2026-02-26 13:36:41 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-26 13:36:41 -0800 |
| commit | 71fde8aa24b2e976b450fa16e3debe1078433086 (patch) | |
| tree | aef96c21488aa36412aac67d1135594cc83c5372 | |
| parent | f031bed801d858b006cb8a49131e18851e10bbec (diff) | |
| download | coinos-server-71fde8aa24b2e976b450fa16e3debe1078433086.tar.gz coinos-server-71fde8aa24b2e976b450fa16e3debe1078433086.zip | |
fix ark vault to custodial sends
| -rw-r--r-- | index.ts | 27 | ||||
| -rw-r--r-- | lib/payments.ts | 68 | ||||
| -rw-r--r-- | routes/payments.ts | 129 |
3 files changed, 175 insertions, 49 deletions
@@ -1,5 +1,8 @@ +import config from "$config"; import app from "$lib/app"; +import { sendArk } from "$lib/ark"; import { admin, auth, optional } from "$lib/auth"; +import { g, s } from "$lib/db"; import { fixBolt12, listenForLightning, replay } from "$lib/lightning"; import { startHealthCheck } from "$lib/health"; @@ -224,6 +227,30 @@ app.post("/echo", (req, res) => { res.send(req.body); }); +app.post("/test/ark/send", async (req, res) => { + try { + const secret = req.headers["x-test-secret"] as string; + if (!config.testSecret || secret !== config.testSecret) { + return res.code(403).send("Forbidden"); + } + const { address, amount, iid } = req.body; + const txid = await sendArk(address, amount); + + // Mark invoice as paid so tests don't need client-side SDK sync + if (iid) { + const invoice = await g(`invoice:${iid}`); + if (invoice) { + invoice.received = (invoice.received || 0) + amount; + await s(`invoice:${iid}`, invoice); + } + } + + res.send({ txid, iid }); + } catch (e: any) { + res.code(500).send(e.message); + } +}); + const host: string = process.env["HOST"] || "0.0.0.0"; const port: number = Number.parseInt(process.env["PORT"]) || 3119; diff --git a/lib/payments.ts b/lib/payments.ts index 841beac5..08fa8665 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -46,7 +46,7 @@ import { sleep, t, } from "$lib/utils"; -import { sendArk } from "$lib/ark"; +import { getArkBalance, sendArk } from "$lib/ark"; import { callWebhook } from "$lib/webhooks"; import rpc from "@coinos/rpc"; import { selectUTXO, p2wpkh } from "@scure/btc-signer"; @@ -493,30 +493,50 @@ const pay = async ({ aid = undefined, amount, to, user }) => { }); if (to.startsWith("ark") || to.startsWith("tark")) { - // Check for local custodial receive - do internal transfer - const vault = await g(`arkaddr:${to}`); - if (vault) { - const vaultAccount = await g(`account:${vault.aid}`); - if (vaultAccount?.arkAddress) { - const iid = await g(`custodial-ark-invoice:${vaultAccount.arkAddress}`); - if (iid) { - const invoice = await getInvoice(iid); - if (invoice && !invoice.hash) invoice.hash = invoice.id; - const recipient = await getUser(vault.uid); - if (recipient && invoice) - return sendInternal({ amount, invoice, recipient, sender: user }); - } - } - } - - const txid = await sendArk(to, amount); + const tmpHash = v4(); const p = await debit({ - hash: txid, + hash: tmpHash, amount, user, type: PaymentType.ark, }); + try { + const txid = await sendArk(to, amount); + p.hash = txid; + await s(`payment:${p.id}`, p); + await s(`payment:${txid}`, p.id); + + // Instant vault notification + try { + const vaultInfo = await g(`arkaddr:${to}`); + if (vaultInfo) { + const { aid: vaultAid, uid: vaultUid } = vaultInfo; + const isForward = await g(`custodial-ark-invoice:${to}`); + const vaultOwner = await g(`user:${vaultUid}`); + const { rate, currency } = await getUserRate(vaultOwner || user); + const vp = await createArkPayment({ + aid: vaultAid, + uid: vaultUid, + amount, + hash: txid, + rate, + currency, + extraHashMappings: [txid], + }); + if (!isForward) { + l("ark pay: instant vault credit", vaultAid, txid); + emit(vaultUid, "payment", vp); + } + } + } catch (e) { + warn("ark pay: instant vault notification failed", e.message); + } + } catch (e) { + await reverse(p); + throw e; + } + return p; } @@ -1375,7 +1395,7 @@ const finalize = async (r, p) => { return p; }; -const reverse = async (p) => { +export const reverse = async (p) => { await sleep(Math.floor(Math.random() * (1500 - 500 + 1)) + 500); const total = Math.abs(p.amount) + p.fee + p.ourfee; @@ -1424,6 +1444,14 @@ const freezeCheck = async () => { await s("ecash:limit", Math.max(lnbalance - lnthreshold, 0)); await s("bolt12:limit", Math.max(lnbalance - lnthreshold, 0)); + try { + const arkBalance = await getArkBalance(); + const arkthreshold = (await g("ark:threshold")) || 0; + await s("ark:limit", Math.max(arkBalance.available - arkthreshold, 0)); + } catch (e) { + warn("freezeCheck ark balance failed:", e.message); + } + setTimeout(freezeCheck, 10000); }; freezeCheck(); diff --git a/routes/payments.ts b/routes/payments.ts index e7fe7933..33bf3381 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -19,6 +19,7 @@ import { decode, getUserRate, processWatchedTx, + reverse, sendInternal, sendLightning, sendOnchain, @@ -77,15 +78,52 @@ export default { if (invoice?.type === PaymentType.ark) { if (!amount) ({ amount } = invoice); if (!invoice.text) fail("Missing ark address"); - const txid = await sendArk(invoice.text, amount); + const tmpHash = v4(); p = await debit({ - hash: txid, + hash: tmpHash, amount, memo, user, type: PaymentType.ark, aid, }); + try { + const txid = await sendArk(invoice.text, amount); + p.hash = txid; + await s(`payment:${p.id}`, p); + await s(`payment:${txid}`, p.id); + + // Instant vault notification + try { + const vaultInfo = await g(`arkaddr:${invoice.text}`); + if (vaultInfo) { + const { aid: vaultAid, uid: vaultUid } = vaultInfo; + const isForward = await g(`custodial-ark-invoice:${invoice.text}`); + const vaultOwner = await g(`user:${vaultUid}`); + const { rate: vRate, currency: vCurrency } = await getUserRate( + vaultOwner || user, + ); + const vp = await createArkPayment({ + aid: vaultAid, + uid: vaultUid, + amount, + hash: txid, + rate: vRate, + currency: vCurrency, + extraHashMappings: [txid], + }); + if (!isForward) { + l("ark invoice: instant vault credit", vaultAid, txid); + emit(vaultUid, "payment", vp); + } + } + } catch (e) { + warn("ark invoice: instant vault notification failed", e.message); + } + } catch (e) { + await reverse(p); + throw e; + } } else { // Check if recipient account is non-custodial (vault) const recipientAccount = invoice?.aid ? await g(`account:${invoice.aid}`) : null; @@ -757,39 +795,59 @@ export default { try { await requirePin({ body, user }); - // Check for local custodial receive - do internal transfer - const vault = await g(`arkaddr:${address}`); - if (vault) { - const account = await g(`account:${vault.aid}`); - if (account?.arkAddress) { - const iid = await g(`custodial-ark-invoice:${account.arkAddress}`); - if (iid) { - const invoice = await getInvoice(iid); - if (invoice && !invoice.hash) invoice.hash = invoice.id; - const recipient = await getUser(vault.uid); - if (recipient && invoice) { - l("ark local send", user.username, recipient.username, amount); - const p = await sendInternal({ amount, invoice, recipient, sender: user }); - await db.del(`custodial-ark-invoice:${account.arkAddress}`); - return res.send(p); - } - } - } - } - l("ark send", user.username, address, amount); - const txid = await sendArk(address, amount); - l("ark send complete", txid); - const hash = txid; - + const tmpHash = v4(); const p = await debit({ - hash, + hash: tmpHash, amount, user, type: PaymentType.ark, aid, }); + try { + const txid = await sendArk(address, amount); + l("ark send complete", txid); + p.hash = txid; + await s(`payment:${p.id}`, p); + await s(`payment:${txid}`, p.id); + + // Instant vault notification: if destination is a vault on our platform, + // create the receive payment immediately so the client doesn't have to + // wait for ASP indexer + arkSync backoff delays + try { + const vaultInfo = await g(`arkaddr:${address}`); + if (vaultInfo) { + const { aid: vaultAid, uid: vaultUid } = vaultInfo; + + // Skip notification if this is a forward (e.g. vault → lightning); + // the funds are transient and will leave again immediately + const isForward = await g(`custodial-ark-invoice:${address}`); + + const vaultOwner = await g(`user:${vaultUid}`); + const { rate, currency } = await getUserRate(vaultOwner || user); + const vp = await createArkPayment({ + aid: vaultAid, + uid: vaultUid, + amount: parseInt(amount), + hash: txid, + rate, + currency, + extraHashMappings: [txid], + }); + if (!isForward) { + l("ark send: instant vault credit", vaultAid, txid); + emit(vaultUid, "payment", vp); + } + } + } catch (e) { + warn("ark send: instant vault notification failed", e.message); + } + } catch (e) { + await reverse(p); + throw e; + } + res.send(p); } catch (e) { warn(user.username, "ark payment failed", e.message); @@ -941,6 +999,10 @@ export default { } if (found) continue; + // Skip sent/zero transactions not recorded via vault-send — these are + // from internal wallet operations (settle, refresh) not real sends + if (tx.amount <= 0) continue; + const primaryHash = hashes[0]; const p = await createArkPayment({ @@ -981,12 +1043,21 @@ export default { if (typeof balance === "number" && balance >= 0) { const paymentIds = await db.lRange(`${aid}:payments`, 0, -1); let expectedBalance = 0; + const now = Date.now(); + let hasRecentPayments = false; for (const pid of paymentIds) { const pay = await g(`payment:${pid}`); - if (pay) expectedBalance += pay.amount; + if (pay) { + expectedBalance += pay.amount; + // Skip reconciliation if there are recent payments the SDK + // may not have indexed yet (e.g. instant vault credits) + if (pay.amount > 0 && now - pay.created < 120_000) { + hasRecentPayments = true; + } + } } - if (expectedBalance > balance) { + if (expectedBalance > balance && !hasRecentPayments) { const p = await createArkPayment({ aid, uid, |
