aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-14 15:46:09 -0800
committerAdam Soltys <[email protected]>2026-02-14 15:46:09 -0800
commit40fc5fe98c08357dff41539de48532d554aa73cb (patch)
tree289e6f1af7f256b1e8ddab75d74e272ead7baa2f
parentd242a387279119cfc6e191eff3b4e2228cbb5a4e (diff)
downloadcoinos-ui-40fc5fe98c08357dff41539de48532d554aa73cb.tar.gz
coinos-ui-40fc5fe98c08357dff41539de48532d554aa73cb.zip
custodial ark sends
-rw-r--r--src/components/Account.svelte2
-rw-r--r--src/components/InvoiceActions.svelte2
-rw-r--r--src/components/InvoiceTypes.svelte17
-rw-r--r--src/lib/ark.ts14
-rw-r--r--src/locales/en.json6
-rw-r--r--src/routes/(app)/+layout.svelte21
-rw-r--r--src/routes/(app)/[username]/(profile)/+page.svelte17
-rw-r--r--src/routes/(app)/account/[id]/+page.svelte26
-rw-r--r--src/routes/(app)/invoice/[id]/+page.svelte2
-rw-r--r--test/e2e/ark-custodial-receive.spec.ts160
10 files changed, 228 insertions, 39 deletions
diff --git a/src/components/Account.svelte b/src/components/Account.svelte
index b077c30b..adea5c70 100644
--- a/src/components/Account.svelte
+++ b/src/components/Account.svelte
@@ -146,7 +146,7 @@
</div>
{/if}
</div>
- <div class="flex items-start gap-4 shrink-0 text-gray-600">
+ <div class="flex items-start gap-4 shrink-0 text-black">
<div class="flex items-center justify-center w-8 h-8 leading-[0]">
{#if isArk}
<img src="/images/ark.png" class="w-8 h-8 rounded-full object-cover" alt="Ark" />
diff --git a/src/components/InvoiceActions.svelte b/src/components/InvoiceActions.svelte
index 53447fec..e7fb57e3 100644
--- a/src/components/InvoiceActions.svelte
+++ b/src/components/InvoiceActions.svelte
@@ -90,7 +90,7 @@
</button>
{/if}
- {#if !account?.seed && !(account?.pubkey && account?.fingerprint)}
+ {#if !account?.seed && !(account?.pubkey && account?.fingerprint) && account?.type !== "ark"}
<button
class="btn flex-nowrap !w-auto grow"
class:bg-base-300={type === types.bitcoin}
diff --git a/src/components/InvoiceTypes.svelte b/src/components/InvoiceTypes.svelte
index 5845f699..23735a49 100644
--- a/src/components/InvoiceTypes.svelte
+++ b/src/components/InvoiceTypes.svelte
@@ -71,7 +71,7 @@
}}
>
<div class="bg-black rounded-full w-8 h-8 items-center justify-center flex">
- <div class="m-auto">
+ <div class="m-auto mt-1">
<iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300 text-2xl"
></iconify-icon>
</div>
@@ -86,7 +86,7 @@
onclick={() => (activeOnly ? toggleType() : setType(types.bolt12))}
>
<div class="bg-black rounded-full w-8 h-8 items-center justify-center flex">
- <div class="m-auto">
+ <div class="m-auto mt-1">
<iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300 text-2xl"
></iconify-icon>
</div>
@@ -110,7 +110,7 @@
}}
>
<div class="bg-black rounded-full w-8 h-8 items-center justify-center flex">
- <div class="m-auto">
+ <div class="m-auto mt-1">
<iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300 text-2xl"
></iconify-icon>
</div>
@@ -142,6 +142,17 @@
<img src="/images/liquid.svg" class="w-8" alt="Liquid" />
<div class="my-auto text-lg">Liquid</div>
</button>
+
+ <button
+ class="btn flex-nowrap !w-auto grow"
+ class:bg-base-300={type === types.ark}
+ class:text-secondary={type === types.ark}
+ class:hidden={activeOnly && type !== types.ark}
+ onclick={() => (activeOnly ? toggleType() : setType("ark"))}
+ >
+ <img src="/images/ark.png" class="w-8 rounded-full" alt="Ark" />
+ <div class="my-auto text-lg">Ark</div>
+ </button>
</div>
<div></div>
diff --git a/src/lib/ark.ts b/src/lib/ark.ts
index b9902a5a..dcc42868 100644
--- a/src/lib/ark.ts
+++ b/src/lib/ark.ts
@@ -125,7 +125,19 @@ export const sendArk = async (address: string, amount: number) => {
arkSending = true;
try {
- const txid = await wallet.sendBitcoin({ address, amount });
+ let txid;
+ try {
+ txid = await wallet.sendBitcoin({ address, amount });
+ } catch (e: any) {
+ if (e?.message?.includes("VTXO_ALREADY_REGISTERED")) {
+ await wallet.settle();
+ const vtxos = await wallet.getVtxos({ spendableOnly: false });
+ if (vtxos?.length) addProcessedVtxos(vtxos.map(vtxoKey));
+ txid = await wallet.sendBitcoin({ address, amount });
+ } else {
+ throw e;
+ }
+ }
// Mark post-send VTXOs as processed so notifyIncomingFunds
// ignores change VTXOs from this send
diff --git a/src/locales/en.json b/src/locales/en.json
index 9f23dd47..2ed0d54d 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -53,7 +53,7 @@
"revealMnemonic": "Reveal seed phrase",
"regenerate": "Regenerate",
"deleteAccount": "Delete account",
- "addAccount": "Create a non-custodial account",
+ "addAccount": "Add account",
"create": "Create",
"import": "Import",
"accountSetup": "Account setup",
@@ -101,7 +101,7 @@
"dm": {
"chatHeader": "Chats",
"newChatHeader": "New Chat",
- "header": "Direct Messages",
+ "header": "Messages",
"searchPrompt": "username, nip-05 or pubkey",
"findByUsername": "Find by username",
"findByNIP05": "Find by NIP-05",
@@ -244,7 +244,7 @@
"light": "Light",
"about": "About",
"docs": "Docs",
- "dm": "Nostr Messages",
+ "dm": "Messages",
"messages": "Messages",
"funds": "Funds",
"map": "Map",
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index 2c96fc7c..2d252385 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
- import { getWallet, syncTransactions, settle, arkkey, arkaid } from "$lib/ark";
+ import { getWallet, syncTransactions, settle, arkkey, arkaid, sendArk } from "$lib/ark";
import { SvelteToast } from "@zerodevx/svelte-toast";
import { onDestroy, onMount } from "svelte";
import { close, connect, send, socket } from "$lib/socket";
@@ -22,7 +22,7 @@
import AppHeader from "$comp/AppHeader.svelte";
import Nostr from "$comp/Nostr.svelte";
import Password from "$comp/Password.svelte";
- import { s, f, toFiat, success, getCookie, warning } from "$lib/utils";
+ import { s, f, toFiat, success, getCookie, warning, post } from "$lib/utils";
import { t, locale, loading } from "$lib/translations";
import { goto, invalidate, afterNavigate, preloadData } from "$app/navigation";
@@ -52,6 +52,7 @@
});
let arkSyncing = false;
+ let arkForwarding = false;
let arkSync = async () => {
if (arkSyncing) return;
@@ -61,11 +62,23 @@
const aid = $arkaid || getCookie("aid") || user.id;
const result = await syncTransactions(aid);
- if (result?.received > 0) {
+ if (result?.forward && !arkForwarding) {
+ arkForwarding = true;
+ try {
+ const { serverArkAddress, amount, iid } = result.forward;
+ const txid = await sendArk(serverArkAddress, amount);
+ await post("/post/ark/vault-send", { hash: txid, amount, aid });
+ await post("/post/ark/receive", { amount, hash: txid, iid });
+ } catch (e) {
+ console.error("Ark custodial forward failed:", e);
+ } finally {
+ arkForwarding = false;
+ }
+ } else if (result?.received > 0) {
if ($fiat && $rateStore && user?.currency) {
success(`Received ${f(toFiat(result.received, $rateStore as number), user.currency)}!`);
} else {
- success(`Received ⚡️${s(result.received)}!`);
+ success(`Received ${s(result.received)}!`);
}
}
diff --git a/src/routes/(app)/[username]/(profile)/+page.svelte b/src/routes/(app)/[username]/(profile)/+page.svelte
index 966925d5..4885189b 100644
--- a/src/routes/(app)/[username]/(profile)/+page.svelte
+++ b/src/routes/(app)/[username]/(profile)/+page.svelte
@@ -64,17 +64,12 @@
{#each accounts as account}
<Account {user} {rates} {account} />
{/each}
- <div class="flex justify-end">
- <a href="/account/new" class="contents" aria-label={$t("accounts.addAccount")}>
- <button
- class="btn btn-circle btn-accent !w-14 !h-14 !rounded-full"
- aria-label={$t("accounts.addAccount")}
- title={$t("accounts.addAccount")}
- >
- <iconify-icon noobserver icon="ph:plus-bold" width="28"></iconify-icon>
- </button>
- </a>
- </div>
+ <a href="/account/new" class="contents">
+ <button class="btn btn-accent w-full">
+ <iconify-icon noobserver icon="ph:plus-bold" width="24"></iconify-icon>
+ {$t("accounts.addAccount")}
+ </button>
+ </a>
</div>
{#if locked}
diff --git a/src/routes/(app)/account/[id]/+page.svelte b/src/routes/(app)/account/[id]/+page.svelte
index fa9d744d..157dd096 100644
--- a/src/routes/(app)/account/[id]/+page.svelte
+++ b/src/routes/(app)/account/[id]/+page.svelte
@@ -30,7 +30,7 @@
let currency = $state(data.account.currency || data.user.currency);
let autowithdraw = $state(data.account.autowithdraw || false);
let threshold = $state(data.account.threshold || data.user.threshold || 1000000);
- let reserve = $state(data.account.reserve || data.user.reserve || 0);
+ let reserve = $state(data.account.reserve ?? 0);
let destination = $state(data.account.destination || data.user.destination || "");
let rate = $derived(rates[currency]);
@@ -113,21 +113,19 @@
</script>
<div class="space-y-5">
- <h1 class="text-center text-3xl font-semibold">{$t("payments.accountSettings")}</h1>
+ <h1 class="text-center text-3xl font-semibold flex items-center justify-center gap-2">
+ {#if account.type === "ark"}
+ <img src="/images/ark.png" class="w-8 h-8 rounded-full object-cover" alt="Ark" />
+ {:else if seed}
+ <iconify-icon noobserver icon="cryptocurrency-color:btc" width="32"></iconify-icon>
+ {:else}
+ <img src="/images/icon.png" class="w-8 h-8" alt="Coinos" />
+ {/if}
+ {displayType} {$t("payments.accountSettings").toLowerCase()}
+ </h1>
<div class="container w-full mx-auto text-lg px-4 max-w-xl space-y-2">
<form class="space-y-5 pb-8" method="POST" use:enhance>
- <div class="flex items-center gap-2 text-lg">
- <span class="font-bold">{$t("accounts.accountType")}:</span>
- {#if account.type === "ark"}
- <img src="/images/ark.png" class="w-6 h-6 rounded-full object-cover" alt="Ark" />
- {:else if seed}
- <iconify-icon noobserver icon="cryptocurrency-color:btc" width="24"></iconify-icon>
- {:else}
- <img src="/images/icon.png" class="w-6 h-6" alt="Coinos" />
- {/if}
- {displayType}
- </div>
<div class="space-y-1">
<label for="name" class="font-bold block">{$t("accounts.name")}</label>
@@ -213,7 +211,7 @@
</button>
{/if}
- {#if seed}
+ {#if seed || account.type === "ark"}
<button onclick={del} type="button" class="btn">
<iconify-icon noobserver icon="ph:trash-bold" width="32"></iconify-icon>
<div class="my-auto">{$t("accounts.deleteAccount")}</div>
diff --git a/src/routes/(app)/invoice/[id]/+page.svelte b/src/routes/(app)/invoice/[id]/+page.svelte
index d8f30fd1..e4a5b85e 100644
--- a/src/routes/(app)/invoice/[id]/+page.svelte
+++ b/src/routes/(app)/invoice/[id]/+page.svelte
@@ -94,7 +94,7 @@
invoice.address_type = address_type;
invoice.type = type;
- if (type === "ark") {
+ if (type === "ark" && invoice.aid !== invoice.uid) {
const wallet = await getWallet();
invoice.hash = await wallet.getAddress();
}
diff --git a/test/e2e/ark-custodial-receive.spec.ts b/test/e2e/ark-custodial-receive.spec.ts
new file mode 100644
index 00000000..8d8dba88
--- /dev/null
+++ b/test/e2e/ark-custodial-receive.spec.ts
@@ -0,0 +1,160 @@
+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("custodial account receives ark payment via vault forward", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ test.skip(!testSecret, "E2E_TEST_SECRET is required");
+
+ // 1. Log in
+ 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}(?:[/?#]|$)`));
+
+ // 2. Find custodial account and select it
+ const custodialCard = page.locator(
+ '[data-testid="account-card"]:not([data-account-type="ark"]):not([data-account-type="bitcoin"])',
+ );
+ const custodialCount = await custodialCard.count();
+ expect(custodialCount).toBeGreaterThan(0);
+
+ // Get the custodial account ID and switch to it
+ const accountHref = await custodialCard
+ .first()
+ .locator('a[href^="/account/"]')
+ .first()
+ .getAttribute("href");
+ const custodialAid = accountHref?.split("/account/")[1];
+ expect(custodialAid).toBeTruthy();
+
+ // Set custodial account as active
+ await page.evaluate((aid) => {
+ document.cookie = `aid=${aid}; path=/; max-age=86400`;
+ }, custodialAid!);
+
+ // 3. Handle wallet password if needed (to unlock ark vault)
+ const walletPassInput = page.getByTestId("walletpass-input");
+
+ // Navigate to invoice page to create an ark invoice
+ await page.goto("/invoice");
+
+ if (
+ await walletPassInput
+ .first()
+ .isVisible({ timeout: 2000 })
+ .catch(() => false)
+ ) {
+ await walletPassInput.fill(password);
+ await page.getByTestId("walletpass-submit").click();
+ }
+
+ await page.waitForURL(/\/invoice\/[^/?#]+/, { timeout: 10_000 });
+
+ const invoiceId = page.url().split("/invoice/")[1]?.split(/[/?#]/)[0];
+ expect(invoiceId).toBeTruthy();
+
+ // 4. Switch invoice type to Ark
+ // Click "More options" to open type picker
+ const moreOptions = page.getByText("More options");
+ if (await moreOptions.isVisible({ timeout: 3000 }).catch(() => false)) {
+ await moreOptions.click();
+ }
+
+ // Click the Ark button
+ const arkButton = page.locator("button", { hasText: "Ark" });
+ await expect(arkButton).toBeVisible({ timeout: 5000 });
+ await arkButton.click();
+
+ // Wait for invoice to regenerate with ark type
+ await page.waitForURL(/\/invoice\/[^/?#]+/, { timeout: 10_000 });
+
+ const newInvoiceId = page.url().split("/invoice/")[1]?.split(/[/?#]/)[0];
+ expect(newInvoiceId).toBeTruthy();
+
+ // 5. Verify the invoice shows a tark address (vault's address)
+ const invoiceText = (
+ await page.getByTestId("invoice-text").first().innerText()
+ ).trim();
+ const addressMatch = invoiceText.match(/t?ark1[a-z0-9]+/i);
+ expect(
+ addressMatch,
+ `Expected ark address in invoice text: ${invoiceText}`,
+ ).toBeTruthy();
+ const address = addressMatch![0];
+
+ // Verify the invoice type is ark on the server
+ const invoiceRes = await page.request.get(
+ `${apiBaseUrl}/invoice/${newInvoiceId}`,
+ );
+ const invoiceData = await invoiceRes.json();
+ expect(invoiceData.type).toBe("ark");
+ expect(invoiceData.text).toContain("ark1");
+
+ console.log(
+ `[e2e] Created ark invoice ${newInvoiceId} on custodial account, address: ${address}`,
+ );
+
+ // 6. Send from server wallet to the vault address
+ const sendResult = await page.evaluate(
+ async ({ address, testSecret, iid }) => {
+ const res = await fetch("/test/ark/send", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-test-secret": testSecret,
+ },
+ body: JSON.stringify({ address, amount: 1000 }),
+ });
+ const text = await res.text();
+ return { ok: res.ok, status: res.status, text };
+ },
+ { address, testSecret: testSecret!, iid: newInvoiceId },
+ );
+ expect(
+ sendResult.ok,
+ `/test/ark/send failed (${sendResult.status}): ${sendResult.text}`,
+ ).toBeTruthy();
+
+ console.log(`[e2e] Sent 1000 sats to vault address ${address}`);
+
+ // 7. Wait for the custodial forward to complete
+ // The flow: vault receives -> arkSync detects custodial invoice ->
+ // returns forward -> frontend sendArk to server -> POST /ark/receive -> credit
+ let paid = false;
+ let status: any;
+
+ for (let i = 0; i < 60; i++) {
+ const statusResponse = await page.request.get(
+ `${apiBaseUrl}/invoice/${newInvoiceId}`,
+ );
+ if (statusResponse.ok()) {
+ status = await statusResponse.json();
+ if ((status?.received || 0) >= 1000) {
+ paid = true;
+ break;
+ }
+ }
+ await page.waitForTimeout(2000);
+ }
+
+ expect(
+ paid,
+ `Custodial ark forward did not complete: ${JSON.stringify(status)}`,
+ ).toBeTruthy();
+
+ console.log(
+ `[e2e] Custodial ark receive complete: invoice ${newInvoiceId} received=${status?.received}`,
+ );
+
+ if (pauseAtEnd) {
+ await page.pause();
+ }
+});