diff --git a/services/vault/e2e/connected-state.spec.ts b/services/vault/e2e/connected-state.spec.ts new file mode 100644 index 000000000..0ae18e5a7 --- /dev/null +++ b/services/vault/e2e/connected-state.spec.ts @@ -0,0 +1,66 @@ +/** + * Proves the e2e "fully connected" seam works: + * - BTC injectable auto-reconnects from pre-seeded localStorage + * - ETH wagmi mock auto-connects via the e2e wagmi config + * - `` flips to the connected wallet menu (renders both + * wallet avatars) instead of the connect button + * + * Subsequent specs (#1591 vault providers, #1592 deposit, etc.) + * rely on this seam to land in a connected starting state. + */ + +import { + expect, + mockGraphql, + mockHealthCheck, + mockMempoolForSeededBtcWallet, + preConnectWallets, + test, +} from "./fixtures"; + +test("preConnectWallets renders the connected wallet menu, not the connect button", async ({ + page, + seededBtcWallet, + seededEthWallet, +}) => { + const btc = seededBtcWallet({ amount: 100_000n }); + const eth = seededEthWallet({ balanceWei: 5n * 10n ** 18n }); + await mockHealthCheck(page); + await mockGraphql(page, () => ({ data: { __typename: "Query" } })); + await mockMempoolForSeededBtcWallet(page, btc); + await preConnectWallets(page, { btc, eth }); + + await page.goto("/"); + await page.waitForLoadState("networkidle", { timeout: 20_000 }).catch(() => {}); + + // The connected wallet menu trigger renders avatars for each wallet; + // the connect button (testid: "connect-wallet-button") is gone in + // the connected branch. + await expect( + page.getByTestId("connect-wallet-button"), + ).toHaveCount(0, { timeout: 15_000 }); +}); + +test("preConnectWallets renders the 'Deposit BTC' CTA that's gated on connection", async ({ + page, + seededBtcWallet, + seededEthWallet, +}) => { + const btc = seededBtcWallet({ amount: 100_000n }); + const eth = seededEthWallet({ balanceWei: 5n * 10n ** 18n }); + await mockHealthCheck(page); + await mockGraphql(page, () => ({ data: { __typename: "Query" } })); + await mockMempoolForSeededBtcWallet(page, btc); + await preConnectWallets(page, { btc, eth }); + + await page.goto("/"); + await page.waitForLoadState("networkidle", { timeout: 20_000 }).catch(() => {}); + + // RootLayout only renders the deposit CTA when both wallets are + // connected and the app is past geofencing. Coin symbol varies by + // network (BTC on mainnet, sBTC on signet) so we match on the + // "Deposit " prefix rather than the full label. + await expect( + page.getByRole("button", { name: /^Deposit (s?BTC)$/i }), + ).toBeVisible({ timeout: 15_000 }); +}); diff --git a/services/vault/e2e/fixtures-smoke.spec.ts b/services/vault/e2e/fixtures-smoke.spec.ts index c0320b015..7651dce5c 100644 --- a/services/vault/e2e/fixtures-smoke.spec.ts +++ b/services/vault/e2e/fixtures-smoke.spec.ts @@ -28,9 +28,11 @@ test("seededBtcWallet exposes mempool-wire payloads that sum to the seeded amoun const totalValue = wallet.mempoolUtxos.reduce((s, u) => s + u.value, 0); expect(BigInt(totalValue)).toBe(250_000n); expect(wallet.mempoolAddressInfo.isvalid).toBe(true); - // Default address is signet P2WPKH (`tb1q...`), so the scriptPubKey - // is 0014 (OP_0 push-20) + 20-byte hash placeholder (40 hex). - expect(wallet.mempoolAddressInfo.scriptPubKey).toMatch(/^0014[0-9a-f]{40}$/); + // Default address is signet P2TR (`tb1p...`) - the vault deposit CTA + // is gated on Taproot, so the seeded fixture must default there. + // P2TR scriptPubKey: 5120 (OP_1 push-32) + 32-byte x-only key + // placeholder (64 hex). + expect(wallet.mempoolAddressInfo.scriptPubKey).toMatch(/^5120[0-9a-f]{64}$/); }); test("seededBtcWallet rejects utxoSplit that doesn't sum to amount", ({ diff --git a/services/vault/e2e/fixtures/connectedWallets.ts b/services/vault/e2e/fixtures/connectedWallets.ts new file mode 100644 index 000000000..fcf620633 --- /dev/null +++ b/services/vault/e2e/fixtures/connectedWallets.ts @@ -0,0 +1,103 @@ +/** + * "Pre-connected" wallet helper. + * + * The dApp's connect flow is two-modal (BTC then ETH) and ETH connects + * through Reown's AppKit, which is hostile to e2e drivers. Rather than + * fight the modal, this helper stages the page so the connect happens + * on the page-load auto-reconnect path: + * + * - BTC: write `baby-connected-wallet-accounts` localStorage so + * `createWalletConnector` re-connects the `"injectable"` adapter + * on boot. Combined with `window.btcwallet` installed by + * `injectBtcWalletProvider`, the BTC side is connected by the + * time React renders the connect UI. + * - ETH: in `NEXT_PUBLIC_E2E_MODE=1` the vault's wagmi config + * (services/vault/src/config/wagmi.ts) drops in a wagmi `mock` + * connector and auto-connects on the next microtask. The + * wallet-connector's `AppKitProvider` watches wagmi via + * `watchAccount` and emits the wagmi account through the same + * channel a real connect would use. + * + * Together, calling `preConnectWallets(page, ...)` before + * `page.goto("/")` lands the test on a "both wallets connected" + * starting state without ever opening the connect modal. + */ + +import type { Page } from "@playwright/test"; + +import { mockEthRpcForSeededWallet } from "./networkRoutes"; +import type { SeededBtcWallet, SeededEthWallet } from "./seededWallets"; +import { + btcWalletConfigFromSeeded, + injectBtcWalletProvider, +} from "./walletPageInjection"; + +const ACCOUNTS_KEY = "baby-connected-wallet-accounts"; +const BTC_INJECTABLE_ID = "injectable"; + +export interface PreConnectOptions { + btc: SeededBtcWallet; + eth: SeededEthWallet; + /** + * Network identifier the dApp scopes localStorage by. Defaults to + * `"signet"` to match playwright.config.ts's + * `NEXT_PUBLIC_BTC_NETWORK`. Update both if the e2e BTC network + * ever changes. + */ + btcNetwork?: string; + /** + * Per-test override for ETH RPC methods beyond the ones + * `mockEthRpcForSeededWallet` already answers (chainId, blockNumber, + * getBalance). Useful when a flow needs `eth_call` contract reads. + */ + ethRpcHandler?: (method: string, params: unknown[]) => unknown; +} + +export async function preConnectWallets( + page: Page, + options: PreConnectOptions, +): Promise { + const btcNetwork = options.btcNetwork ?? "signet"; + await mockEthRpcForSeededWallet(page, options.eth, options.ethRpcHandler); + await injectBtcWalletProvider( + page, + btcWalletConfigFromSeeded(options.btc, { network: btcNetwork }), + ); + // wallet-connector's `createWallet` only instantiates a provider when + // the wallet's origin (the `wallet:` lookup key on window) is + // truthy. AppKit's ETH provider ignores the origin object - it + // reads from the shared wagmi config - but the gate runs first, so + // an absent `window.ethereum` prevents AppKitProvider from being + // created at all. Plant a sentinel so the gate passes. + await page.addInitScript(() => { + if (!(window as unknown as { ethereum?: unknown }).ethereum) { + (window as unknown as { ethereum: object }).ethereum = { + __e2eSentinel: true, + }; + } + }); + await page.addInitScript( + ({ key, scoped, id, now }) => { + const entry: Record = { + [scoped]: id, + _timestamps: { [scoped]: now }, + }; + localStorage.setItem(key, JSON.stringify(entry)); + }, + { + key: ACCOUNTS_KEY, + scoped: `BTC:${btcNetwork}`, + id: BTC_INJECTABLE_ID, + now: Date.now(), + }, + ); + // The ETH address is announced via the test-only override the + // production wagmi config reads. Setting it here means tests can + // declare a wallet whose address matches a contract fixture rather + // than the wagmi.ts default. + await page.addInitScript((address) => { + ( + window as unknown as { __BABYLON_E2E_ETH_ADDRESS__?: string } + ).__BABYLON_E2E_ETH_ADDRESS__ = address; + }, options.eth.account.address); +} diff --git a/services/vault/e2e/fixtures/index.ts b/services/vault/e2e/fixtures/index.ts index f2cdd764a..c6b51d865 100644 --- a/services/vault/e2e/fixtures/index.ts +++ b/services/vault/e2e/fixtures/index.ts @@ -1,3 +1,5 @@ +export { preConnectWallets } from "./connectedWallets"; +export type { PreConnectOptions } from "./connectedWallets"; export { createMockBtcWallet } from "./mockBtcWallet"; export type { MockBtcScript, diff --git a/services/vault/e2e/fixtures/seededWallets.ts b/services/vault/e2e/fixtures/seededWallets.ts index 6622522e0..4fe6ecd52 100644 --- a/services/vault/e2e/fixtures/seededWallets.ts +++ b/services/vault/e2e/fixtures/seededWallets.ts @@ -26,7 +26,15 @@ import { type MockEthWalletOptions, } from "./mockEthWallet"; -const DEFAULT_BTC_ADDRESS = "tb1qce0n0rv27dwx37dfvhxaaly4lnwelqjuqywvka"; +// Signet Taproot address derived from the all-`ab` test key (matches +// `mockBtcWallet.ts`'s publicKey). Required: the vault's +// AddressTypeProvider only treats `tb1p...` / `bc1p...` (P2TR) as +// supported, and the dApp's "Deposit BTC" CTA is gated on that. A +// non-Taproot address would render the CTA disabled with a "switch +// to Taproot" tooltip, breaking any test that drives the deposit +// flow. +const DEFAULT_BTC_ADDRESS = + "tb1pwumwrmky0y5m5vsarnxs5fz37gvaxfcgxrf5zu3slg48pzsdn8zqfcxywh"; /** Wire payload from `GET /api/address/{addr}/utxo`. */ export interface SeededMempoolUtxo { diff --git a/services/vault/playwright.config.ts b/services/vault/playwright.config.ts index 03206e8a1..2e635c84b 100644 --- a/services/vault/playwright.config.ts +++ b/services/vault/playwright.config.ts @@ -20,6 +20,10 @@ const MOCK_ENV_VARS = { // signet/mainnet default and tests would have to intercept the live // hostname. NEXT_PUBLIC_MEMPOOL_API: "http://localhost:9996/mempool", + // Pin the network so fixture pre-seed of + // `baby-connected-wallet-accounts` (which scopes by chain:network) + // matches what the dApp reads at boot. + NEXT_PUBLIC_BTC_NETWORK: "signet", NEXT_PUBLIC_REOWN_PROJECT_ID: "test-project-id-12345", NEXT_PUBLIC_SENTRY_DSN: "https://test@o12345.ingest.sentry.io/12345", NEXT_PUBLIC_SIDECAR_API_URL: "http://localhost:8092", diff --git a/services/vault/src/config/wagmi.ts b/services/vault/src/config/wagmi.ts index 11627c9c9..0fd4b0eeb 100644 --- a/services/vault/src/config/wagmi.ts +++ b/services/vault/src/config/wagmi.ts @@ -6,16 +6,39 @@ * * Since the vault uses AppKit for ETH wallet connections, we let AppKit create * the wagmi config to ensure compatibility. + * + * E2E mode (NEXT_PUBLIC_E2E_MODE=1) takes a different path: it skips + * AppKit entirely and builds a wagmi config whose only connector is + * wagmi's `mock`. The shared config that `AppKitProvider` reads is + * still populated via `setSharedWagmiConfig`, so the rest of the app + * code (which calls into wagmi via that provider) keeps working + * without knowing the connector underneath is faked. The mock account + * comes from a deterministic test address pinned below. */ import { initializeAppKitModal, + setSharedWagmiConfig, type AppKitModalConfig, } from "@babylonlabs-io/wallet-connector"; import { createConfig, http } from "wagmi"; +import { connect } from "wagmi/actions"; +import { mock } from "wagmi/connectors"; import { getETHChain, getNetworkConfigETH } from "@/config/network"; +const IS_E2E_MODE = process.env.NEXT_PUBLIC_E2E_MODE === "1"; + +/** + * Deterministic ETH account exposed to wagmi in e2e mode. The address + * is derived from the same all-`ab` private key the e2e mock ETH + * wallet uses (`services/vault/e2e/fixtures/mockEthWallet.ts`), so + * signatures and addresses line up across fixtures and page-side + * state. + */ +const E2E_DEFAULT_ETH_ADDRESS = + "0xe239cdc5fbe977a8a141B72194D3CF8c41bC5BC6" as `0x${string}`; + interface WagmiInitResult { wagmiConfig: ReturnType; error: string | null; @@ -102,7 +125,60 @@ function createFallbackConfig() { }); } -const initResult = initializeVaultWagmi(); +/** + * E2E wagmi init: skip AppKit, drop in a mock connector, and push the + * resulting config into the wallet-connector's shared singleton so + * AppKitProvider keeps using the same wagmi instance. Auto-connect + * fires on the next microtask so the page hydrates with a wagmi + * account already present — AppKitProvider's `setupEventWatchers` + * picks it up and emits the connection event the rest of the stack + * listens to. + */ +function getE2EAccountAddress(): `0x${string}` { + if (typeof window === "undefined") return E2E_DEFAULT_ETH_ADDRESS; + const override = ( + window as unknown as { __BABYLON_E2E_ETH_ADDRESS__?: string } + ).__BABYLON_E2E_ETH_ADDRESS__; + if (typeof override === "string" && /^0x[0-9a-fA-F]{40}$/.test(override)) { + return override as `0x${string}`; + } + return E2E_DEFAULT_ETH_ADDRESS; +} + +function initializeE2EWagmi(): WagmiInitResult { + const chain = getETHChain(); + const { rpcUrl } = getNetworkConfigETH(); + const e2eAddress = getE2EAccountAddress(); + const mockConnector = mock({ + accounts: [e2eAddress], + features: { reconnect: true }, + }); + const config = createConfig({ + chains: [chain], + transports: { [chain.id]: http(rpcUrl) }, + connectors: [mockConnector], + }); + setSharedWagmiConfig(config); + if (typeof window !== "undefined") { + queueMicrotask(async () => { + try { + const instance = config.connectors.find((c) => c.id === "mock"); + if (instance) { + await connect(config, { connector: instance }); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + "[e2e] Mock ETH auto-connect failed:", + err instanceof Error ? err.message : String(err), + ); + } + }); + } + return { wagmiConfig: config, error: null }; +} + +const initResult = IS_E2E_MODE ? initializeE2EWagmi() : initializeVaultWagmi(); /** * Singleton wagmi config instance diff --git a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx index 36ce315ba..1611b84b4 100644 --- a/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx +++ b/services/vault/src/context/wallet/VaultWalletConnectionProvider.tsx @@ -22,9 +22,16 @@ import { logger } from "@/infrastructure"; // throws `WALLET_METHOD_NOT_SUPPORTED` at the connector layer; this // list just keeps them out of the connection UI in the first place so // users don't pick something that can't complete a deposit. +// +// In e2e mode we keep `"injectable"` enabled so Playwright can install +// a `window.btcwallet` mock that does expose `deriveContextHash`. The +// gate flips at build time via Vite's `NEXT_PUBLIC_E2E_MODE`; a +// production build never sets this var, so users still see only the +// UniSat-and-friends list. +const IS_E2E_MODE = process.env.NEXT_PUBLIC_E2E_MODE === "1"; const DISABLED_WALLETS: string[] = [ APPKIT_BTC_CONNECTOR_ID, - "injectable", + ...(IS_E2E_MODE ? [] : ["injectable"]), "keystone", "ledger_btc", "ledger_btc_v2",