diff options
| author | Adam Soltys <[email protected]> | 2026-02-26 13:36:28 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-26 13:36:28 -0800 |
| commit | a5faa64ba470f935ea3e7932feece3cd9fbadb90 (patch) | |
| tree | 79ec7d6cb2038b5920d5cca977a244fbb189923c | |
| parent | b137cdfca84cf68a2b8a620ba1c55337e88a0050 (diff) | |
| download | coinos-ui-a5faa64ba470f935ea3e7932feece3cd9fbadb90.tar.gz coinos-ui-a5faa64ba470f935ea3e7932feece3cd9fbadb90.zip | |
ark tests
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 |
