aboutsummaryrefslogtreecommitdiffstats
path: root/test
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-16 11:51:41 -0800
committerAdam Soltys <[email protected]>2026-02-16 11:51:41 -0800
commitee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40 (patch)
tree04d097d676c4c0a48287f631a539b4055522dcfe /test
parent20cee249905ee6975e88089b76b2eccb4019e19a (diff)
downloadcoinos-server-ee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40.tar.gz
coinos-server-ee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40.zip
passkey auth
Diffstat (limited to 'test')
-rw-r--r--test/forward.test.ts162
-rw-r--r--test/integration/ark-reconcile.test.ts272
2 files changed, 434 insertions, 0 deletions
diff --git a/test/forward.test.ts b/test/forward.test.ts
index 7f548b67..692c5b1e 100644
--- a/test/forward.test.ts
+++ b/test/forward.test.ts
@@ -377,6 +377,168 @@ describe("arkSync", () => {
});
// =====================================================================
+// Tests: arkSync expired VTXO reconciliation
+// =====================================================================
+
+describe("arkSync reconciliation", () => {
+ beforeEach(resetAll);
+
+ test("creates expired debit when payment sum exceeds wallet balance", async () => {
+ // Pre-seed two received payments totaling 7128
+ const pay1 = { id: "pay-1", aid: arkAccountId, amount: 5000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() - 2000 };
+ const pay2 = { id: "pay-2", aid: arkAccountId, amount: 2128, hash: "h2", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() - 1000 };
+ store().kvStore[`payment:pay-1`] = JSON.stringify(pay1);
+ store().kvStore[`payment:pay-2`] = JSON.stringify(pay2);
+ store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1");
+ store().kvStore[`payment:${arkAccountId}:h2`] = JSON.stringify("pay-2");
+ store().listStore[`${arkAccountId}:payments`] = ["pay-2", "pay-1"];
+
+ // Sync with no new transactions but balance=0 (VTXOs expired)
+ const req = {
+ body: {
+ aid: arkAccountId,
+ transactions: [],
+ balance: 0,
+ },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkSync(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ expect(sent.synced).toBe(1);
+ expect(sent.payments).toHaveLength(1);
+
+ const reconPayment = sent.payments[0];
+ expect(reconPayment.amount).toBe(-7128);
+ expect(reconPayment.memo).toBe("expired");
+ expect(reconPayment.type).toBe("ark");
+
+ // Payment list should now have 3 entries
+ expect(store().listStore[`${arkAccountId}:payments`]).toHaveLength(3);
+ });
+
+ test("does not reconcile when sum matches balance", async () => {
+ // Pre-seed a payment of 5000
+ const pay1 = { id: "pay-1", aid: arkAccountId, amount: 5000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() };
+ store().kvStore[`payment:pay-1`] = JSON.stringify(pay1);
+ store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1");
+ store().listStore[`${arkAccountId}:payments`] = ["pay-1"];
+
+ const req = {
+ body: {
+ aid: arkAccountId,
+ transactions: [],
+ balance: 5000,
+ },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkSync(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ expect(sent.synced).toBe(0);
+ expect(sent.payments).toHaveLength(0);
+ expect(store().listStore[`${arkAccountId}:payments`]).toHaveLength(1);
+ });
+
+ test("does not reconcile when balance is not provided (backward compat)", async () => {
+ const pay1 = { id: "pay-1", aid: arkAccountId, amount: 5000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() };
+ store().kvStore[`payment:pay-1`] = JSON.stringify(pay1);
+ store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1");
+ store().listStore[`${arkAccountId}:payments`] = ["pay-1"];
+
+ const req = {
+ body: {
+ aid: arkAccountId,
+ transactions: [],
+ },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkSync(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ expect(sent.synced).toBe(0);
+ expect(sent.payments).toHaveLength(0);
+ });
+
+ test("is idempotent — second sync does not create duplicate", async () => {
+ // Seed payment sum = 5000, balance = 0
+ const pay1 = { id: "pay-1", aid: arkAccountId, amount: 5000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() };
+ store().kvStore[`payment:pay-1`] = JSON.stringify(pay1);
+ store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1");
+ store().listStore[`${arkAccountId}:payments`] = ["pay-1"];
+
+ const req = {
+ body: { aid: arkAccountId, transactions: [], balance: 0 },
+ user: makeUser(),
+ };
+
+ // First sync — should create reconciliation
+ const res1 = makeRes();
+ await routes.arkSync(req as any, res1 as any);
+ const sent1 = (res1 as any).getSent();
+ expect(sent1.synced).toBe(1);
+ expect(sent1.payments[0].amount).toBe(-5000);
+
+ // Second sync — sum is now 0, matches balance, no new reconciliation
+ const res2 = makeRes();
+ await routes.arkSync(req as any, res2 as any);
+ const sent2 = (res2 as any).getSent();
+ expect(sent2.synced).toBe(0);
+ expect(sent2.payments).toHaveLength(0);
+ });
+
+ test("reconciles partial expiry", async () => {
+ // 10000 received, 3000 sent, sum = 7000. Actual balance = 2000 (5000 expired)
+ const pay1 = { id: "pay-1", aid: arkAccountId, amount: 10000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() };
+ const pay2 = { id: "pay-2", aid: arkAccountId, amount: -3000, hash: "h2", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() };
+ store().kvStore[`payment:pay-1`] = JSON.stringify(pay1);
+ store().kvStore[`payment:pay-2`] = JSON.stringify(pay2);
+ store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1");
+ store().kvStore[`payment:${arkAccountId}:h2`] = JSON.stringify("pay-2");
+ store().listStore[`${arkAccountId}:payments`] = ["pay-2", "pay-1"];
+
+ const req = {
+ body: { aid: arkAccountId, transactions: [], balance: 2000 },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkSync(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ expect(sent.synced).toBe(1);
+ expect(sent.payments[0].amount).toBe(-5000);
+ expect(sent.payments[0].memo).toBe("expired");
+ });
+
+ test("does not reconcile when sum is less than balance", async () => {
+ // Sum = 3000, balance = 5000 (e.g., recovered VTXOs already synced)
+ const pay1 = { id: "pay-1", aid: arkAccountId, amount: 3000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() };
+ store().kvStore[`payment:pay-1`] = JSON.stringify(pay1);
+ store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1");
+ store().listStore[`${arkAccountId}:payments`] = ["pay-1"];
+
+ const req = {
+ body: { aid: arkAccountId, transactions: [], balance: 5000 },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkSync(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ expect(sent.synced).toBe(0);
+ expect(sent.payments).toHaveLength(0);
+ });
+});
+
+// =====================================================================
// Tests: BOLT12 offer autowithdraw
// =====================================================================
diff --git a/test/integration/ark-reconcile.test.ts b/test/integration/ark-reconcile.test.ts
new file mode 100644
index 00000000..5e2061e6
--- /dev/null
+++ b/test/integration/ark-reconcile.test.ts
@@ -0,0 +1,272 @@
+process.env.INTEGRATION = "1";
+
+import { describe, test, expect, beforeAll } from "bun:test";
+
+// =====================================================================
+// Helpers
+// =====================================================================
+
+const APP = "http://localhost:3119";
+
+const api = async (path: string, token: string, opts: any = {}) => {
+ const res = await fetch(`${APP}${path}`, {
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ ...opts.headers,
+ },
+ ...opts,
+ });
+ return res.json();
+};
+
+const register = async (username: string, password: string) => {
+ const res = await fetch(`${APP}/register`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ user: { username, password } }),
+ });
+ if (!res.ok) throw new Error(`register failed: ${await res.text()}`);
+ return res.json();
+};
+
+const login = async (username: string, password: string) => {
+ const res = await fetch(`${APP}/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!res.ok) throw new Error(`login failed: ${await res.text()}`);
+ return res.json();
+};
+
+const createInvoice = (token: string, invoice: any) =>
+ api("/invoice", token, {
+ method: "POST",
+ body: JSON.stringify({ invoice }),
+ });
+
+const getPayments = (token: string, aid?: string) =>
+ api(`/payments${aid ? `?aid=${aid}` : ""}`, token);
+
+// =====================================================================
+// Test state
+// =====================================================================
+
+let token: string;
+let user: any;
+let arkAccountId: string;
+
+const ts = Math.random().toString(36).slice(2, 8);
+const username = `arkrec${ts}`;
+
+// =====================================================================
+// Setup
+// =====================================================================
+
+beforeAll(async () => {
+ const result = await register(username, "testpass");
+ token = result.token;
+ user = result.user;
+
+ // Create an ark account for this user
+ const res = await fetch(`${APP}/accounts`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ type: "ark", name: "ark vault" }),
+ });
+ if (res.ok) {
+ const account = await res.json();
+ arkAccountId = account.id;
+ } else {
+ // Fallback: use user's own ID as the account
+ arkAccountId = user.id;
+ }
+});
+
+// =====================================================================
+// Tests
+// =====================================================================
+
+describe("ark sync reconciliation", () => {
+ test("syncs new transactions and creates payment records", async () => {
+ const hash = `test-recv-${Date.now()}`;
+ const result = await api("/ark/sync", token, {
+ method: "POST",
+ body: JSON.stringify({
+ aid: arkAccountId,
+ transactions: [
+ {
+ arkTxid: hash,
+ commitmentTxid: null,
+ amount: 10000,
+ settled: true,
+ createdAt: Date.now(),
+ },
+ ],
+ balance: 10000,
+ }),
+ });
+
+ expect(result.synced).toBe(1);
+ expect(result.received).toBe(10000);
+ expect(result.payments).toHaveLength(1);
+ expect(result.payments[0].amount).toBe(10000);
+ });
+
+ test("deduplicates already-synced transactions", async () => {
+ // Sync the same hash again — should be skipped
+ const hash = `test-recv-${Date.now()}`;
+
+ // First sync
+ await api("/ark/sync", token, {
+ method: "POST",
+ body: JSON.stringify({
+ aid: arkAccountId,
+ transactions: [
+ { arkTxid: hash, amount: 3000, settled: true, createdAt: Date.now() },
+ ],
+ balance: 13000,
+ }),
+ });
+
+ // Second sync with same hash
+ const result = await api("/ark/sync", token, {
+ method: "POST",
+ body: JSON.stringify({
+ aid: arkAccountId,
+ transactions: [
+ { arkTxid: hash, amount: 3000, settled: true, createdAt: Date.now() },
+ ],
+ balance: 13000,
+ }),
+ });
+
+ expect(result.synced).toBe(0);
+ expect(result.payments).toHaveLength(0);
+ });
+
+ test("creates expired debit when balance < payment sum", async () => {
+ // Current payment sum is 13000 (10000 + 3000 from previous tests)
+ // Send balance=0 to simulate all VTXOs expired
+ const result = await api("/ark/sync", token, {
+ method: "POST",
+ body: JSON.stringify({
+ aid: arkAccountId,
+ transactions: [],
+ balance: 0,
+ }),
+ });
+
+ expect(result.synced).toBe(1);
+ expect(result.payments).toHaveLength(1);
+
+ const expired = result.payments[0];
+ expect(expired.amount).toBe(-13000);
+ expect(expired.memo).toBe("expired");
+ expect(expired.type).toBe("ark");
+ });
+
+ test("is idempotent — no duplicate expired debit on re-sync", async () => {
+ // Sum is now 0 (13000 - 13000), balance is still 0
+ const result = await api("/ark/sync", token, {
+ method: "POST",
+ body: JSON.stringify({
+ aid: arkAccountId,
+ transactions: [],
+ balance: 0,
+ }),
+ });
+
+ expect(result.synced).toBe(0);
+ expect(result.payments).toHaveLength(0);
+ });
+
+ test("new receive after expiry works correctly", async () => {
+ // Simulate VTXOs recovered and new receive
+ const hash = `test-recovery-${Date.now()}`;
+ const result = await api("/ark/sync", token, {
+ method: "POST",
+ body: JSON.stringify({
+ aid: arkAccountId,
+ transactions: [
+ { arkTxid: hash, amount: 5000, settled: true, createdAt: Date.now() },
+ ],
+ balance: 5000,
+ }),
+ });
+
+ expect(result.synced).toBe(1);
+ expect(result.received).toBe(5000);
+
+ // Sum is now 5000 (13000 - 13000 + 5000), matches balance=5000
+ // No reconciliation needed
+ expect(
+ result.payments.filter((p: any) => p.memo === "expired"),
+ ).toHaveLength(0);
+ });
+
+ test("partial expiry creates correct debit", async () => {
+ // Balance dropped from 5000 to 2000 (3000 worth of VTXOs expired)
+ const result = await api("/ark/sync", token, {
+ method: "POST",
+ body: JSON.stringify({
+ aid: arkAccountId,
+ transactions: [],
+ balance: 2000,
+ }),
+ });
+
+ expect(result.synced).toBe(1);
+ expect(result.payments).toHaveLength(1);
+ expect(result.payments[0].amount).toBe(-3000);
+ expect(result.payments[0].memo).toBe("expired");
+ });
+
+ test("matches ark invoice when receiving", async () => {
+ // Create an ark invoice first
+ const inv = await createInvoice(token, {
+ type: "ark",
+ amount: 2000,
+ aid: arkAccountId,
+ });
+ expect(inv.id).toBeTruthy();
+
+ const hash = `test-invoice-recv-${Date.now()}`;
+ const result = await api("/ark/sync", token, {
+ method: "POST",
+ body: JSON.stringify({
+ aid: arkAccountId,
+ transactions: [
+ { arkTxid: hash, amount: 2000, settled: true, createdAt: Date.now() },
+ ],
+ balance: 4000, // previous 2000 + new 2000
+ }),
+ });
+
+ expect(result.synced).toBe(1);
+ expect(result.received).toBe(2000);
+
+ // Payment should be matched to the invoice
+ const matched = result.payments.find((p: any) => p.iid === inv.id);
+ expect(matched).toBeTruthy();
+ expect(matched.amount).toBe(2000);
+ });
+
+ test("backward compat — no reconciliation without balance field", async () => {
+ // Call without balance field (old client)
+ const result = await api("/ark/sync", token, {
+ method: "POST",
+ body: JSON.stringify({
+ aid: arkAccountId,
+ transactions: [],
+ }),
+ });
+
+ expect(result.synced).toBe(0);
+ expect(result.payments).toHaveLength(0);
+ });
+});