diff options
| author | Adam Soltys <[email protected]> | 2026-03-03 21:27:16 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-03 21:27:16 -0800 |
| commit | 632358158c76ceaee5db2b1879487c136289766e (patch) | |
| tree | 631a9c7d23027a7ef373b214aa90167b38310de5 /test | |
| parent | 8551df92b96ff69f3cda50184cb714ba30f3803d (diff) | |
| download | coinos-ui-632358158c76ceaee5db2b1879487c136289766e.tar.gz coinos-ui-632358158c76ceaee5db2b1879487c136289766e.zip | |
bip353
Diffstat (limited to 'test')
| -rw-r--r-- | test/e2e/custodial-btc-fee-bump.spec.ts | 199 | ||||
| -rw-r--r-- | test/e2e/helpers.ts | 153 | ||||
| -rw-r--r-- | test/e2e/vault-send-bob-btc.spec.ts | 97 |
3 files changed, 447 insertions, 2 deletions
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); + } +}); |
