Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions services/vault/e2e/connected-state.spec.ts
Original file line number Diff line number Diff line change
@@ -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
* - `<Connect>` 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 });
});
8 changes: 5 additions & 3 deletions services/vault/e2e/fixtures-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", ({
Expand Down
103 changes: 103 additions & 0 deletions services/vault/e2e/fixtures/connectedWallets.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, unknown> = {
[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);
}
2 changes: 2 additions & 0 deletions services/vault/e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { preConnectWallets } from "./connectedWallets";
export type { PreConnectOptions } from "./connectedWallets";
export { createMockBtcWallet } from "./mockBtcWallet";
export type {
MockBtcScript,
Expand Down
10 changes: 9 additions & 1 deletion services/vault/e2e/fixtures/seededWallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions services/vault/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 77 additions & 1 deletion services/vault/src/config/wagmi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createConfig>;
error: string | null;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down