Skip to content

Commit a409d2d

Browse files
committed
feat(vault): e2e wallet-connect + BTC injectable wallet (#1590)
1 parent 56ff8e8 commit a409d2d

5 files changed

Lines changed: 350 additions & 7 deletions

File tree

services/vault/e2e/fixtures/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export {
1414
mockEthRpc,
1515
mockEthRpcForSeededWallet,
1616
mockGraphql,
17+
mockHealthCheck,
1718
mockMempoolForSeededBtcWallet,
1819
mockVpProxy,
1920
} from "./networkRoutes";
@@ -35,3 +36,8 @@ export {
3536
injectWallets,
3637
} from "./walletInjection";
3738
export type { InjectedWallets } from "./walletInjection";
39+
export {
40+
btcWalletConfigFromSeeded,
41+
injectBtcWalletProvider,
42+
} from "./walletPageInjection";
43+
export type { BtcWalletPageConfig } from "./walletPageInjection";

services/vault/e2e/fixtures/networkRoutes.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@ export async function mockVpProxy(
9090
): Promise<void> {
9191
const snapshots = options.healthSnapshots ?? [];
9292
await page.route("**/vp-health", (route) => jsonResponse(route, snapshots));
93-
await page.route("**/rpc/*", async (route) => {
93+
// Scope the per-VP rpc route to the proxy port so we don't shadow
94+
// the eth-rpc endpoint (also at /rpc). playwright.config.ts pins
95+
// VP_PROXY_URL to localhost:9998; if you re-point that env var,
96+
// update this pattern.
97+
await page.route("http://localhost:9998/rpc/*", async (route) => {
9498
if (!options.rpcHandler) {
9599
return jsonResponse(
96100
route,
@@ -179,6 +183,33 @@ export async function mockEthRpcForSeededWallet(
179183
});
180184
}
181185

186+
/**
187+
* Mock the `/health` endpoint that `checkGeofencing` calls. The check
188+
* lives at `${graphqlOrigin}/health` and is gated on HTTP status:
189+
* - 200 -> not blocked
190+
* - 451 -> geo-blocked, surfaces "Not available in your region"
191+
* - other -> treated as healthy (only 451 hard-blocks)
192+
*
193+
* The default 200 unblocks the connect-button path. Tests that need
194+
* geo-blocking pass `status: 451`.
195+
*/
196+
export async function mockHealthCheck(
197+
page: Page,
198+
options: { status?: number; delayMs?: number } = {},
199+
): Promise<void> {
200+
const status = options.status ?? 200;
201+
await page.route("**/health", async (route) => {
202+
if (options.delayMs) {
203+
await new Promise((resolve) => setTimeout(resolve, options.delayMs));
204+
}
205+
await route.fulfill({
206+
status,
207+
contentType: "application/json",
208+
body: status === 200 ? "{}" : `{"error":"status ${status}"}`,
209+
});
210+
});
211+
}
212+
182213
/**
183214
* Mock the GraphQL endpoint with a request-shape-aware handler. The
184215
* vault app uses `graphql-request` so each request is `POST` with a

services/vault/e2e/fixtures/test.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ import {
3636
type SeededEthWalletOptions,
3737
} from "./seededWallets";
3838
import { E2E_WALLETS_GLOBAL } from "./walletInjection";
39+
import {
40+
btcWalletConfigFromSeeded,
41+
injectBtcWalletProvider,
42+
} from "./walletPageInjection";
3943

4044
export interface VaultE2EFixtures {
4145
/** Build a seeded BTC wallet with a declared balance. */
@@ -78,12 +82,11 @@ async function installWalletSentinelOnPage(
7882
globalName: string,
7983
payload: WalletSentinelPayload,
8084
): Promise<void> {
81-
// The full mocks contain closures that don't survive structured-clone
82-
// across the Node/browser boundary, so we install a JSON-only
83-
// sentinel here. Reconstructing the live mock provider page-side is
84-
// the single-vault deposit happy-path ticket's responsibility
85-
// (#1592). For #1589 the install proves the bridge fires before
86-
// navigation - enough to unblock per-flow tickets.
85+
// The sentinel records that a wallet was requested by the test, for
86+
// diagnostic / introspection use. Actual `window.btcwallet` wiring
87+
// happens via `installBtcOnPage` below - the wallet-connector reads
88+
// from that exact global. ETH provider injection still waits on
89+
// mocking Reown's AppKit (deferred to a follow-up).
8790
await page.addInitScript(
8891
({ globalName: name, sentinel }) => {
8992
(window as unknown as Record<string, unknown>)[name] = sentinel;
@@ -92,6 +95,13 @@ async function installWalletSentinelOnPage(
9295
);
9396
}
9497

98+
async function installBtcOnPage(
99+
page: Page,
100+
wallet: SeededBtcWallet,
101+
): Promise<void> {
102+
await injectBtcWalletProvider(page, btcWalletConfigFromSeeded(wallet));
103+
}
104+
95105
function toSentinel(
96106
wallet: SeededBtcWallet | SeededEthWallet | null | undefined,
97107
kind: "seeded-btc" | "seeded-eth",
@@ -118,6 +128,9 @@ export const test = base.extend<VaultE2EFixtures>({
118128
btc: toSentinel(wallets.btc, "seeded-btc"),
119129
eth: toSentinel(wallets.eth, "seeded-eth"),
120130
});
131+
if (wallets.btc) {
132+
await installBtcOnPage(page, wallets.btc);
133+
}
121134
});
122135
},
123136
appShell: async ({ page }, use) => {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Page-side wallet injection.
3+
*
4+
* The babylon-wallet-connector's injectable BTC adapter reads from
5+
* `window.btcwallet` and treats whatever it finds there as an
6+
* `IBTCProvider`. To drive the connect flow in e2e we install a
7+
* deterministic provider on that global *before* the dApp loads, via
8+
* `page.addInitScript`.
9+
*
10+
* The mock provider lives entirely in page context: `addInitScript`
11+
* serialises its callback to a string and re-evaluates it in the
12+
* browser, so any function the callback references must be defined
13+
* inside the callback body. Closures captured Node-side do not
14+
* survive the boundary. Anything the test needs to vary across runs
15+
* therefore travels as a plain-JSON `config` arg.
16+
*
17+
* ETH provider injection is a separate problem: the dApp uses Reown's
18+
* AppKit, which has its own React state machine. Mocking that lands
19+
* with a follow-up ticket (the per-flow deposit specs that need a
20+
* fully-connected wallet pair will block on it).
21+
*/
22+
23+
import type { Page } from "@playwright/test";
24+
25+
import type { SeededBtcWallet } from "./seededWallets";
26+
27+
export interface BtcWalletPageConfig {
28+
/** Bech32 address `getAddress` returns. */
29+
address: string;
30+
/** 33-byte compressed pubkey hex `getPublicKeyHex` returns. */
31+
publicKeyHex: string;
32+
/** Network string ("mainnet" | "signet" | "testnet"). */
33+
network: string;
34+
/** Display name for the wallet menu. */
35+
providerName: string;
36+
/** Data-URI icon. */
37+
providerIcon: string;
38+
/** Marker so the page-side instance is recognisable from devtools. */
39+
e2e: true;
40+
}
41+
42+
/**
43+
* Install a deterministic BTC provider on `window.btcwallet`. Must be
44+
* called BEFORE the first `page.goto` so the injectable adapter
45+
* discovers it during module evaluation.
46+
*/
47+
export async function injectBtcWalletProvider(
48+
page: Page,
49+
config: BtcWalletPageConfig,
50+
): Promise<void> {
51+
await page.addInitScript((cfg) => {
52+
const SIGNED_MESSAGE_HEX = "cd".repeat(64);
53+
54+
// sha256 of `${appName}:${context}` rendered as lowercase hex. The
55+
// deriveContextHash output must be deterministic across runs but
56+
// not collide with real key material - the mock prefixes the
57+
// appName with a marker via the chosen input.
58+
async function sha256Hex(input: string): Promise<string> {
59+
const data = new TextEncoder().encode(input);
60+
const buffer = await crypto.subtle.digest("SHA-256", data);
61+
return Array.from(new Uint8Array(buffer))
62+
.map((b) => b.toString(16).padStart(2, "0"))
63+
.join("");
64+
}
65+
66+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
67+
68+
const provider = {
69+
connectWallet: async () => undefined,
70+
getAddress: async () => cfg.address,
71+
getPublicKeyHex: async () => cfg.publicKeyHex,
72+
getNetwork: async () => cfg.network,
73+
getInscriptions: async () => [],
74+
getWalletProviderName: async () => cfg.providerName,
75+
getWalletProviderIcon: async () => cfg.providerIcon,
76+
// Echo the PSBT back so callers that just decode-and-re-encode keep
77+
// working. Tests that need a real partial signature must reach
78+
// into the provider via `window.btcwallet` and overwrite the
79+
// method per-test.
80+
signPsbt: async (psbtHex: string) => psbtHex,
81+
signPsbts: async (psbtsHexes: string[]) => [...psbtsHexes],
82+
signMessage: async () => SIGNED_MESSAGE_HEX,
83+
deriveContextHash: async (appName: string, context: string) =>
84+
sha256Hex(`${appName}:${context}`),
85+
on: (event: string, cb: (...args: unknown[]) => void) => {
86+
if (!listeners.has(event)) listeners.set(event, new Set());
87+
listeners.get(event)!.add(cb);
88+
},
89+
off: (event: string, cb: (...args: unknown[]) => void) => {
90+
listeners.get(event)?.delete(cb);
91+
},
92+
// Tests dispatch events by reading `window.btcwallet.__emit`.
93+
__emit: (event: string, ...args: unknown[]) => {
94+
listeners.get(event)?.forEach((cb) => cb(...args));
95+
},
96+
__e2eConfig: cfg,
97+
};
98+
99+
(window as unknown as { btcwallet?: unknown }).btcwallet = provider;
100+
}, config);
101+
}
102+
103+
/**
104+
* Convenience adapter for the seeded wallets in `seededWallets.ts`.
105+
* Builds the page-side config from a SeededBtcWallet so tests can
106+
* pass the same value to mempool route helpers and the injector.
107+
*/
108+
export function btcWalletConfigFromSeeded(
109+
wallet: SeededBtcWallet,
110+
overrides: Partial<BtcWalletPageConfig> = {},
111+
): BtcWalletPageConfig {
112+
return {
113+
address: wallet.address,
114+
publicKeyHex: `02${"ab".repeat(32)}`,
115+
network: "signet",
116+
providerName: "E2E Mock BTC",
117+
providerIcon: "data:image/svg+xml;base64,PHN2Zy8+",
118+
e2e: true,
119+
...overrides,
120+
};
121+
}

0 commit comments

Comments
 (0)