aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-26 13:36:41 -0800
committerAdam Soltys <[email protected]>2026-02-26 13:36:41 -0800
commit71fde8aa24b2e976b450fa16e3debe1078433086 (patch)
treeaef96c21488aa36412aac67d1135594cc83c5372
parentf031bed801d858b006cb8a49131e18851e10bbec (diff)
downloadcoinos-server-71fde8aa24b2e976b450fa16e3debe1078433086.tar.gz
coinos-server-71fde8aa24b2e976b450fa16e3debe1078433086.zip
fix ark vault to custodial sends
-rw-r--r--index.ts27
-rw-r--r--lib/payments.ts68
-rw-r--r--routes/payments.ts129
3 files changed, 175 insertions, 49 deletions
diff --git a/index.ts b/index.ts
index ab20e388..efcaf484 100644
--- a/index.ts
+++ b/index.ts
@@ -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,