aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-15 16:58:17 -0800
committerAdam Soltys <[email protected]>2026-02-15 16:58:17 -0800
commitc7e8f628aa215caaa999f06d7fccb910fd5cc099 (patch)
tree86af97b298f4d4023b5b00a4c2498b218ecda628
parent5323057a68c1e2607128a3b216a767802725c52c (diff)
downloadcoinos-ui-c7e8f628aa215caaa999f06d7fccb910fd5cc099.tar.gz
coinos-ui-c7e8f628aa215caaa999f06d7fccb910fd5cc099.zip
fix custodial bitcoin sends
-rw-r--r--src/lib/parse.ts8
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/+layout.server.ts11
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/+page.svelte2
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte2
-rw-r--r--src/routes/(app)/send/invoice/[id]/+page.server.ts10
-rw-r--r--src/routes/(app)/send/invoice/[id]/+page.svelte31
-rw-r--r--test/e2e/custodial-bitcoin-send.spec.ts141
-rw-r--r--test/e2e/vault-send-bitcoin.spec.ts98
8 files changed, 275 insertions, 28 deletions
diff --git a/src/lib/parse.ts b/src/lib/parse.ts
index 48541728..06adfd7e 100644
--- a/src/lib/parse.ts
+++ b/src/lib/parse.ts
@@ -48,7 +48,7 @@ export default async (s, host, cookies) => {
}
const { validate } = await loadValidate();
- if (validate(t) || isLiquid(t)) {
+ if (validate(t) || isLiquid(t) || t.startsWith("bcrt1")) {
let r = `/send/bitcoin/${t}`;
if (amount) r += `/${Math.round(amount * sats)}`;
redirect(307, r);
@@ -123,10 +123,4 @@ export default async (s, host, cookies) => {
} catch {}
if (fund) redirect(307, `/send/fund/${t}`);
-
- try {
- invoice ||= await get(`/invoice/${t}`);
- } catch {}
-
- if (invoice) redirect(307, `/invoice/${invoice.id}`);
};
diff --git a/src/routes/(app)/send/bitcoin/[address]/+layout.server.ts b/src/routes/(app)/send/bitcoin/[address]/+layout.server.ts
index d8df1986..6acb36b6 100644
--- a/src/routes/(app)/send/bitcoin/[address]/+layout.server.ts
+++ b/src/routes/(app)/send/bitcoin/[address]/+layout.server.ts
@@ -15,10 +15,13 @@ export async function load({ cookies, params, parent }) {
}
if (invoice) {
- const recipient = await get(`/users/${invoice.uid}`);
- let r = `/pay/${recipient.username}`;
- if (amount) r += `/${amount}`;
- if (recipient?.id !== user.id) redirect(307, r);
+ const isVault = invoice.account?.pubkey || invoice.account?.seed;
+ if (isVault) {
+ // Vault account — stay on /send/bitcoin for on-chain send
+ } else {
+ // Custodial account — send internally via the original invoice
+ if (invoice.uid !== user.id) redirect(307, `/send/invoice/${invoice.id}`);
+ }
}
return { balance };
diff --git a/src/routes/(app)/send/bitcoin/[address]/+page.svelte b/src/routes/(app)/send/bitcoin/[address]/+page.svelte
index 6a34ccfa..7aba37a2 100644
--- a/src/routes/(app)/send/bitcoin/[address]/+page.svelte
+++ b/src/routes/(app)/send/bitcoin/[address]/+page.svelte
@@ -48,7 +48,7 @@
</button>
<form action={`/send/bitcoin/${address}/${amount}`} class="contents">
- <button use:focus bind:this={submit} type="submit" class="btn !w-auto grow btn-accent">
+ <button bind:this={submit} type="submit" class="btn !w-auto grow btn-accent">
{$t("payments.next")}
</button>
</form>
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 1ef38f02..3afeeace 100644
--- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
+++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
@@ -74,7 +74,7 @@
return;
}
- if ((account.seed || data.user.seed) && !signed) (cancel(), void togglePassword());
+ if ((account.seed || (data.user.seed && account.fingerprint)) && !signed) (cancel(), void togglePassword());
return async ({ result }) => {
if (result.type === "redirect") {
goto(result.location);
diff --git a/src/routes/(app)/send/invoice/[id]/+page.server.ts b/src/routes/(app)/send/invoice/[id]/+page.server.ts
index 59add224..dfbe9d26 100644
--- a/src/routes/(app)/send/invoice/[id]/+page.server.ts
+++ b/src/routes/(app)/send/invoice/[id]/+page.server.ts
@@ -12,7 +12,7 @@ export async function load({ cookies, depends, params: { id }, parent }) {
const trust = await get("/trust", auth(cookies));
const trusted = trust.includes(invoice.uid);
- if (trusted && (pin || !user.haspin)) {
+ if (trusted && (pin || !user.haspin) && invoice.amount) {
let p;
try {
if (!invoice.tip && invoice.user.prompt) {
@@ -37,8 +37,12 @@ export async function load({ cookies, depends, params: { id }, parent }) {
if (invoice.memoPrompt && invoice.memo === null) redirect(307, `/invoice/${id}/memo`);
if (user && invoice.aid === aid) error(500, { message: "Cannot send to self" });
- else if (user && ![types.lightning, types.bolt12].includes(invoice.type))
- redirect(307, `/send/${invoice.type === types.ecash ? "ecash" : "bitcoin"}/${invoice.hash}`);
+ else if (user && ![types.lightning, types.bolt12].includes(invoice.type)) {
+ // Don't redirect custodial bitcoin invoices — they should be paid internally
+ const isVault = invoice.account?.pubkey || invoice.account?.seed;
+ if (invoice.type !== types.bitcoin || isVault)
+ redirect(307, `/send/${invoice.type === types.ecash ? "ecash" : "bitcoin"}/${invoice.hash}`);
+ }
if (!user) redirect(307, `/invoice/${id}`);
diff --git a/src/routes/(app)/send/invoice/[id]/+page.svelte b/src/routes/(app)/send/invoice/[id]/+page.svelte
index 99040ed6..01b4facd 100644
--- a/src/routes/(app)/send/invoice/[id]/+page.svelte
+++ b/src/routes/(app)/send/invoice/[id]/+page.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
import { invalidate } from "$app/navigation";
- import { untrack } from "svelte";
+ import { tick, untrack } from "svelte";
import handler from "$lib/handler";
import { t } from "$lib/translations";
import { goto, invalidateAll } from "$app/navigation";
@@ -29,14 +29,19 @@
let rate = $derived(invoice.rate * (data.rate / data.invoiceRate));
- let a: any = $state(),
- af: any = $state(),
+ let a: any = $state(0),
+ af: any = $state(0),
fiat = $state(untrack(() => !amount));
let amountFiat = $state((untrack(() => amount) * untrack(() => rate)) / sats);
- let setAmount = () => {
+ let formEl: HTMLFormElement | undefined = $state();
+ let setAmount = async () => {
amount = a;
amountFiat = af;
+ if (trusted) {
+ await tick();
+ formEl?.querySelector('button[type="submit"]')?.click();
+ }
};
let submit = $state(),
@@ -142,10 +147,10 @@
{/each}
{/if}
{:else}
- <Numpad bind:amount={a} bind:amountFiat={af} bind:fiat {currency} {rate} {submit} />
+ <Numpad bind:amount={a} bind:amountFiat={af} bind:fiat {currency} {locale} {rate} {submit} />
{/if}
- <form method="POST" use:enhance={handler(toggle)} class="space-y-2">
+ <form method="POST" use:enhance={handler(toggle)} class="space-y-2" bind:this={formEl}>
<input name="address" value={address} type="hidden" />
<input name="amount" value={amount} type="hidden" />
<input name="payreq" value={payreq} type="hidden" />
@@ -180,13 +185,15 @@
</button>
{/if}
</div>
- <button type="button" class="btn" onclick={external}>{$t("payments.moreOptions")}</button>
+ {#if amount}
+ <button type="button" class="btn" onclick={external}>{$t("payments.moreOptions")}</button>
- {#if !trusted}
- <button type="button" class="btn" onclick={toggleTrust}>
- <iconify-icon icon="ph:star-fill" width={32}></iconify-icon>
- {$t("payments.trustUser")}
- </button>
+ {#if !trusted}
+ <button type="button" class="btn" onclick={toggleTrust}>
+ <iconify-icon icon="ph:star-fill" width={32}></iconify-icon>
+ {$t("payments.trustUser")}
+ </button>
+ {/if}
{/if}
</form>
</div>
diff --git a/test/e2e/custodial-bitcoin-send.spec.ts b/test/e2e/custodial-bitcoin-send.spec.ts
new file mode 100644
index 00000000..84c60581
--- /dev/null
+++ b/test/e2e/custodial-bitcoin-send.spec.ts
@@ -0,0 +1,141 @@
+import { expect, test } from "@playwright/test";
+
+const aliceUsername = process.env.E2E_ALICE_USERNAME || "alice";
+const alicePassword = process.env.E2E_ALICE_PASSWORD || "pw";
+const bobUsername = process.env.E2E_BOB_USERNAME || "bob";
+const bobPassword = process.env.E2E_BOB_PASSWORD || "pw";
+
+test("custodial-to-custodial via bitcoin address sends internally and notifies recipient", async ({
+ browser,
+}) => {
+ test.setTimeout(90_000);
+
+ // --- Alice: create a bitcoin receive invoice on her custodial account ---
+ const aliceContext = await browser.newContext();
+ const alicePage = await aliceContext.newPage();
+
+ await alicePage.goto("/login");
+ await alicePage.getByTestId("login-username").fill(aliceUsername);
+ await alicePage.locator('input[name="password"]').first().fill(alicePassword);
+ await alicePage.getByTestId("login-submit").click();
+ await expect(alicePage).toHaveURL(
+ new RegExp(`/${aliceUsername}(?:[/?#]|$)`),
+ { timeout: 15_000 },
+ );
+
+ // Navigate to receive page with bitcoin address type to auto-create a bitcoin invoice
+ await alicePage.goto(`/${aliceUsername}/receive?address_type=bech32`);
+
+ // Wait for redirect to /invoice/{id} (setType fires on mount and creates a bitcoin invoice)
+ await alicePage.waitForURL(/\/invoice\/[^/?#]+/, { timeout: 15_000 });
+
+ // Grab the invoice URL and ID
+ const aliceInvoiceUrl = alicePage.url();
+ const invoiceId = aliceInvoiceUrl.match(/\/invoice\/([^/?#]+)/)?.[1];
+ expect(invoiceId, "Should have invoice ID in URL").toBeTruthy();
+
+ // Grab the bitcoin address from the invoice text
+ const invoiceText = await alicePage
+ .getByTestId("invoice-text")
+ .first()
+ .innerText({ timeout: 10_000 });
+ const bitcoinAddress = invoiceText.trim().replace(/\s+/g, "");
+ expect(
+ bitcoinAddress.startsWith("bcrt1") || bitcoinAddress.startsWith("bc1"),
+ `Expected bitcoin address, got: ${bitcoinAddress}`,
+ ).toBeTruthy();
+
+ console.log(`[e2e] Alice custodial bitcoin address: ${bitcoinAddress}`);
+ console.log(`[e2e] Alice invoice ID: ${invoiceId}`);
+
+ // Alice stays on the invoice page — she's subscribed to notifications via websocket
+
+ // --- Bob: paste Alice's bitcoin address and send internally ---
+ const bobContext = await browser.newContext();
+ const bobPage = await bobContext.newPage();
+
+ await bobPage.goto("/login");
+ await bobPage.waitForLoadState("networkidle");
+ await bobPage.getByTestId("login-username").fill(bobUsername);
+ await bobPage.locator('input[name="password"]').first().fill(bobPassword);
+ await bobPage.getByTestId("login-submit").click();
+ await expect(bobPage).toHaveURL(
+ new RegExp(`/${bobUsername}(?:[/?#]|$)`),
+ { timeout: 15_000 },
+ );
+
+ // Navigate to send page
+ await bobPage.goto("/send");
+ await bobPage.waitForURL(/\/send/);
+
+ // Paste Alice's bitcoin address
+ const textarea = bobPage.locator('textarea[name="text"]');
+ await expect(textarea).toBeVisible({ timeout: 5_000 });
+ await textarea.fill(bitcoinAddress);
+ await bobPage.locator('button[type="submit"]').first().click();
+
+ // Wait for navigation — should go to /send/invoice/ (not /pay/alice or /send/bitcoin)
+ await bobPage.waitForURL(
+ (url) => url.pathname !== "/send" && url.pathname !== "/send/",
+ { timeout: 15_000 },
+ );
+
+ const bobUrl = bobPage.url();
+ console.log(`[e2e] Bob navigated to: ${bobUrl}`);
+
+ // Verify it went to /send/invoice/ (internal send via original invoice)
+ expect(
+ bobUrl,
+ `Expected /send/invoice/ URL but got: ${bobUrl}`,
+ ).toContain("/send/invoice/");
+ expect(
+ bobUrl,
+ `Should NOT redirect to /pay/ but got: ${bobUrl}`,
+ ).not.toContain("/pay/");
+
+ // Bob should see the Numpad (no amount set on the invoice)
+ 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)
+ const swapButton = bobPage.locator('[aria-label="Swap currency display"]');
+ const swapText = await swapButton.innerText();
+ // If swap button shows a fiat amount (not sats), we're in sats mode already
+ // If it shows sats (⚡), we're in fiat mode — click to swap to sats
+ if (swapText.includes("⚡") || swapText.includes("lightning")) {
+ await swapButton.click();
+ }
+
+ // Type amount using keyboard - click the contenteditable, clear it, type 1000
+ await amountInput.click();
+ await bobPage.keyboard.press("Control+a");
+ await bobPage.keyboard.type("1000");
+
+ // Click "Next" to set the amount (the submit button bound to Numpad)
+ const nextButton = bobPage.locator('button[type="button"].btn-accent');
+ await expect(nextButton).toBeVisible({ timeout: 5_000 });
+ await nextButton.click();
+
+ // Now the Send button should appear
+ const sendButton = bobPage.locator('button[type="submit"]');
+ await expect(sendButton).toBeVisible({ timeout: 5_000 });
+ await sendButton.click();
+
+ // Bob should be redirected to /sent/ after successful payment
+ await bobPage.waitForURL(/\/sent\//, { timeout: 15_000 });
+ console.log(`[e2e] Bob payment sent: ${bobPage.url()}`);
+
+ // --- Verify Alice receives notification and is redirected to /paid ---
+ await alicePage.waitForURL(/\/invoice\/.*\/paid/, { timeout: 30_000 });
+ console.log(`[e2e] Alice redirected to: ${alicePage.url()}`);
+
+ // Verify Alice sees the payment success page
+ const successText = await alicePage.locator("h1").first().innerText({ timeout: 5_000 });
+ expect(
+ successText.toLowerCase().includes("payment") || successText.toLowerCase().includes("success") || successText.toLowerCase().includes("detected"),
+ `Expected payment success text, got: ${successText}`,
+ ).toBeTruthy();
+
+ await aliceContext.close();
+ await bobContext.close();
+});
diff --git a/test/e2e/vault-send-bitcoin.spec.ts b/test/e2e/vault-send-bitcoin.spec.ts
new file mode 100644
index 00000000..d08d5d5f
--- /dev/null
+++ b/test/e2e/vault-send-bitcoin.spec.ts
@@ -0,0 +1,98 @@
+import { expect, test } from "@playwright/test";
+
+const aliceUsername = process.env.E2E_ALICE_USERNAME || "alice";
+const alicePassword = process.env.E2E_ALICE_PASSWORD || "pw";
+const bobUsername = process.env.E2E_BOB_USERNAME || "bob";
+const bobPassword = process.env.E2E_BOB_PASSWORD || "pw";
+
+test("pasting vault bitcoin address redirects to /send/bitcoin, not /pay", async ({
+ browser,
+}) => {
+ test.setTimeout(60_000);
+
+ // --- Alice: generate a vault invoice and grab the address ---
+ const aliceContext = await browser.newContext();
+ const alicePage = await aliceContext.newPage();
+
+ await alicePage.goto("/login");
+ await alicePage.getByTestId("login-username").fill(aliceUsername);
+ await alicePage.locator('input[name="password"]').first().fill(alicePassword);
+ await alicePage.getByTestId("login-submit").click();
+ await expect(alicePage).toHaveURL(
+ new RegExp(`/${aliceUsername}(?:[/?#]|$)`),
+ );
+
+ // Find the bitcoin vault account card and click Receive
+ const vaultCard = alicePage.locator(
+ '[data-testid="account-card"][data-account-type="bitcoin"]',
+ );
+ await expect(vaultCard.first()).toBeVisible({ timeout: 10_000 });
+
+ const receiveBtn = vaultCard
+ .first()
+ .getByTestId("account-receive");
+ await receiveBtn.click();
+
+ // Wait for the invoice page
+ await alicePage.waitForURL(/\/invoice\/[^/?#]+/, { timeout: 10_000 });
+
+ // Grab the bitcoin address from the invoice text
+ const invoiceText = await alicePage
+ .getByTestId("invoice-text")
+ .first()
+ .innerText();
+ const vaultAddress = invoiceText.trim().replace(/\s+/g, "");
+ expect(
+ vaultAddress.startsWith("bcrt1") || vaultAddress.startsWith("bc1"),
+ `Expected bitcoin address, got: ${vaultAddress}`,
+ ).toBeTruthy();
+
+ console.log(`[e2e] Alice vault address: ${vaultAddress}`);
+ await aliceContext.close();
+
+ // --- Bob: paste the vault address and verify redirect ---
+ const bobContext = await browser.newContext();
+ const bobPage = await bobContext.newPage();
+
+ await bobPage.goto("/login");
+ await bobPage.waitForLoadState("networkidle");
+ await bobPage.getByTestId("login-username").fill(bobUsername);
+ await bobPage.locator('input[name="password"]').first().fill(bobPassword);
+ await bobPage.getByTestId("login-submit").click();
+ await expect(bobPage).toHaveURL(new RegExp(`/${bobUsername}(?:[/?#]|$)`), {
+ timeout: 15_000,
+ });
+
+ // Navigate to send page
+ await bobPage.goto("/send");
+ await bobPage.waitForURL(/\/send/);
+
+ // Fill the textarea with Alice's vault address and submit
+ const textarea = bobPage.locator('textarea[name="text"]');
+ await expect(textarea).toBeVisible({ timeout: 5_000 });
+ await textarea.fill(vaultAddress);
+
+ // Click the submit button and wait for navigation away from /send
+ await bobPage.locator('button[type="submit"]').first().click();
+
+ // Wait for navigation to leave /send (goes to /send/bitcoin, /pay, or /invoice)
+ await bobPage.waitForURL(
+ (url) => url.pathname !== "/send" && url.pathname !== "/send/",
+ { timeout: 15_000 },
+ );
+
+ const finalUrl = bobPage.url();
+ console.log(`[e2e] After paste, Bob navigated to: ${finalUrl}`);
+
+ // Should go to /send/bitcoin, NOT /pay/alice
+ expect(
+ finalUrl,
+ `Expected /send/bitcoin/ URL but got: ${finalUrl}`,
+ ).toContain("/send/bitcoin/");
+ expect(
+ finalUrl,
+ `Should NOT redirect to /pay/ but got: ${finalUrl}`,
+ ).not.toContain("/pay/");
+
+ await bobContext.close();
+});