aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-26 13:36:28 -0800
committerAdam Soltys <[email protected]>2026-02-26 13:36:28 -0800
commita5faa64ba470f935ea3e7932feece3cd9fbadb90 (patch)
tree79ec7d6cb2038b5920d5cca977a244fbb189923c
parentb137cdfca84cf68a2b8a620ba1c55337e88a0050 (diff)
downloadcoinos-ui-a5faa64ba470f935ea3e7932feece3cd9fbadb90.tar.gz
coinos-ui-a5faa64ba470f935ea3e7932feece3cd9fbadb90.zip
ark tests
-rw-r--r--src/components/AmountField.svelte2
-rw-r--r--src/components/Lnurlp.svelte4
-rw-r--r--src/components/Lnurlw.svelte5
-rw-r--r--src/lib/ark.ts119
-rw-r--r--src/routes/(app)/+layout.svelte31
-rw-r--r--src/routes/(app)/account/[id]/+page.svelte5
-rw-r--r--src/routes/(app)/pay/[username]/[...amount]/+page.server.ts21
-rw-r--r--src/routes/(app)/payment/[id]/+page.svelte2
-rw-r--r--src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.server.ts6
-rw-r--r--src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte30
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte9
-rw-r--r--test/e2e/ark-receive-from-server-wallet.spec.ts158
-rw-r--r--test/e2e/ark-send-custodial.spec.ts13
-rw-r--r--test/e2e/ark-vault-to-custodial.spec.ts63
-rw-r--r--test/e2e/custodial-bitcoin-send.spec.ts15
-rw-r--r--test/e2e/custodial-send-lightning.spec.ts4
-rw-r--r--test/e2e/custodial-send-username.spec.ts6
-rw-r--r--test/e2e/helpers.ts59
-rw-r--r--test/e2e/login-balance.spec.ts13
-rwxr-xr-xtest/e2e/run-staging.sh65
20 files changed, 438 insertions, 192 deletions
diff --git a/src/components/AmountField.svelte b/src/components/AmountField.svelte
index 810e36f1..dc88089c 100644
--- a/src/components/AmountField.svelte
+++ b/src/components/AmountField.svelte
@@ -52,7 +52,7 @@
<div class="fixed bg-base-100/90 inset-0 overflow-y-auto h-full w-full z-50 max-w-lg mx-auto">
<div class="relative p-5 border shadow-lg rounded-md bg-base-100 space-y-5">
<h1 class="text-center text-2xl font-semibold">{label}</h1>
- <Numpad bind:amount {currency} {rate} bind:submit={done} bind:element={numpad} />
+ <Numpad bind:amount {currency} {rate} {locale} bind:submit={done} bind:element={numpad} />
<button bind:this={done} type="button" onclick={set} class="btn">Ok</button>
</div>
diff --git a/src/components/Lnurlp.svelte b/src/components/Lnurlp.svelte
index cde26d86..757a2540 100644
--- a/src/components/Lnurlp.svelte
+++ b/src/components/Lnurlp.svelte
@@ -6,9 +6,11 @@
import Numpad from "$comp/Numpad.svelte";
import Spinner from "$comp/Spinner.svelte";
import { pin } from "$lib/store";
+ import { loc } from "$lib/utils";
let { data, form, send = $bindable(), comment = $bindable() } = $props();
let currency = $derived(data.user.currency);
+ let locale = $derived(loc(data.user));
let minSendable = $derived(data.minSendable);
let maxSendable = $derived(data.maxSendable);
let commentAllowed = $derived(data.commentAllowed);
@@ -39,7 +41,7 @@
<div class="text-red-600 text-center mb-5">{form.error}</div>
{/if}
- <Numpad bind:amount {rate} {currency} bind:submit={send} />
+ <Numpad bind:amount {rate} {currency} {locale} bind:submit={send} />
<form action="?/pay" method="POST" use:enhance onsubmit={submit}>
<input name="amount" value={amount} type="hidden" />
diff --git a/src/components/Lnurlw.svelte b/src/components/Lnurlw.svelte
index a743bba5..0cc6f824 100644
--- a/src/components/Lnurlw.svelte
+++ b/src/components/Lnurlw.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
import { tick } from "svelte";
import handler from "$lib/handler";
- import { sats, f, s, focus } from "$lib/utils";
+ import { sats, f, s, focus, loc } from "$lib/utils";
import { t } from "$lib/translations";
import { enhance } from "$app/forms";
import Numpad from "$comp/Numpad.svelte";
@@ -11,6 +11,7 @@
let { data, form } = $props();
let currency = $derived(data.user.currency);
+ let locale = $derived(loc(data.user));
let username = $derived(data.user.username);
let defaultDescription = $derived(data.defaultDescription);
let minWithdrawable = $derived(data.minWithdrawable);
@@ -56,7 +57,7 @@
{/if}
</div>
{:else}
- <Numpad bind:amount {currency} {rate} />
+ <Numpad bind:amount {currency} {rate} {locale} />
{/if}
<form use:enhance={handler(toggle)} action="?/withdraw" method="POST" class="space-y-5">
diff --git a/src/lib/ark.ts b/src/lib/ark.ts
index 82d1ccce..6685f3ab 100644
--- a/src/lib/ark.ts
+++ b/src/lib/ark.ts
@@ -36,6 +36,90 @@ export const getWallet = async () => {
return walletInstance;
};
+const toHex = (bytes: Uint8Array) =>
+ Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+
+export const subscribeToAsp = (onEvent: () => void) => {
+ let es: EventSource | null = null;
+ let subId: string | null = null;
+ let stopped = false;
+ let watchdog: ReturnType<typeof setInterval> | undefined;
+
+ const connect = async () => {
+ if (stopped) return;
+ es?.close();
+
+ const wallet = await getWallet();
+ if (!wallet) return;
+
+ const script = toHex(wallet.offchainTapscript.pkScript);
+
+ const res = await fetch(`${arkServerUrl}/v1/indexer/script/subscribe`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ scripts: [script] }),
+ });
+
+ if (!res.ok) throw new Error(`ASP subscribe failed: ${res.statusText}`);
+ ({ subscriptionId: subId } = await res.json());
+
+ es = new EventSource(
+ `${arkServerUrl}/v1/indexer/script/subscription/${subId}`,
+ );
+
+ es.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ console.log("[ark] SSE event:", data);
+ if (
+ data.event?.newVtxos?.length > 0 ||
+ data.event?.spentVtxos?.length > 0
+ ) {
+ onEvent();
+ }
+ } catch (e) {
+ console.error("[ark] SSE parse error:", e);
+ }
+ };
+
+ es.onerror = () => {
+ // Native EventSource auto-reconnects on transient errors.
+ // If the subscription expired server-side, the watchdog below
+ // will detect CLOSED state and fully re-subscribe.
+ };
+
+ console.log("[ark] SSE subscribed", subId);
+ };
+
+ connect().catch((e) => {
+ console.error("[ark] SSE initial connect failed:", e);
+ });
+
+ // Watchdog: detect CLOSED state (server rejected reconnect) and re-subscribe
+ watchdog = setInterval(() => {
+ if (stopped) return;
+ if (!es || es.readyState === EventSource.CLOSED) {
+ console.warn("[ark] SSE dead, re-subscribing...");
+ connect().catch((e) => console.error("[ark] SSE reconnect failed:", e));
+ }
+ }, 30_000);
+
+ return () => {
+ stopped = true;
+ if (watchdog) clearInterval(watchdog);
+ es?.close();
+ if (subId) {
+ fetch(`${arkServerUrl}/v1/indexer/script/unsubscribe`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ subscriptionId: subId }),
+ }).catch(() => {});
+ }
+ };
+};
+
export const getAddress = async (): Promise<string> => {
const wallet = await getWallet();
return wallet.getAddress();
@@ -155,26 +239,41 @@ export const sendArk = async (address: string, amount: number) => {
}
const freshBalance = await wallet.getBalance();
+ const preSendTotal = (freshBalance.available || 0) + (freshBalance.preconfirmed || 0);
if (freshBalance.available < amount) {
throw new Error(`Insufficient funds: ${freshBalance.available} available, need ${amount}`);
}
+
let txid;
+ let sendWallet = wallet;
try {
txid = await withTimeout(wallet.sendBitcoin({ address, amount }), 45_000, "Ark send");
} catch (e: any) {
- if (e?.message?.includes("VTXO_ALREADY_REGISTERED")) {
- await wallet.settle();
- const vtxos = await wallet.getVtxos({ spendableOnly: false });
+ if (e?.message?.includes("VTXO_ALREADY_REGISTERED") || e?.message?.includes("VTXO_ALREADY_SPENT")) {
+ console.log("ark send: stale VTXOs, recreating wallet and retrying");
+ walletInstance = undefined;
+ walletKey = undefined;
+ sendWallet = await getWallet();
+ if (!sendWallet) throw new Error("Ark wallet not available after refresh");
+ const vtxos = await sendWallet.getVtxos({ spendableOnly: false });
if (vtxos?.length) addProcessedVtxos(vtxos.map(vtxoKey));
- txid = await withTimeout(wallet.sendBitcoin({ address, amount }), 45_000, "Ark send retry");
+ txid = await withTimeout(sendWallet.sendBitcoin({ address, amount }), 45_000, "Ark send retry");
} else {
throw e;
}
}
+ // Verify the send actually changed the balance
+ const postBalance = await sendWallet.getBalance();
+ const postSendTotal = (postBalance.available || 0) + (postBalance.preconfirmed || 0);
+ console.log("ark send: pre-balance", preSendTotal, "post-balance", postSendTotal, "amount", amount, "txid", txid);
+ if (postSendTotal >= preSendTotal) {
+ throw new Error("Send failed: balance unchanged after sendBitcoin");
+ }
+
// Mark post-send VTXOs as processed so notifyIncomingFunds
// ignores change VTXOs from this send
- const vtxos = await wallet.getVtxos({ spendableOnly: false });
+ const vtxos = await sendWallet.getVtxos({ spendableOnly: false });
if (vtxos?.length) addProcessedVtxos(vtxos.map(vtxoKey));
return txid;
@@ -193,17 +292,17 @@ export const sendArkViaForward = async ({
serverArkAddress: string;
amount: number;
aid: string;
- forward: string;
+ forward?: string;
user: any;
}) => {
const txid = await sendArk(serverArkAddress, amount);
await post("/post/ark/vault-send", { hash: txid, amount, aid });
- const inv = await post("/post/invoice", {
- invoice: { type: "ark", amount, forward, aid },
- user,
- });
+ const invoice: any = { type: "ark", amount, aid };
+ if (forward) invoice.forward = forward;
+
+ const inv = await post("/post/invoice", { invoice, user });
return post("/post/ark/receive", { amount, hash: txid, iid: inv.id });
};
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index 0c88ff19..3fc8f9cf 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
- import { getWallet, syncTransactions, settle, refresh, arkkey, arkaid, sendArk, arkSending } from "$lib/ark";
+ import { getWallet, subscribeToAsp, syncTransactions, settle, refresh, arkkey, arkaid, sendArk, arkSending } from "$lib/ark";
import { SvelteToast } from "@zerodevx/svelte-toast";
import { onDestroy, onMount } from "svelte";
import { close, connect, send, socket } from "$lib/socket";
@@ -102,6 +102,7 @@
let arkInitializedKey: string | undefined;
let arkRefreshTimer: ReturnType<typeof setInterval> | undefined;
+ let aspStopFunc: (() => void) | undefined;
const initArk = async () => {
const key = $arkkey;
@@ -111,7 +112,32 @@
const wallet = await getWallet();
if (wallet) {
localStorage.setItem("arkkey:uid", user.id);
- wallet.notifyIncomingFunds(() => arkSync("notifyIncomingFunds"));
+
+ // Subscribe directly to ASP event stream (bypasses SDK's fragile wrapper)
+ aspStopFunc?.();
+ aspStopFunc = subscribeToAsp(async () => {
+ // Retry arkSync with backoff — indexer needs time to process the new VTXO
+ for (const delay of [1000, 3000, 8000]) {
+ await new Promise((r) => setTimeout(r, delay));
+ const aid = $arkaid || getCookie("aid") || user.id;
+ const result = await syncTransactions(aid);
+ console.log(`[arkSync] SSE retry (delay: ${delay}ms, received: ${result?.received || 0})`);
+ if (result?.received > 0) {
+ const paidPayment = result.payments?.find((p: any) => p.iid && p.amount > 0);
+ if (paidPayment) {
+ goto(`/invoice/${paidPayment.iid}`);
+ } else if ($fiat && $rateStore && user?.currency) {
+ success(`Received ${f(toFiat(result.received, $rateStore as number), user.currency)}!`);
+ } else {
+ success(`Received ${s(result.received)}!`);
+ }
+ invalidate("app:payments");
+ return;
+ }
+ }
+ invalidate("app:payments");
+ });
+
arkSync("initArk");
refresh().catch((e) => console.error("Ark refresh failed:", e));
settle().catch((e) => console.error("Ark settle failed:", e));
@@ -200,6 +226,7 @@
close();
clearTimeout(checkTimer);
if (arkRefreshTimer) clearInterval(arkRefreshTimer);
+ aspStopFunc?.();
}
});
</script>
diff --git a/src/routes/(app)/account/[id]/+page.svelte b/src/routes/(app)/account/[id]/+page.svelte
index 065b85c6..62c4beea 100644
--- a/src/routes/(app)/account/[id]/+page.svelte
+++ b/src/routes/(app)/account/[id]/+page.svelte
@@ -5,7 +5,7 @@
import Toggle from "$comp/Toggle.svelte";
import WalletPass from "$comp/WalletPass.svelte";
import { goto } from "$app/navigation";
- import { copy, fail, focus, post } from "$lib/utils";
+ import { copy, fail, focus, loc, post } from "$lib/utils";
import { importing } from "$lib/store";
import { enhance } from "$app/forms";
import { t } from "$lib/translations";
@@ -33,6 +33,7 @@
let name = $state(data.account.name || displayType);
let currency = $state(data.account.currency || data.user.currency);
let userCurrency = $derived(data.user.currency);
+ let locale = $derived(loc(data.user));
let currencyChanged = $derived(currency !== userCurrency);
let updateUserCurrency = $state(false);
@@ -325,6 +326,7 @@
<Numpad
bind:amount={threshold}
{currency}
+ {locale}
bind:rate
bind:submit={doneReserve}
bind:element={thresholdEl}
@@ -341,6 +343,7 @@
<Numpad
bind:amount={reserve}
{currency}
+ {locale}
bind:rate
bind:submit={doneReserve}
bind:element={reserveEl}
diff --git a/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts b/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts
index f1e5a073..ca0f382d 100644
--- a/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts
+++ b/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts
@@ -7,7 +7,26 @@ export async function load({ cookies, params, parent }) {
const { user } = await parent();
const rates = await getRates();
- if (user?.username === username) redirect(307, `/${username}/receive`);
+ if (user?.username === username) {
+ // Allow vault→custodial self-payment: redirect to send/ark with server address
+ const aid = cookies.get("aid");
+ if (aid && aid !== user.id) {
+ const account = await get(`/account/${aid}`, auth(cookies));
+ if (account?.type === "ark") {
+ const serverAddr = await get("/ark/address");
+ let [amt, cur] = params.amount.split("/");
+ if (amt) {
+ if (cur) {
+ const r = rates[cur.toUpperCase()];
+ if (r) amt = String(Math.round((Number(amt) * sats) / r));
+ }
+ redirect(307, `/send/ark/${serverAddr}/${amt}`);
+ }
+ redirect(307, `/send/ark/${serverAddr}`);
+ }
+ }
+ redirect(307, `/${username}/receive`);
+ }
const subject = await get(`/users/${username}`);
let [amount, currency] = params.amount.split("/");
diff --git a/src/routes/(app)/payment/[id]/+page.svelte b/src/routes/(app)/payment/[id]/+page.svelte
index 84a728a0..af729233 100644
--- a/src/routes/(app)/payment/[id]/+page.svelte
+++ b/src/routes/(app)/payment/[id]/+page.svelte
@@ -144,7 +144,7 @@
<div>
<span class="text-lg text-secondary">{$t("payments.date")}</span>
<div>
- {format(new Date(created), "MMM d, yyyy", { locale })}{format(new Date(created), "h:mmaaa", { locale })}
+ {format(new Date(created), "MMM d, yyyy", { locale })} {format(new Date(created), "h:mmaaa", { locale })}
</div>
</div>
diff --git a/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.server.ts b/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.server.ts
index 10a6a737..9fa754d6 100644
--- a/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.server.ts
+++ b/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.server.ts
@@ -26,12 +26,18 @@ export async function load({ params: { address, amount }, cookies, parent }) {
}
const rate = rates[user.currency];
+ const serverArkAddress = account.type === "ark" ? await get("/ark/address") : null;
+
+ // Check if destination is the user's own vault address (custodial ark invoice)
+ const isCustodialForward = account.type === "ark" && address === account.arkAddress;
return {
account,
amount,
address,
rate,
+ serverArkAddress,
+ isCustodialForward,
};
}
diff --git a/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte b/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte
index 02f1c4fb..744886ae 100644
--- a/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte
+++ b/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.svelte
@@ -2,12 +2,12 @@
import { t } from "$lib/translations";
import { enhance } from "$app/forms";
import Spinner from "$comp/Spinner.svelte";
- import { focus, s, post } from "$lib/utils";
+ import { focus, loc, s, post } from "$lib/utils";
import { pin } from "$lib/store";
import { goto, invalidate } from "$app/navigation";
import { rate } from "$lib/store";
import { applyAction } from "$app/forms";
- import { sendArk } from "$lib/ark";
+ import { sendArk, sendArkViaForward } from "$lib/ark";
import Amount from "$comp/Amount.svelte";
@@ -23,13 +23,24 @@
submitting = true;
error = "";
try {
- const txid = await sendArk(address, parseInt(amount));
+ let p;
- const p = await post("/post/ark/vault-send", {
- hash: txid,
- amount: parseInt(amount),
- aid: account.id,
- });
+ if (data.serverArkAddress && (address === data.serverArkAddress || data.isCustodialForward)) {
+ // Vault→custodial: send to server and credit custodial account
+ p = await sendArkViaForward({
+ serverArkAddress: data.serverArkAddress,
+ amount: parseInt(amount),
+ aid: account.id,
+ user: data.user,
+ });
+ } else {
+ const txid = await sendArk(address, parseInt(amount));
+ p = await post("/post/ark/vault-send", {
+ hash: txid,
+ amount: parseInt(amount),
+ aid: account.id,
+ });
+ }
goto(`/sent/${p.id}`, { invalidateAll: true });
} catch (e: any) {
@@ -65,6 +76,7 @@
let balance = $derived(data.user.balance);
let currency = $derived(data.user.currency);
+ let locale = $derived(loc(data.user));
let submitting = $state(false),
submit = $state();
</script>
@@ -81,7 +93,7 @@
{#if !error}
<div class="text-xl text-secondary break-all">{address}</div>
- <Amount {amount} rate={$rate} {currency} />
+ <Amount {amount} rate={$rate} {currency} {locale} />
<form method="POST" use:enhance={handler}>
<input name="pin" value={$pin} type="hidden" />
diff --git a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
index 967c47af..4cb22155 100644
--- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
+++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
@@ -6,7 +6,7 @@
import Spinner from "$comp/Spinner.svelte";
import WalletPass from "$comp/WalletPass.svelte";
import { page } from "$app/stores";
- import { toFiat, f, focus, s, sat, closest, network } from "$lib/utils";
+ import { toFiat, f, focus, loc, s, sat, closest, network } from "$lib/utils";
import { pin } from "$lib/store";
import { goto, invalidate } from "$app/navigation";
import { rate } from "$lib/store";
@@ -198,6 +198,7 @@
let balance = $derived(data.user.balance);
let currency = $derived(data.user.currency);
+ let locale = $derived(loc(data.user));
let submitting = $state(),
submit: any = $state(),
showSettings = $state();
@@ -231,7 +232,7 @@
<div class="text-xl text-secondary break-all">{address}</div>
{/if}
- <Amount amount={subtract ? amount - fee - ourfee : amount} rate={$rate} {currency} />
+ <Amount amount={subtract ? amount - fee - ourfee : amount} rate={$rate} {currency} {locale} />
{#if fees}
<div class="text-center">
@@ -245,7 +246,7 @@
</option>
{/each}
</select>
- <Amount amount={fee} rate={$rate} {currency} />
+ <Amount amount={fee} rate={$rate} {currency} {locale} />
</div>
</div>
@@ -253,7 +254,7 @@
<div class="text-center">
<h2 class="text-secondary text-lg">{$t("payments.platformFee")}</h2>
- <Amount amount={ourfee} rate={$rate} {currency} />
+ <Amount amount={ourfee} rate={$rate} {currency} {locale} />
</div>
{/if}
{/if}
diff --git a/test/e2e/ark-receive-from-server-wallet.spec.ts b/test/e2e/ark-receive-from-server-wallet.spec.ts
index bb7acb7b..3ad7205f 100644
--- a/test/e2e/ark-receive-from-server-wallet.spec.ts
+++ b/test/e2e/ark-receive-from-server-wallet.spec.ts
@@ -1,148 +1,36 @@
import { expect, test } from "@playwright/test";
+import {
+ bobUsername,
+ bobPassword,
+ testSecret,
+ arkWalletPassword,
+ login,
+ ensureArkAccount,
+ createArkInvoiceViaUI,
+ sendArkFromTestEndpoint,
+ waitForInvoicePaid,
+} from "./helpers";
-const username = process.env.E2E_USERNAME || "bob";
-const password = process.env.E2E_PASSWORD || "pw";
-const testSecret = process.env.E2E_TEST_SECRET;
-const apiBaseUrl = process.env.E2E_API_BASE_URL || "http://localhost:3119";
-const pauseAtEnd = process.env.E2E_PAUSE_AT_END === "true";
-
-test("bob receives 1000 sats in ark vault after server wallet send", async ({ page }) => {
+test("bob receives 100 sats in ark vault after server wallet send", async ({ page }) => {
test.setTimeout(90_000);
test.skip(!testSecret, "E2E_TEST_SECRET is required for /test/ark/send");
- await page.goto("/login");
- await page.getByTestId("login-username").fill(username);
- await page.locator('input[name="password"]').first().fill(password);
- await page.getByTestId("login-submit").click();
-
- await expect(page).toHaveURL(new RegExp(`/${username}(?:[/?#]|$)`));
-
- const arkAccountCard = page.locator('[data-testid="account-card"][data-account-type="ark"]');
- const arkAccountCount = await arkAccountCard.count();
- expect(arkAccountCount).toBeGreaterThan(0);
-
- let invoiceOpened = false;
-
- const walletPassInput = page.getByTestId("walletpass-input");
-
- for (let i = 0; i < arkAccountCount; i++) {
- const card = arkAccountCard.nth(i);
- await card.scrollIntoViewIfNeeded();
- const accountHref = await card.locator('a[href^="/account/"]').first().getAttribute("href");
- const accountId = accountHref?.split("/account/")[1];
-
- await card.locator('a[href="/invoice"]').first().click();
-
- // Ark vault actions can require wallet password to derive/unlock key.
- if (
- await walletPassInput
- .first()
- .isVisible({ timeout: 2000 })
- .catch(() => false)
- ) {
- await walletPassInput.fill(password);
- await page.getByTestId("walletpass-submit").click();
- }
-
- invoiceOpened = await page
- .waitForURL(/\/invoice\/[^/?#]+/, { timeout: 5000 })
- .then(() => true)
- .catch(() => false);
-
- // Fallback for brittle click bubbling in account cards:
- // set the selected aid cookie and navigate to invoice route directly.
- if (!invoiceOpened && accountId) {
- await page.evaluate((aid) => {
- document.cookie = `aid=${aid}; path=/; max-age=86400`;
- }, accountId);
- await page.goto("/invoice");
- invoiceOpened = await page
- .waitForURL(/\/invoice\/[^/?#]+/, { timeout: 5000 })
- .then(() => true)
- .catch(() => false);
- }
-
- if (invoiceOpened) break;
-
- if (
- await page
- .getByTestId("walletpass-cancel")
- .isVisible()
- .catch(() => false)
- ) {
- await page.getByTestId("walletpass-cancel").click();
- }
- }
-
- expect(invoiceOpened).toBeTruthy();
- await expect(page).toHaveURL(/\/invoice\/[^/?#]+/);
-
- const invoiceText = (await page.getByTestId("invoice-text").first().innerText()).trim();
- const addressMatch = invoiceText.match(/t?ark1[a-z0-9]+/i);
- expect(addressMatch).toBeTruthy();
- const address = addressMatch![0];
-
- const invoiceId = page.url().split("/invoice/")[1]?.split(/[/?#]/)[0];
- expect(invoiceId).toBeTruthy();
-
- const sendResult = await page.evaluate(
- async ({ address, testSecret, invoiceId }) => {
- const res = await fetch("/test/ark/send", {
- method: "POST",
- headers: {
- "content-type": "application/json",
- "x-test-secret": testSecret,
- },
- body: JSON.stringify({ address, amount: 1000, iid: invoiceId }),
- });
- const text = await res.text();
- return { ok: res.ok, status: res.status, text };
- },
- { address, testSecret: testSecret!, invoiceId },
- );
- expect(
- sendResult.ok,
- `/test/ark/send failed (${sendResult.status}): ${sendResult.text}`,
- ).toBeTruthy();
-
- let paid = false;
- let status: any;
-
- for (let i = 0; i < 30; i++) {
- const statusResponse = await page.request.get(`${apiBaseUrl}/invoice/${invoiceId}`);
- if (statusResponse.ok()) {
- status = await statusResponse.json();
- if ((status?.received || 0) >= 1000 || (status?.pending || 0) >= 1000) {
- paid = true;
- break;
- }
- }
- await page.waitForTimeout(1000);
- }
+ await login(page, bobUsername, bobPassword);
+ await ensureArkAccount(page, arkWalletPassword);
- expect(
- paid,
- `ark payment did not appear in invoice status: ${JSON.stringify(status)}`,
- ).toBeTruthy();
+ // Navigate back to dashboard after account setup
+ await page.goto(`/${bobUsername}`);
+ await page.waitForLoadState("networkidle");
- const paidRouteReached = await page
- .waitForURL(new RegExp(`/invoice/${invoiceId}/paid(?:[/?#]|$)`), {
- timeout: 20_000,
- })
- .then(() => true)
- .catch(() => false);
+ const { invoiceId, address } = await createArkInvoiceViaUI(page, arkWalletPassword);
+ console.log(`[e2e] Bob ark address: ${address}, invoice: ${invoiceId}`);
- if (pauseAtEnd) {
- // Keep browser open for manual inspection at end of flow.
- await page.pause();
- }
+ await sendArkFromTestEndpoint(page, address, 100, invoiceId);
- expect(
- paidRouteReached,
- `expected redirect to /invoice/${invoiceId}/paid, current URL: ${page.url()}`,
- ).toBeTruthy();
+ const status = await waitForInvoicePaid(page, invoiceId, 100);
+ expect(status.received).toBeGreaterThanOrEqual(100);
console.log(
- `[e2e] ark invoice ${invoiceId} funded with 1000 sats (received=${status?.received || 0}, pending=${status?.pending || 0})`,
+ `[e2e] ark invoice ${invoiceId} funded with 100 sats (received=${status?.received || 0})`,
);
});
diff --git a/test/e2e/ark-send-custodial.spec.ts b/test/e2e/ark-send-custodial.spec.ts
index 5806ca72..ab5d2141 100644
--- a/test/e2e/ark-send-custodial.spec.ts
+++ b/test/e2e/ark-send-custodial.spec.ts
@@ -5,8 +5,9 @@ import {
bobUsername,
bobPassword,
testSecret,
- apiBaseUrl,
+ arkWalletPassword,
loginNewContext,
+ ensureArkAccount,
createArkInvoiceViaUI,
pasteAndSend,
fillNumpadAmount,
@@ -17,14 +18,18 @@ test("custodial sends to external ark address", async ({ browser }) => {
test.setTimeout(120_000);
test.skip(!testSecret, "E2E_TEST_SECRET is required for ark tests");
- // --- Alice: log in and get her ark vault address ---
+ // --- Alice: log in, ensure ark account, get her ark vault address ---
const { context: aliceContext, page: alicePage } = await loginNewContext(
browser,
aliceUsername,
alicePassword,
);
- const { address: arkAddress } = await createArkInvoiceViaUI(alicePage, alicePassword);
+ await ensureArkAccount(alicePage, arkWalletPassword);
+ await alicePage.goto(`/${aliceUsername}`);
+ await alicePage.waitForLoadState("networkidle");
+
+ const { address: arkAddress } = await createArkInvoiceViaUI(alicePage, arkWalletPassword);
console.log(`[e2e] Alice ark address: ${arkAddress}`);
await aliceContext.close();
@@ -45,7 +50,7 @@ test("custodial sends to external ark address", async ({ browser }) => {
expect(bobUrl).toContain("/send/ark/");
// Enter amount on numpad
- await fillNumpadAmount(bobPage, 1000);
+ await fillNumpadAmount(bobPage, 100);
// Now on the confirmation page with Send button
await bobPage.waitForURL(/\/send\/ark\/[^/]+\/\d+/, { timeout: 10_000 });
diff --git a/test/e2e/ark-vault-to-custodial.spec.ts b/test/e2e/ark-vault-to-custodial.spec.ts
new file mode 100644
index 00000000..36754696
--- /dev/null
+++ b/test/e2e/ark-vault-to-custodial.spec.ts
@@ -0,0 +1,63 @@
+import { expect, test } from "@playwright/test";
+import {
+ bobUsername,
+ bobPassword,
+ testSecret,
+ arkWalletPassword,
+ login,
+ ensureArkAccount,
+ createArkInvoiceViaUI,
+ sendArkFromTestEndpoint,
+ waitForInvoicePaid,
+ fillNumpadAmount,
+ waitForSentRedirect,
+} from "./helpers";
+
+test("vault sends to own custodial account", async ({ page }) => {
+ test.slow();
+ test.setTimeout(180_000);
+ test.skip(!testSecret, "E2E_TEST_SECRET is required for ark tests");
+
+ // --- Login and ensure ark account ---
+ await login(page, bobUsername, bobPassword);
+ await ensureArkAccount(page, arkWalletPassword);
+
+ // --- Fund vault via /test/ark/send ---
+ await page.goto(`/${bobUsername}`);
+ await page.waitForLoadState("networkidle");
+
+ const { invoiceId, address } = await createArkInvoiceViaUI(page, arkWalletPassword);
+ console.log(`[e2e] Bob vault address: ${address}, invoice: ${invoiceId}`);
+
+ await sendArkFromTestEndpoint(page, address, 100, invoiceId);
+ await waitForInvoicePaid(page, invoiceId, 100);
+ console.log("[e2e] Vault funded with 100 sats");
+
+ // --- Send vault → custodial (self-send to own username) ---
+ await page.goto(`/pay/${bobUsername}`);
+ await page.waitForLoadState("networkidle");
+
+ // Should redirect to /send/ark/{serverAddr}/{amount} or similar vault→custodial path
+ // The UI detects vault→custodial and routes accordingly
+ await page.waitForURL(/\/send\//, { timeout: 15_000 });
+ console.log(`[e2e] Bob on send page: ${page.url()}`);
+
+ // Enter amount
+ await fillNumpadAmount(page, 100);
+
+ // Handle WalletPass modal if it appears
+ const walletPassInput = page.getByTestId("walletpass-input");
+ if (await walletPassInput.isVisible({ timeout: 3_000 }).catch(() => false)) {
+ await walletPassInput.fill(arkWalletPassword);
+ await page.getByTestId("walletpass-submit").click();
+ }
+
+ // Confirm send
+ const sendButton = page.locator('button[type="submit"]');
+ if (await sendButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
+ await sendButton.click();
+ }
+
+ await waitForSentRedirect(page, 60_000);
+ console.log(`[e2e] Vault→custodial payment sent: ${page.url()}`);
+});
diff --git a/test/e2e/custodial-bitcoin-send.spec.ts b/test/e2e/custodial-bitcoin-send.spec.ts
index c8543dac..23428abc 100644
--- a/test/e2e/custodial-bitcoin-send.spec.ts
+++ b/test/e2e/custodial-bitcoin-send.spec.ts
@@ -52,17 +52,20 @@ test("custodial-to-custodial via bitcoin address sends internally and notifies r
const amountInput = bobPage.locator('[aria-label="Amount input"]');
await expect(amountInput).toBeVisible({ timeout: 5_000 });
- // Switch to sats mode if in fiat mode (click swap if lightning icon is not visible next to input)
+ // Ensure sats mode: swap button shows lightning icon when in fiat mode
const swapButton = bobPage.locator('[aria-label="Swap currency display"]');
- const swapText = await swapButton.innerText();
- if (swapText.includes("⚡") || swapText.includes("lightning")) {
- await swapButton.click();
+ if (await swapButton.isVisible({ timeout: 2_000 }).catch(() => false)) {
+ const hasLightning = await swapButton.locator('iconify-icon[icon="ph:lightning-fill"]').count();
+ if (hasLightning > 0) {
+ await swapButton.click();
+ await bobPage.waitForTimeout(300);
+ }
}
// Type amount using keyboard
await amountInput.click();
await bobPage.keyboard.press("Control+a");
- await bobPage.keyboard.type("1000");
+ await bobPage.keyboard.type("100");
// Click "Next" to set the amount
const nextButton = bobPage.locator('button[type="button"].btn-accent');
@@ -75,7 +78,7 @@ test("custodial-to-custodial via bitcoin address sends internally and notifies r
await sendButton.click();
// Bob should be redirected to /sent/ after successful payment
- await bobPage.waitForURL(/\/sent\//, { timeout: 15_000 });
+ await bobPage.waitForURL(/\/sent\//, { timeout: 30_000 });
console.log(`[e2e] Bob payment sent: ${bobPage.url()}`);
// --- Verify Alice receives notification and is redirected to /paid ---
diff --git a/test/e2e/custodial-send-lightning.spec.ts b/test/e2e/custodial-send-lightning.spec.ts
index 885da1ae..62bf9415 100644
--- a/test/e2e/custodial-send-lightning.spec.ts
+++ b/test/e2e/custodial-send-lightning.spec.ts
@@ -30,11 +30,11 @@ test("custodial-to-custodial via lightning bolt11 sends internally and notifies
});
const { token } = await tokenRes.json();
- // Create lightning invoice with amount 1000 sats
+ // Create lightning invoice with amount 100 sats
const invoiceRes = await alicePage.request.post(`${apiBaseUrl}/invoice`, {
headers: { authorization: `Bearer ${token}` },
data: {
- invoice: { type: "lightning", amount: 1000 },
+ invoice: { type: "lightning", amount: 100 },
user: { username: aliceUsername },
},
});
diff --git a/test/e2e/custodial-send-username.spec.ts b/test/e2e/custodial-send-username.spec.ts
index f8b4a002..cbf2ce74 100644
--- a/test/e2e/custodial-send-username.spec.ts
+++ b/test/e2e/custodial-send-username.spec.ts
@@ -39,7 +39,7 @@ test("custodial-to-custodial via username (/pay/) sends and notifies recipient",
console.log(`[e2e] Alice balance before: ${aliceBalanceBefore}`);
- // --- Bob: navigate to /pay/alice and send 1000 sats ---
+ // --- Bob: navigate to /pay/alice and send 100 sats ---
const { context: bobContext, page: bobPage } = await loginNewContext(
browser,
bobUsername,
@@ -50,7 +50,7 @@ test("custodial-to-custodial via username (/pay/) sends and notifies recipient",
await bobPage.waitForURL(new RegExp(`/pay/${aliceUsername}`));
// Numpad should be visible — type amount and click Next (submits form)
- await fillNumpadAmount(bobPage, 1000);
+ await fillNumpadAmount(bobPage, 100);
// The form creates an invoice. If Alice has tip prompt, we go to /invoice/{id}/tip
// Otherwise we go to /invoice/{id} which loads the send page
@@ -102,7 +102,7 @@ test("custodial-to-custodial via username (/pay/) sends and notifies recipient",
})();
console.log(`[e2e] Alice balance after: ${aliceBalanceAfter}`);
- expect(aliceBalanceAfter).toBeGreaterThanOrEqual(aliceBalanceBefore + 1000);
+ expect(aliceBalanceAfter).toBeGreaterThanOrEqual(aliceBalanceBefore + 100);
await aliceContext.close();
await bobContext.close();
diff --git a/test/e2e/helpers.ts b/test/e2e/helpers.ts
index 08d19810..b45c2919 100644
--- a/test/e2e/helpers.ts
+++ b/test/e2e/helpers.ts
@@ -3,6 +3,7 @@ import type { Browser, Page } from "@playwright/test";
import { expect } from "@playwright/test";
// --- Environment ---
+export const arkWalletPassword = process.env.E2E_ARK_WALLET_PASSWORD || "testpw123";
export const aliceUsername = process.env.E2E_ALICE_USERNAME || "alice";
export const alicePassword = process.env.E2E_ALICE_PASSWORD || "pw";
export const bobUsername = process.env.E2E_BOB_USERNAME || "bob";
@@ -218,6 +219,17 @@ export async function pasteAndSend(page: Page, text: string) {
export async function fillNumpadAmount(page: Page, amount: number) {
const amountInput = page.locator('[aria-label="Amount input"]');
await expect(amountInput).toBeVisible({ timeout: 5_000 });
+
+ // Ensure sats mode: swap button shows lightning icon when in fiat mode
+ const swapButton = page.locator('[aria-label="Swap currency display"]');
+ if (await swapButton.isVisible({ timeout: 2_000 }).catch(() => false)) {
+ const hasLightning = await swapButton.locator('iconify-icon[icon="ph:lightning-fill"]').count();
+ if (hasLightning > 0) {
+ await swapButton.click();
+ await page.waitForTimeout(300);
+ }
+ }
+
await amountInput.click();
await page.keyboard.press("Control+a");
await page.keyboard.type(String(amount));
@@ -311,7 +323,7 @@ export async function sendArkFromTestEndpoint(
async ({ address, testSecret, amount, iid }) => {
const body: any = { address, amount };
if (iid) body.iid = iid;
- const res = await fetch("/test/ark/send", {
+ const res = await fetch("/api/test/ark/send", {
method: "POST",
headers: {
"content-type": "application/json",
@@ -338,3 +350,48 @@ export async function setActiveAccount(page: Page, accountId: string) {
document.cookie = `aid=${aid}; path=/; max-age=86400`;
}, accountId);
}
+
+export async function ensureArkAccount(page: Page, password: string) {
+ await page.goto("/account/ark");
+ await page.waitForLoadState("networkidle");
+
+ // If redirected to dashboard, user already has an ark account
+ const url = page.url();
+ if (!url.includes("/account/ark")) {
+ console.log("[e2e] Ark account already exists, skipping creation");
+ return;
+ }
+
+ // Check if we're on the creation page (has key generation)
+ const revealButton = page.locator("button.btn-warning");
+ const hasReveal = await revealButton.isVisible({ timeout: 30_000 }).catch(() => false);
+ if (!hasReveal) {
+ console.log("[e2e] Ark account page loaded but no reveal button — account may already exist");
+ return;
+ }
+
+ // Click "Reveal Backup Key"
+ await revealButton.click();
+
+ // Click "I've Backed It Up"
+ const backedUpButton = page.locator("button.btn-accent");
+ await expect(backedUpButton).toBeVisible({ timeout: 5_000 });
+ await backedUpButton.click();
+
+ // Fill password and confirm password
+ const passwordInputs = page.locator('input[type="password"]');
+ await expect(passwordInputs.first()).toBeVisible({ timeout: 5_000 });
+ const count = await passwordInputs.count();
+ for (let i = 0; i < count; i++) {
+ await passwordInputs.nth(i).fill(password);
+ }
+
+ // Click "Create Wallet" submit
+ const submitButton = page.locator('button[type="submit"]');
+ await expect(submitButton).toBeVisible({ timeout: 5_000 });
+ await submitButton.click();
+
+ // Wait for redirect to dashboard (account created)
+ await page.waitForURL((u) => !u.pathname.includes("/account/ark"), { timeout: 30_000 });
+ console.log("[e2e] Ark account created successfully");
+}
diff --git a/test/e2e/login-balance.spec.ts b/test/e2e/login-balance.spec.ts
index 58e4c2dd..0c982e29 100644
--- a/test/e2e/login-balance.spec.ts
+++ b/test/e2e/login-balance.spec.ts
@@ -1,16 +1,11 @@
import { expect, test } from "@playwright/test";
+import { login, bobUsername, bobPassword } from "./helpers";
-const username = process.env.E2E_USERNAME || "bob";
-const password = process.env.E2E_PASSWORD || "pw";
+const username = process.env.E2E_USERNAME || bobUsername;
+const password = process.env.E2E_PASSWORD || bobPassword;
test("login and report account balance", async ({ page }) => {
- await page.goto("/login");
-
- await page.getByTestId("login-username").fill(username);
- await page.locator('input[name="password"]').first().fill(password);
- await page.getByTestId("login-submit").click();
-
- await expect(page).toHaveURL(new RegExp(`/${username}(?:[/?#]|$)`));
+ await login(page, username, password);
const balances = page.getByTestId("balance-value");
await expect(balances.first()).toBeVisible();
diff --git a/test/e2e/run-staging.sh b/test/e2e/run-staging.sh
new file mode 100755
index 00000000..0f1aaebd
--- /dev/null
+++ b/test/e2e/run-staging.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# --- Staging E2E test runner ---
+# Runs tests against staging.coinos.io that don't require docker/local infra.
+#
+# Usage:
+# ./test/e2e/run-staging.sh # headless
+# ./test/e2e/run-staging.sh headed # visible browser
+# ./test/e2e/run-staging.sh debug # Playwright inspector
+# ./test/e2e/run-staging.sh <spec> # run a specific test file
+
+export E2E_BASE_URL="${E2E_BASE_URL:-https://staging.coinos.io}"
+export E2E_API_BASE_URL="${E2E_API_BASE_URL:-https://staging.coinos.io/api}"
+export E2E_SKIP_WEBSERVER=1
+
+# Test accounts — create these on staging first
+export E2E_ALICE_USERNAME="${E2E_ALICE_USERNAME:-testalice}"
+export E2E_ALICE_PASSWORD="${E2E_ALICE_PASSWORD:-testpw123}"
+export E2E_BOB_USERNAME="${E2E_BOB_USERNAME:-testbob}"
+export E2E_BOB_PASSWORD="${E2E_BOB_PASSWORD:-testpw123}"
+
+# login-balance.spec.ts uses E2E_USERNAME/E2E_PASSWORD
+export E2E_USERNAME="${E2E_USERNAME:-$E2E_BOB_USERNAME}"
+export E2E_PASSWORD="${E2E_PASSWORD:-$E2E_BOB_PASSWORD}"
+
+# Test secret for ark endpoints (must match staging server config)
+export E2E_TEST_SECRET="${E2E_TEST_SECRET:-}"
+
+# Ark wallet password for vault creation/unlock
+export E2E_ARK_WALLET_PASSWORD="${E2E_ARK_WALLET_PASSWORD:-testpw123}"
+
+# Tests that work against staging (no docker exec / local node deps)
+STAGING_SPECS=(
+ "test/e2e/login-balance.spec.ts"
+ "test/e2e/custodial-send-username.spec.ts"
+ "test/e2e/custodial-send-lightning.spec.ts"
+ "test/e2e/custodial-bitcoin-send.spec.ts"
+ "test/e2e/ark-receive-from-server-wallet.spec.ts"
+ "test/e2e/ark-send-custodial.spec.ts"
+ # vault/ark tests — client-side SDK signing is unreliable
+ # "test/e2e/ark-vault-to-custodial.spec.ts"
+ # "test/e2e/ark-custodial-receive.spec.ts"
+ # "test/e2e/vault-send-bitcoin.spec.ts"
+ # "test/e2e/vault-send-to-custodial-btc.spec.ts"
+)
+
+MODE="${1:-}"
+
+case "$MODE" in
+ headed)
+ npx playwright test "${STAGING_SPECS[@]}" --project=chromium --headed --workers=1
+ ;;
+ debug)
+ export PWDEBUG=1
+ npx playwright test "${STAGING_SPECS[@]}" --project=chromium --headed --workers=1
+ ;;
+ test/e2e/*.spec.ts)
+ # Run a specific test file
+ npx playwright test "$MODE" --project=chromium --workers=1
+ ;;
+ *)
+ npx playwright test "${STAGING_SPECS[@]}" --project=chromium --workers=1
+ ;;
+esac