diff options
| author | Adam Soltys <[email protected]> | 2026-02-14 15:46:09 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-14 15:46:09 -0800 |
| commit | 40fc5fe98c08357dff41539de48532d554aa73cb (patch) | |
| tree | 289e6f1af7f256b1e8ddab75d74e272ead7baa2f | |
| parent | d242a387279119cfc6e191eff3b4e2228cbb5a4e (diff) | |
| download | coinos-ui-40fc5fe98c08357dff41539de48532d554aa73cb.tar.gz coinos-ui-40fc5fe98c08357dff41539de48532d554aa73cb.zip | |
custodial ark sends
| -rw-r--r-- | src/components/Account.svelte | 2 | ||||
| -rw-r--r-- | src/components/InvoiceActions.svelte | 2 | ||||
| -rw-r--r-- | src/components/InvoiceTypes.svelte | 17 | ||||
| -rw-r--r-- | src/lib/ark.ts | 14 | ||||
| -rw-r--r-- | src/locales/en.json | 6 | ||||
| -rw-r--r-- | src/routes/(app)/+layout.svelte | 21 | ||||
| -rw-r--r-- | src/routes/(app)/[username]/(profile)/+page.svelte | 17 | ||||
| -rw-r--r-- | src/routes/(app)/account/[id]/+page.svelte | 26 | ||||
| -rw-r--r-- | src/routes/(app)/invoice/[id]/+page.svelte | 2 | ||||
| -rw-r--r-- | test/e2e/ark-custodial-receive.spec.ts | 160 |
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(); + } +}); |
