diff options
| author | Adam Soltys <[email protected]> | 2026-02-15 16:58:17 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-15 16:58:17 -0800 |
| commit | c7e8f628aa215caaa999f06d7fccb910fd5cc099 (patch) | |
| tree | 86af97b298f4d4023b5b00a4c2498b218ecda628 | |
| parent | 5323057a68c1e2607128a3b216a767802725c52c (diff) | |
| download | coinos-ui-c7e8f628aa215caaa999f06d7fccb910fd5cc099.tar.gz coinos-ui-c7e8f628aa215caaa999f06d7fccb910fd5cc099.zip | |
fix custodial bitcoin sends
| -rw-r--r-- | src/lib/parse.ts | 8 | ||||
| -rw-r--r-- | src/routes/(app)/send/bitcoin/[address]/+layout.server.ts | 11 | ||||
| -rw-r--r-- | src/routes/(app)/send/bitcoin/[address]/+page.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/send/invoice/[id]/+page.server.ts | 10 | ||||
| -rw-r--r-- | src/routes/(app)/send/invoice/[id]/+page.svelte | 31 | ||||
| -rw-r--r-- | test/e2e/custodial-bitcoin-send.spec.ts | 141 | ||||
| -rw-r--r-- | test/e2e/vault-send-bitcoin.spec.ts | 98 |
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(); +}); |
