aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-11 10:45:39 -0800
committerAdam Soltys <[email protected]>2026-02-11 15:34:39 -0800
commite8e3fe413f02b9520bd3539f351c344134148c58 (patch)
treebf2508e6228c060ffc6df6efc09a39e1a3732234
parent2cc4969678f4d073ebddec0e7e9f2aac707fb761 (diff)
downloadcoinos-ui-e8e3fe413f02b9520bd3539f351c344134148c58.tar.gz
coinos-ui-e8e3fe413f02b9520bd3539f351c344134148c58.zip
ark payment test
-rw-r--r--.env.sample6
-rw-r--r--package.json4
-rw-r--r--playwright.config.ts2
-rw-r--r--src/components/Account.svelte4
-rw-r--r--src/components/InvoiceData.svelte1
-rw-r--r--src/components/WalletPass.svelte4
-rw-r--r--src/routes/test/ark/send/+server.ts76
-rw-r--r--test-results/.last-run.json4
-rw-r--r--test/e2e/ark-receive-from-server-wallet.spec.ts149
9 files changed, 249 insertions, 1 deletions
diff --git a/.env.sample b/.env.sample
index 72dfb652..d4c24f77 100644
--- a/.env.sample
+++ b/.env.sample
@@ -9,3 +9,9 @@ PUBLIC_COINOS_PUBKEY=ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a
PUBLIC_COINOS_RELAY=wss://relay.coinos.io
PUBLIC_COINOS_NETWORK=regtest
PUBLIC_RECAPTCHA_SITE_KEY=
+
+# Test-only endpoints (never enable in production)
+ENABLE_E2E_TEST_ENDPOINTS=false
+E2E_TEST_SECRET=
+ARK_SERVER_WALLET_PRIVATE_KEY=
+ARK_SERVER_URL=http://localhost:7070
diff --git a/package.json b/package.json
index 49da557a..c7d3999c 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,10 @@
"docker": "docker build . -t coinos-ui",
"preview": "vite preview",
"test:e2e": "playwright test",
+ "test:e2e:ark": "ENABLE_E2E_TEST_ENDPOINTS=true E2E_TEST_SECRET=local-e2e-secret ARK_SERVER_WALLET_PRIVATE_KEY=0ebe9a690651e9e08d7c2f4db106d8ec264725241fdb34c57aedb26155b0e027 playwright test test/e2e/ark-receive-from-server-wallet.spec.ts --project=chromium --workers=1",
+ "test:e2e:ark:headed": "ENABLE_E2E_TEST_ENDPOINTS=true E2E_TEST_SECRET=local-e2e-secret ARK_SERVER_WALLET_PRIVATE_KEY=0ebe9a690651e9e08d7c2f4db106d8ec264725241fdb34c57aedb26155b0e027 playwright test test/e2e/ark-receive-from-server-wallet.spec.ts --project=chromium --headed --workers=1",
+ "test:e2e:ark:watch": "ENABLE_E2E_TEST_ENDPOINTS=true E2E_TEST_SECRET=local-e2e-secret ARK_SERVER_WALLET_PRIVATE_KEY=0ebe9a690651e9e08d7c2f4db106d8ec264725241fdb34c57aedb26155b0e027 E2E_SLOWMO=350 E2E_PAUSE_AT_END=true playwright test test/e2e/ark-receive-from-server-wallet.spec.ts --project=chromium --headed --workers=1 --retries=0 --timeout=120000 --global-timeout=180000",
+ "test:e2e:ark:debug": "ENABLE_E2E_TEST_ENDPOINTS=true E2E_TEST_SECRET=local-e2e-secret ARK_SERVER_WALLET_PRIVATE_KEY=0ebe9a690651e9e08d7c2f4db106d8ec264725241fdb34c57aedb26155b0e027 PWDEBUG=1 playwright test test/e2e/ark-receive-from-server-wallet.spec.ts --project=chromium --headed --workers=1",
"lint": "prettier --check .",
"format": "prettier --write ."
},
diff --git a/playwright.config.ts b/playwright.config.ts
index f245b4e4..3b1be109 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -3,6 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
const port = Number(process.env.E2E_PORT || 4173);
const host = process.env.E2E_HOST || "127.0.0.1";
const baseURL = process.env.E2E_BASE_URL || `http://${host}:${port}`;
+const slowMo = Number(process.env.E2E_SLOWMO || 0);
export default defineConfig({
testDir: "./test/e2e",
@@ -18,6 +19,7 @@ export default defineConfig({
use: {
baseURL,
trace: "on-first-retry",
+ launchOptions: slowMo > 0 ? { slowMo } : undefined,
},
projects: [
{
diff --git a/src/components/Account.svelte b/src/components/Account.svelte
index 711c5ba7..bb9aec22 100644
--- a/src/components/Account.svelte
+++ b/src/components/Account.svelte
@@ -142,6 +142,8 @@
<div
class="shadow-lg space-y-4 p-4 cursor-pointer hover:bg-base-200"
aria-label="Payments"
+ data-testid="account-card"
+ data-account-type={accountType}
role="button"
tabindex="0"
onclick={goPayments}
@@ -180,7 +182,7 @@
class="contents"
onclick={(e) => setAccount(e, "/invoice")}
>
- <button class="btn !w-auto flex-1 !bg-base-300">
+ <button class="btn !w-auto flex-1 !bg-base-300" data-testid="account-receive">
<iconify-icon
noobserver
icon="ph:hand-coins-bold"
diff --git a/src/components/InvoiceData.svelte b/src/components/InvoiceData.svelte
index e0ec0f3e..5ba950d5 100644
--- a/src/components/InvoiceData.svelte
+++ b/src/components/InvoiceData.svelte
@@ -56,6 +56,7 @@
{#if !showQr || txt?.length <= 120}
<div
+ data-testid="invoice-text"
class="break-all text-center text-secondary text-xl flex gap-1 items-center justify-center"
>
{txt}
diff --git a/src/components/WalletPass.svelte b/src/components/WalletPass.svelte
index e0dcf961..11f6978a 100644
--- a/src/components/WalletPass.svelte
+++ b/src/components/WalletPass.svelte
@@ -37,6 +37,7 @@
{#if revealPassword}
<input
name="password"
+ data-testid="walletpass-input"
type="text"
required
bind:value={password}
@@ -46,6 +47,7 @@
{:else}
<input
name="password"
+ data-testid="walletpass-input"
type="password"
required
bind:value={password}
@@ -83,6 +85,7 @@
<div class="w-full flex justify-center gap-2">
<button
type="button"
+ data-testid="walletpass-cancel"
class="border rounded-2xl font-semibold mx-auto py-5 px-6 hover:opacity-80 mx-auto grow"
onclick={cancel}
onkeydown={cancel}
@@ -91,6 +94,7 @@
</button>
<button
type="submit"
+ data-testid="walletpass-submit"
class="border rounded-2xl font-semibold mx-auto py-5 px-6 hover:opacity-80 mx-auto grow"
>
<div class="my-auto">Submit</div>
diff --git a/src/routes/test/ark/send/+server.ts b/src/routes/test/ark/send/+server.ts
new file mode 100644
index 00000000..b077c793
--- /dev/null
+++ b/src/routes/test/ark/send/+server.ts
@@ -0,0 +1,76 @@
+import { env } from "$env/dynamic/private";
+import { PUBLIC_COINOS_URL } from "$env/static/public";
+import { error, json } from "@sveltejs/kit";
+
+let walletPromise: Promise<any> | undefined;
+
+const getWallet = async () => {
+ if (!walletPromise) {
+ const privateKey = env.ARK_SERVER_WALLET_PRIVATE_KEY;
+ const arkServerUrl = env.ARK_SERVER_URL || "http://localhost:7070";
+
+ if (!privateKey) {
+ throw new Error("ARK_SERVER_WALLET_PRIVATE_KEY is not configured");
+ }
+
+ const { SingleKey, Wallet } = await import("@arkade-os/sdk");
+ const identity = SingleKey.fromHex(privateKey);
+ walletPromise = Wallet.create({ identity, arkServerUrl });
+ }
+
+ return walletPromise;
+};
+
+export async function POST({ request, cookies, fetch }) {
+ // Never expose this in production.
+ if (env.NODE_ENV === "production") error(404, "Not found");
+
+ // Require explicit opt-in for local/integration test environments.
+ if (env.ENABLE_E2E_TEST_ENDPOINTS !== "true") {
+ error(404, "Not found");
+ }
+
+ const expectedSecret = env.E2E_TEST_SECRET;
+ const providedSecret = request.headers.get("x-test-secret");
+ if (!expectedSecret || providedSecret !== expectedSecret) {
+ error(401, "Unauthorized");
+ }
+
+ const body = await request.json();
+ const address = String(body?.address || "").trim();
+ const amount = Number(body?.amount || 1000);
+ const iid = typeof body?.iid === "string" ? body.iid : undefined;
+
+ if (!/^t?ark1[a-z0-9]+$/i.test(address)) {
+ error(400, "Invalid ark address");
+ }
+
+ if (!Number.isInteger(amount) || amount <= 0 || amount > 1_000_000) {
+ error(400, "Invalid amount");
+ }
+
+ const wallet = await getWallet();
+ const txid = await wallet.sendBitcoin({ address, amount });
+
+ // Optional: mark an Ark invoice as received right after send.
+ if (iid) {
+ const token = cookies.get("token");
+ if (!token) error(401, "Missing auth token cookie");
+
+ const response = await fetch(`${PUBLIC_COINOS_URL}/ark/receive`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ accept: "application/json",
+ authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ iid, amount, hash: txid }),
+ });
+
+ if (!response.ok) {
+ error(response.status, await response.text());
+ }
+ }
+
+ return json({ ok: true, txid, amount, address, iid: iid || null });
+}
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
new file mode 100644
index 00000000..cbcc1fba
--- /dev/null
+++ b/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+} \ No newline at end of file
diff --git a/test/e2e/ark-receive-from-server-wallet.spec.ts b/test/e2e/ark-receive-from-server-wallet.spec.ts
new file mode 100644
index 00000000..80e28fd8
--- /dev/null
+++ b/test/e2e/ark-receive-from-server-wallet.spec.ts
@@ -0,0 +1,149 @@
+import { expect, test } from "@playwright/test";
+
+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.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);
+ }
+
+ expect(
+ paid,
+ `ark payment did not appear in invoice status: ${JSON.stringify(status)}`,
+ ).toBeTruthy();
+
+ const paidRouteReached = await page
+ .waitForURL(new RegExp(`/invoice/${invoiceId}/paid(?:[/?#]|$)`), {
+ timeout: 20_000,
+ })
+ .then(() => true)
+ .catch(() => false);
+
+ if (pauseAtEnd) {
+ // Keep browser open for manual inspection at end of flow.
+ await page.pause();
+ }
+
+ expect(
+ paidRouteReached,
+ `expected redirect to /invoice/${invoiceId}/paid, current URL: ${page.url()}`,
+ ).toBeTruthy();
+
+ console.log(
+ `[e2e] ark invoice ${invoiceId} funded with 1000 sats (received=${status?.received || 0}, pending=${status?.pending || 0})`,
+ );
+});