aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-03 21:27:16 -0800
committerAdam Soltys <[email protected]>2026-03-03 21:27:16 -0800
commit632358158c76ceaee5db2b1879487c136289766e (patch)
tree631a9c7d23027a7ef373b214aa90167b38310de5
parent8551df92b96ff69f3cda50184cb714ba30f3803d (diff)
downloadcoinos-ui-632358158c76ceaee5db2b1879487c136289766e.tar.gz
coinos-ui-632358158c76ceaee5db2b1879487c136289766e.zip
bip353
-rw-r--r--src/lib/settings.ts1
-rw-r--r--src/locales/en.json2
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte5
-rw-r--r--src/routes/(app)/settings/account/+page.svelte8
-rw-r--r--test/e2e/custodial-btc-fee-bump.spec.ts199
-rw-r--r--test/e2e/helpers.ts153
-rw-r--r--test/e2e/vault-send-bob-btc.spec.ts97
7 files changed, 461 insertions, 4 deletions
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index a3b875c0..fa1abc03 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -14,6 +14,7 @@ export default async ({ cookies, request }) => {
form.nip5 = form.nip5 === "on";
form.prompt = form.prompt === "on";
form.autowithdraw = form.autowithdraw === "on";
+ form.bip353 = form.bip353 === "on";
}
try {
diff --git a/src/locales/en.json b/src/locales/en.json
index 7db19d3d..2d49ac8d 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -501,6 +501,8 @@
"theme": "Color Theme",
"tipPrompt": "Prompt for tips",
"tipPromptDescription": "Ask people if they want to add a tip before paying their invoices",
+ "bip353": "BIP 353 DNS payment",
+ "bip353Description": "Make your address discoverable as ₿[email protected] so any BIP 353 compatible wallet can pay you",
"displayName": "Display name",
"themeDescription": "Select from the default color themes.",
"localCurrency": "Currency",
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 54927f0f..ed59b14b 100644
--- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
+++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
@@ -5,7 +5,7 @@
import Toggle from "$comp/Toggle.svelte";
import Spinner from "$comp/Spinner.svelte";
import { page } from "$app/stores";
- import { toFiat, f, fail, focus, loc, s, sat, closest, network } from "$lib/utils";
+ import { toFiat, f, fail, focus, loc, s, sat, closest, network, versions } from "$lib/utils";
import { pin } from "$lib/store";
import { goto, invalidate } from "$app/navigation";
import { rate } from "$lib/store";
@@ -107,7 +107,7 @@
mnemonicStr = entropyToMnemonic(entropy, wordlist);
}
let seed = await mnemonicToSeed(mnemonicStr);
- let master = HDKey.fromMasterSeed(seed, network as any);
+ let master = HDKey.fromMasterSeed(seed, versions as any);
let child = master.derive(`m/84'/0'/${account.accountIndex ?? 0}'`);
if (account.pubkey && child.publicExtendedKey !== account.pubkey) {
@@ -263,6 +263,7 @@
<input name="rate" value={$rate} type="hidden" />
<input name="aid" value={account.id} type="hidden" />
<input name="signed" value={signed} type="hidden" />
+ <input name="feeRate" value={feeRate} type="hidden" />
<div class="flex justify-center gap-2">
<button
diff --git a/src/routes/(app)/settings/account/+page.svelte b/src/routes/(app)/settings/account/+page.svelte
index 28cd9e4b..115f46c5 100644
--- a/src/routes/(app)/settings/account/+page.svelte
+++ b/src/routes/(app)/settings/account/+page.svelte
@@ -135,6 +135,14 @@
<p class="text-secondary mt-1 w-9/12">{$t("user.settings.tipPromptDescription")}</p>
</div>
+<div>
+ <div class="flex justify-between items-center">
+ <span class="font-bold">{$t("user.settings.bip353")}</span>
+ <Toggle id="bip353" bind:value={user.bip353} />
+ </div>
+ <p class="text-secondary mt-1 w-9/12">{$t("user.settings.bip353Description")}</p>
+</div>
+
<div class="space-y-1 relative">
<label for="tip" class="font-bold block">{$t("user.settings.tip")}</label>
<p class="text-secondary">{$t("user.settings.tipDesc")}</p>
diff --git a/test/e2e/custodial-btc-fee-bump.spec.ts b/test/e2e/custodial-btc-fee-bump.spec.ts
new file mode 100644
index 00000000..45e233d7
--- /dev/null
+++ b/test/e2e/custodial-btc-fee-bump.spec.ts
@@ -0,0 +1,199 @@
+import { expect, test } from "@playwright/test";
+import {
+ bobUsername,
+ bobPassword,
+ login,
+ getBitcoinAddress,
+ pasteAndSend,
+ fillNumpadAmount,
+ waitForSendComplete,
+ apiLogin,
+ apiBaseUrl,
+} from "./helpers";
+
+test("custodial bitcoin send with low fee shows bump reserve and supports CPFP bump", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+
+ await login(page, bobUsername, bobPassword);
+
+ // Check bob has enough balance
+ const token = await apiLogin(page, bobUsername, bobPassword);
+ const meRes = await page.request.get(`${apiBaseUrl}/me`, {
+ headers: { authorization: `Bearer ${token}` },
+ });
+ const me = await meRes.json();
+ if (!me.balance || me.balance < 5000) {
+ test.skip(true, `Custodial balance too low: ${me.balance}`);
+ return;
+ }
+
+ const externalAddress = getBitcoinAddress();
+
+ // --- Send flow: paste address → amount → select low fee → send ---
+ await pasteAndSend(page, externalAddress);
+ expect(page.url()).toContain("/send/bitcoin/");
+
+ await fillNumpadAmount(page, 2000);
+ await page.waitForURL(/\/send\/bitcoin\/[^/]+\/\d+/, { timeout: 10_000 });
+
+ // Check no error on fee page
+ const loadError = await page
+ .locator(".text-red-600")
+ .first()
+ .innerText({ timeout: 1000 })
+ .catch(() => "");
+ expect(loadError, `Fee page error: ${loadError}`).toBeFalsy();
+
+ // Select lowest fee rate to ensure bumpReserve is set
+ const lowButton = page.locator("button.btn-sm", { hasText: /low/i });
+ if (await lowButton.isVisible({ timeout: 3000 }).catch(() => false)) {
+ await lowButton.click();
+ await page.waitForURL(/\/send\/bitcoin\/[^/]+\/\d+\//, { timeout: 10_000 });
+ }
+
+ // Verify bump reserve is displayed
+ const bumpReserveVisible = await page
+ .locator("text=Bump Reserve")
+ .first()
+ .isVisible({ timeout: 5000 })
+ .catch(() => false);
+ expect(bumpReserveVisible, "Bump reserve should be shown for low fee rate").toBeTruthy();
+
+ // Send the transaction
+ const sendButton = page.locator('button[type="submit"]');
+ await expect(sendButton).toBeVisible({ timeout: 5_000 });
+ await sendButton.click();
+
+ await waitForSendComplete(page, 30_000);
+
+ // Extract payment ID from /sent/{id}
+ const sentMatch = page.url().match(/\/sent\/([^/?#]+)/);
+ expect(sentMatch, "Should be on /sent/ page").toBeTruthy();
+ const paymentId = sentMatch![1];
+
+ // --- Navigate to payment detail and verify bump data ---
+ await page.goto(`/payment/${paymentId}`);
+ await page.waitForLoadState("load");
+ await expect(page.locator("h1")).toBeVisible({ timeout: 10_000 });
+
+ // Verify payment has bump reserve via API
+ const paymentRes = await page.request.get(`${apiBaseUrl}/payments/${paymentId}`, {
+ headers: { authorization: `Bearer ${token}` },
+ });
+ expect(paymentRes.ok()).toBeTruthy();
+ const payment = await paymentRes.json();
+ console.log(
+ `[e2e] Payment: confirmed=${payment.confirmed}, bumpReserve=${payment.bumpReserve}`,
+ );
+
+ if (!payment.bumpReserve || payment.bumpReserve <= 0 || payment.confirmed || payment.childTxid) {
+ console.log("[e2e] Payment not bumpable — skipping bump UI test");
+ return;
+ }
+
+ // --- Test CPFP bump UI ---
+
+ // Wait for hydration
+ await page.waitForTimeout(2000);
+
+ // Click "Speed up" button using mouse coordinates (Svelte hydration can delay event binding)
+ const speedUpBtn = page.locator("button.btn-accent", { hasText: "Speed up" });
+ await expect(speedUpBtn).toBeVisible({ timeout: 10_000 });
+ const box = await speedUpBtn.boundingBox();
+ expect(box, "Speed up button should have bounds").toBeTruthy();
+ await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2);
+
+ // Wait for bump estimate API call
+ await page.waitForTimeout(5000);
+
+ // Verify bump dialog opened
+ const pageText = await page.evaluate(() => document.body.innerText);
+ expect(pageText).toContain("Speed up transaction");
+
+ // Check for fee rate buttons (estimate loaded)
+ const fastBtn = page.locator("button.btn-sm", { hasText: /Fast/ });
+ const hasFeeButtons = await fastBtn.first().isVisible({ timeout: 5_000 }).catch(() => false);
+
+ if (!hasFeeButtons) {
+ console.log("[e2e] Bump estimate failed to load fee options");
+ return;
+ }
+
+ // Should show cost estimate
+ await expect(page.locator("text=Cost:")).toBeVisible({ timeout: 5_000 });
+
+ // Execute the bump
+ const confirmBumpBtn = page.locator("button", { hasText: "Confirm bump" });
+ await expect(confirmBumpBtn).toBeVisible({ timeout: 5_000 });
+
+ if (await confirmBumpBtn.isDisabled()) {
+ console.log("[e2e] Confirm bump disabled — cost exceeds reserve");
+ return;
+ }
+
+ await confirmBumpBtn.click();
+
+ // Wait for success toast
+ await expect(page.locator("text=Transaction bumped!").first()).toBeVisible({ timeout: 15_000 });
+ console.log("[e2e] CPFP bump succeeded!");
+
+ // Verify bump stored via API
+ const bumpedRes = await page.request.get(`${apiBaseUrl}/payments/${paymentId}`, {
+ headers: { authorization: `Bearer ${token}` },
+ });
+ const bumped = await bumpedRes.json();
+ expect(bumped.childTxid, "Payment should have child txid after bump").toBeTruthy();
+ expect(bumped.bumpedFee, "Payment should have bumped fee").toBeGreaterThan(0);
+ console.log(`[e2e] Child txid: ${bumped.childTxid}, fee: ${bumped.bumpedFee}`);
+});
+
+test("bump estimate API returns valid cost and fee data", async ({ page }) => {
+ test.setTimeout(60_000);
+
+ await login(page, bobUsername, bobPassword);
+ const token = await apiLogin(page, bobUsername, bobPassword);
+
+ // Find an unconfirmed outgoing bitcoin payment with bump reserve
+ const paymentsRes = await page.request.get(`${apiBaseUrl}/payments`, {
+ headers: { authorization: `Bearer ${token}` },
+ });
+ expect(paymentsRes.ok()).toBeTruthy();
+ const data = await paymentsRes.json();
+ const payments = data.payments || data;
+
+ const bumpable = payments.find(
+ (p: any) =>
+ p.type === "bitcoin" &&
+ !p.confirmed &&
+ p.amount < 0 &&
+ p.bumpReserve > 0 &&
+ !p.childTxid,
+ );
+
+ if (!bumpable) {
+ test.skip(true, "No bumpable unconfirmed bitcoin payment found");
+ return;
+ }
+
+ console.log(`[e2e] Bumpable payment: ${bumpable.id}, reserve: ${bumpable.bumpReserve}`);
+
+ // Test bump estimate endpoint
+ const estimateRes = await page.request.post(`${apiBaseUrl}/bump/estimate`, {
+ headers: {
+ authorization: `Bearer ${token}`,
+ "content-type": "application/json",
+ },
+ data: { id: bumpable.id, targetFeeRate: 10 },
+ });
+
+ expect(estimateRes.ok(), `Bump estimate failed: ${await estimateRes.text()}`).toBeTruthy();
+ const estimate = await estimateRes.json();
+
+ console.log(`[e2e] Estimate: cost=${estimate.cost}, reserve=${estimate.bumpReserve}`);
+ expect(estimate.cost).toBeGreaterThanOrEqual(0);
+ expect(estimate.bumpReserve).toBeGreaterThan(0);
+ expect(estimate.fees).toBeTruthy();
+ expect(estimate.fees.fastestFee).toBeGreaterThan(0);
+});
diff --git a/test/e2e/helpers.ts b/test/e2e/helpers.ts
index baa372f4..7a36d3f0 100644
--- a/test/e2e/helpers.ts
+++ b/test/e2e/helpers.ts
@@ -1,8 +1,11 @@
import { execSync } from "child_process";
-import { scryptSync } from "crypto";
+import { createHash, scryptSync } from "crypto";
import type { Browser, Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
-import { nip19 } from "nostr-tools";
+import { finalizeEvent, nip19 } from "nostr-tools";
+import { HDKey } from "@scure/bip32";
+import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39";
+import { wordlist } from "@scure/bip39/wordlists/english.js";
// --- Environment ---
export const arkWalletPassword = process.env.E2E_ARK_WALLET_PASSWORD || "testpw123";
@@ -518,3 +521,149 @@ export async function ensureArkAccount(page: Page, _password: string) {
await page.waitForTimeout(1_000);
}
}
+
+// --- Bitcoin vault helpers ---
+
+const REGTEST_VERSIONS = { private: 0x04358394, public: 0x043587cf };
+
+/**
+ * Derive the bitcoin vault pubkey and fingerprint from a username+password,
+ * using the same derivation path as the app:
+ * scrypt(password, salt) → nsec → sign fixed nostr event → SHA-256(sig) → entropy
+ * → first 16 bytes → mnemonic → seed → HDKey → m/84'/0'/0' → publicExtendedKey
+ */
+export async function deriveVaultCredentials(username: string, password: string) {
+ const salt = `coinos:auth:${username.toLowerCase().replace(/\s/g, "")}`;
+ const sk = scryptSync(password, salt, 32, { N: 65536, r: 8, p: 1, maxmem: 256 * 1024 * 1024 });
+
+ const signed = finalizeEvent(
+ { kind: 1, created_at: 0, content: "coinos-wallet-key-derivation", tags: [] } as any,
+ new Uint8Array(sk),
+ );
+
+ const sigBytes = Buffer.from(signed.sig, "hex");
+ const entropy = createHash("sha256").update(sigBytes).digest();
+
+ const mnemonic = entropyToMnemonic(new Uint8Array(entropy.slice(0, 16)), wordlist);
+ const seed = await mnemonicToSeed(mnemonic);
+ const master = HDKey.fromMasterSeed(seed, REGTEST_VERSIONS);
+ const child = master.derive("m/84'/0'/0'");
+
+ return {
+ pubkey: child.publicExtendedKey,
+ fingerprint: master.fingerprint.toString(16).padStart(8, "0"),
+ };
+}
+
+/**
+ * Find or create a bitcoin vault account for the given user whose keys match
+ * the test-derived credentials. Returns the account object.
+ */
+export async function ensureVaultAccount(
+ page: Page,
+ token: string,
+ username: string,
+ password: string,
+) {
+ const { pubkey, fingerprint } = await deriveVaultCredentials(username, password);
+
+ const accountsRes = await page.request.get(`${apiBaseUrl}/accounts`, {
+ headers: { authorization: `Bearer ${token}` },
+ });
+ expect(accountsRes.ok()).toBeTruthy();
+ const accounts = await accountsRes.json();
+
+ // Check for existing matching vault
+ const existing = accounts.find(
+ (a: any) => a.type === "bitcoin" && a.pubkey === pubkey,
+ );
+ if (existing) {
+ console.log(`[e2e] Found existing vault account: ${existing.id}`);
+ return existing;
+ }
+
+ // Create new vault account
+ const createRes = await page.request.post(`${apiBaseUrl}/accounts`, {
+ headers: {
+ authorization: `Bearer ${token}`,
+ "content-type": "application/json",
+ },
+ data: {
+ name: "Test BTC Vault",
+ type: "bitcoin",
+ pubkey,
+ fingerprint,
+ },
+ });
+ expect(createRes.ok(), `Vault creation failed: ${await createRes.text()}`).toBeTruthy();
+ const account = await createRes.json();
+ console.log(`[e2e] Created vault account: ${account.id}, pubkey: ${pubkey.slice(0, 20)}...`);
+ return account;
+}
+
+/**
+ * Fund a vault account by creating an invoice, sending from bitcoind, and mining.
+ * Returns when the vault has at least `minBalance` sats.
+ */
+export async function fundVaultAccount(
+ page: Page,
+ token: string,
+ accountId: string,
+ username: string,
+ minBalance = 10_000,
+) {
+ // Check current balance
+ const balRes = await page.request.get(`${apiBaseUrl}/account/${accountId}`, {
+ headers: { authorization: `Bearer ${token}` },
+ });
+ const acct = await balRes.json();
+ if ((acct.balance || 0) >= minBalance) {
+ console.log(`[e2e] Vault already funded: ${acct.balance} sats`);
+ return acct.balance;
+ }
+
+ // Create invoice to get a receive address
+ const invoiceRes = await page.request.post(`${apiBaseUrl}/invoice`, {
+ headers: {
+ authorization: `Bearer ${token}`,
+ "content-type": "application/json",
+ },
+ data: {
+ invoice: { aid: accountId, type: "bitcoin", address_type: "bech32", amount: 0 },
+ user: { username, currency: "USD" },
+ },
+ });
+ expect(invoiceRes.ok(), `Invoice creation failed: ${await invoiceRes.text()}`).toBeTruthy();
+ const invoice = await invoiceRes.json();
+ const address = invoice.hash;
+ console.log(`[e2e] Vault receive address: ${address}`);
+
+ // Send from bitcoind (use external wallet, not the coinos hot wallet)
+ const btcAmount = (minBalance / 1e8).toFixed(8);
+ const txid = dockerExec(
+ "bc",
+ `bitcoin-cli -regtest -rpcwallet=external sendtoaddress "${address}" ${btcAmount}`,
+ );
+ console.log(`[e2e] Funded vault: txid=${txid}`);
+
+ // Mine blocks and wait for esplora/server to index
+ mineBlocks(1);
+ await page.waitForTimeout(5_000);
+ mineBlocks(1);
+ await page.waitForTimeout(5_000);
+
+ // Poll for balance
+ for (let i = 0; i < 45; i++) {
+ const res = await page.request.get(`${apiBaseUrl}/account/${accountId}`, {
+ headers: { authorization: `Bearer ${token}` },
+ });
+ const data = await res.json();
+ if ((data.balance || 0) >= minBalance) {
+ console.log(`[e2e] Vault balance: ${data.balance} sats`);
+ return data.balance;
+ }
+ await page.waitForTimeout(2_000);
+ }
+
+ throw new Error(`Vault balance did not reach ${minBalance} after funding`);
+}
diff --git a/test/e2e/vault-send-bob-btc.spec.ts b/test/e2e/vault-send-bob-btc.spec.ts
new file mode 100644
index 00000000..973cee4f
--- /dev/null
+++ b/test/e2e/vault-send-bob-btc.spec.ts
@@ -0,0 +1,97 @@
+import { expect, test } from "@playwright/test";
+import {
+ bobUsername,
+ bobPassword,
+ login,
+ getBitcoinAddress,
+ fillNumpadAmount,
+ waitForSendComplete,
+ setActiveAccount,
+ apiBaseUrl,
+ apiLogin,
+ ensureVaultAccount,
+ fundVaultAccount,
+} from "./helpers";
+
+test("bitcoin vault send completes successfully", async ({ page }) => {
+ test.setTimeout(120_000);
+
+ const consoleErrors: string[] = [];
+ page.on("console", (msg) => {
+ if (msg.type() === "error" || msg.text().includes("Signing with")) {
+ consoleErrors.push(msg.text());
+ }
+ });
+
+ // --- Login as bob ---
+ await login(page, bobUsername, bobPassword);
+ const token = await apiLogin(page, bobUsername, bobPassword);
+
+ // --- Ensure a vault account whose keys match bob's test credentials ---
+ const vaultAccount = await ensureVaultAccount(page, token, bobUsername, bobPassword);
+ await setActiveAccount(page, vaultAccount.id);
+
+ // --- Fund the vault ---
+ await fundVaultAccount(page, token, vaultAccount.id, bobUsername, 10_000);
+
+ // --- Send to an external address ---
+ const externalAddress = getBitcoinAddress();
+
+ await page.goto("/send");
+ await page.waitForLoadState("load");
+
+ const textarea = page.locator('textarea[name="text"]');
+ await expect(textarea).toBeVisible({ timeout: 10_000 });
+ await textarea.fill(externalAddress);
+
+ const submitBtn = page.locator('button[type="submit"]').first();
+ await expect(submitBtn).toBeVisible({ timeout: 5_000 });
+ await submitBtn.click();
+
+ await page.waitForURL((url) => url.pathname !== "/send" && url.pathname !== "/send/", {
+ timeout: 15_000,
+ });
+ expect(page.url()).toContain("/send/bitcoin/");
+
+ // --- Enter amount ---
+ await fillNumpadAmount(page, 1000);
+ await page.waitForURL(/\/send\/bitcoin\/[^/]+\/\d+/, { timeout: 10_000 });
+
+ // --- Check for server-side error ---
+ const errorText = await page
+ .locator(".text-red-600")
+ .first()
+ .innerText({ timeout: 3000 })
+ .catch(() => "");
+ expect(errorText, `Fee page error: ${errorText}`).not.toContain("invalid number");
+
+ if (!errorText) {
+ // --- Click send to trigger client-side signing ---
+ const sendButton = page.locator(
+ 'button[type="submit"]:not([data-testid="walletpass-submit"])',
+ );
+ await expect(sendButton).toBeVisible({ timeout: 5_000 });
+ await sendButton.click();
+
+ // Handle WalletPass dialog if it appears
+ const walletPassInput = page.getByTestId("walletpass-input");
+ const walletPassVisible = await walletPassInput
+ .first()
+ .isVisible({ timeout: 5000 })
+ .catch(() => false);
+
+ if (walletPassVisible) {
+ await walletPassInput.fill(bobPassword);
+ await page.getByTestId("walletpass-submit").click();
+ }
+
+ await waitForSendComplete(page, 30_000);
+ console.log(`[e2e] Vault send succeeded: ${page.url()}`);
+
+ // Verify no path derivation errors in console
+ const regressionErrors = consoleErrors.filter(
+ (e) => e.includes("invalid number") || e.includes("2**32") || e.includes("got undefined"),
+ );
+ expect(regressionErrors, `Path derivation regression: ${regressionErrors}`).toHaveLength(0);
+ }
+});