Skip to content

Commit 061637b

Browse files
grypezclaude
andcommitted
test(wallet): add integration tests against a live Anvil chain
Add Wallet.test.ts covering: - Account population after SRP import - Transaction signing and submission against a local Anvil chain - Secret recovery phrase creation - State exposure - controllerMetadata shape and filtering - Wallet:destroyed lifecycle (published once, even on sync/async errors) Add test/anvil.ts — a helper that spawns and tears down a local Anvil instance for tests, configuring it with the test mnemonic so account addresses are deterministic. Remove the placeholder index.test.ts (greeter stub from package creation). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f1fd9b2 commit 061637b

3 files changed

Lines changed: 368 additions & 9 deletions

File tree

packages/wallet/src/Wallet.test.ts

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { RpcEndpointType } from '@metamask/network-controller';
2+
import {
3+
ClientConfigApiService,
4+
ClientType,
5+
DistributionType,
6+
EnvironmentType,
7+
} from '@metamask/remote-feature-flag-controller';
8+
import { TransactionController } from '@metamask/transaction-controller';
9+
import { enableNetConnect } from 'nock';
10+
11+
import { startAnvil } from '../test/anvil';
12+
import type { AnvilInstance } from '../test/anvil';
13+
import * as initializationModule from './initialization';
14+
import {
15+
createSecretRecoveryPhrase,
16+
importSecretRecoveryPhrase,
17+
sendTransaction,
18+
} from './utilities';
19+
import { Wallet } from './Wallet';
20+
21+
const TEST_PHRASE =
22+
'test test test test test test test test test test test ball';
23+
const TEST_PASSWORD = 'testpass';
24+
25+
async function setupWallet(): Promise<Wallet> {
26+
const wallet = new Wallet({
27+
infuraProjectId: 'fake-infura-project-id',
28+
clientVersion: '1.0.0',
29+
showApprovalRequest: (): undefined => undefined,
30+
clientConfigApiService: new ClientConfigApiService({
31+
fetch: globalThis.fetch,
32+
config: {
33+
client: ClientType.Extension,
34+
distribution: DistributionType.Main,
35+
environment: EnvironmentType.Production,
36+
},
37+
}),
38+
getMetaMetricsId: (): string => 'fake-metrics-id',
39+
});
40+
41+
await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_PHRASE);
42+
43+
return wallet;
44+
}
45+
46+
describe('Wallet', () => {
47+
let wallet: Wallet;
48+
49+
beforeEach(() => {
50+
jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] });
51+
});
52+
53+
afterEach(async () => {
54+
await wallet?.destroy();
55+
enableNetConnect();
56+
jest.useRealTimers();
57+
});
58+
59+
it('can unlock and populate accounts', async () => {
60+
wallet = await setupWallet();
61+
const { messenger } = wallet;
62+
63+
expect(
64+
messenger
65+
.call('AccountsController:listAccounts')
66+
.map((account) => account.address),
67+
).toStrictEqual(['0xc6d5a3c98ec9073b54fa0969957bd582e8d874bf']);
68+
});
69+
70+
describe('with local chain', () => {
71+
let anvil: AnvilInstance;
72+
73+
beforeAll(async () => {
74+
anvil = await startAnvil({ mnemonic: TEST_PHRASE });
75+
});
76+
77+
afterAll(async () => {
78+
await anvil?.stop();
79+
});
80+
81+
it('signs transactions', async () => {
82+
enableNetConnect();
83+
84+
wallet = await setupWallet();
85+
86+
const networkConfig = wallet.messenger.call(
87+
'NetworkController:addNetwork',
88+
{
89+
chainId: '0x7a69',
90+
name: 'Anvil',
91+
nativeCurrency: 'ETH',
92+
blockExplorerUrls: [],
93+
defaultRpcEndpointIndex: 0,
94+
rpcEndpoints: [
95+
{
96+
type: RpcEndpointType.Custom,
97+
url: anvil.rpcUrl,
98+
},
99+
],
100+
},
101+
);
102+
103+
const { networkClientId } = networkConfig.rpcEndpoints[0];
104+
105+
const addresses = wallet.messenger
106+
.call('AccountsController:listAccounts')
107+
.map((account) => account.address);
108+
109+
const { result, transactionMeta } = await sendTransaction(
110+
wallet,
111+
{ from: addresses[0], to: addresses[0] },
112+
{ networkClientId },
113+
);
114+
115+
// Advance timers by an arbitrary value to trigger downstream timer logic.
116+
const hash = await jest
117+
.advanceTimersByTimeAsync(60_000)
118+
.then(() => result);
119+
120+
expect(hash).toStrictEqual(expect.any(String));
121+
expect(transactionMeta).toStrictEqual(
122+
expect.objectContaining({
123+
txParams: expect.objectContaining({
124+
from: addresses[0],
125+
to: addresses[0],
126+
value: '0x0',
127+
type: '0x2',
128+
}),
129+
}),
130+
);
131+
}, 15_000);
132+
});
133+
134+
it('can create secret recovery phrase', async () => {
135+
wallet = new Wallet({
136+
infuraProjectId: 'fake-infura-project-id',
137+
clientVersion: '1.0.0',
138+
showApprovalRequest: (): undefined => undefined,
139+
clientConfigApiService: new ClientConfigApiService({
140+
fetch: globalThis.fetch,
141+
config: {
142+
client: ClientType.Extension,
143+
distribution: DistributionType.Main,
144+
environment: EnvironmentType.Production,
145+
},
146+
}),
147+
getMetaMetricsId: (): string => 'fake-metrics-id',
148+
});
149+
150+
await createSecretRecoveryPhrase(wallet, TEST_PASSWORD);
151+
152+
expect(
153+
wallet.messenger.call('AccountsController:listAccounts'),
154+
).toHaveLength(1);
155+
});
156+
157+
it('exposes state', async () => {
158+
wallet = await setupWallet();
159+
const { state } = wallet;
160+
161+
expect(state.KeyringController).toStrictEqual({
162+
isUnlocked: true,
163+
keyrings: expect.any(Array),
164+
encryptionKey: expect.any(String),
165+
encryptionSalt: expect.any(String),
166+
vault: expect.any(String),
167+
});
168+
});
169+
170+
describe('lifecycle', () => {
171+
const options = {
172+
infuraProjectId: 'fake-infura-project-id',
173+
clientVersion: '1.0.0',
174+
showApprovalRequest: (): undefined => undefined,
175+
clientConfigApiService: new ClientConfigApiService({
176+
fetch: globalThis.fetch,
177+
config: {
178+
client: ClientType.Extension,
179+
distribution: DistributionType.Main,
180+
environment: EnvironmentType.Production,
181+
},
182+
}),
183+
getMetaMetricsId: (): string => 'fake-metrics-id',
184+
};
185+
186+
it('exposes controllerMetadata for each initialized controller', () => {
187+
wallet = new Wallet(options);
188+
189+
const names = Object.keys(wallet.controllerMetadata);
190+
expect(names).toStrictEqual(Object.keys(wallet.state));
191+
for (const name of names) {
192+
expect(wallet.controllerMetadata[name]).toBeDefined();
193+
}
194+
});
195+
196+
it('omits instances without a metadata property from controllerMetadata', () => {
197+
const fakeMetadata = {
198+
foo: { persist: true, includeInDebugSnapshot: false },
199+
};
200+
jest.spyOn(initializationModule, 'initialize').mockReturnValueOnce({
201+
WithMeta: { state: {}, metadata: fakeMetadata },
202+
NoMeta: { state: {} },
203+
} as never);
204+
205+
wallet = new Wallet(options);
206+
207+
expect(wallet.controllerMetadata).toStrictEqual({
208+
WithMeta: fakeMetadata,
209+
});
210+
expect(Object.keys(wallet.state)).toStrictEqual(['WithMeta', 'NoMeta']);
211+
});
212+
213+
it('publishes Wallet:destroyed exactly once on destroy', async () => {
214+
wallet = new Wallet(options);
215+
216+
const listener = jest.fn();
217+
wallet.messenger.subscribe('Wallet:destroyed', listener);
218+
219+
await wallet.destroy();
220+
await wallet.destroy();
221+
222+
expect(listener).toHaveBeenCalledTimes(1);
223+
});
224+
225+
it('publishes Wallet:destroyed even if a controller destroy throws synchronously', async () => {
226+
wallet = new Wallet(options);
227+
228+
jest
229+
.spyOn(TransactionController.prototype, 'destroy')
230+
.mockImplementation(() => {
231+
throw new Error('sync destroy error');
232+
});
233+
234+
const listener = jest.fn();
235+
wallet.messenger.subscribe('Wallet:destroyed', listener);
236+
237+
await wallet.destroy();
238+
239+
expect(listener).toHaveBeenCalledTimes(1);
240+
});
241+
242+
it('publishes Wallet:destroyed even if a controller destroy rejects', async () => {
243+
wallet = new Wallet(options);
244+
245+
jest
246+
.spyOn(TransactionController.prototype, 'destroy')
247+
.mockRejectedValue(new Error('async destroy error') as never);
248+
249+
const listener = jest.fn();
250+
wallet.messenger.subscribe('Wallet:destroyed', listener);
251+
252+
await wallet.destroy();
253+
254+
expect(listener).toHaveBeenCalledTimes(1);
255+
});
256+
});
257+
});

packages/wallet/src/index.test.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

packages/wallet/test/anvil.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { spawn } from 'node:child_process';
2+
import type { ChildProcess } from 'node:child_process';
3+
import { access } from 'node:fs/promises';
4+
import { resolve } from 'node:path';
5+
6+
const ANVIL_STARTUP_TIMEOUT = 15_000;
7+
8+
export type AnvilInstance = {
9+
port: number;
10+
rpcUrl: string;
11+
stop: () => Promise<void>;
12+
};
13+
14+
/**
15+
* Start a local Anvil dev chain instance.
16+
*
17+
* @param options - Options for the Anvil instance.
18+
* @param options.mnemonic - The mnemonic to use for pre-funded accounts.
19+
* @returns An object with the port, RPC URL, and a stop function.
20+
*/
21+
export async function startAnvil(options: {
22+
mnemonic: string;
23+
}): Promise<AnvilInstance> {
24+
const anvilBin = await getAnvilBinaryPath();
25+
26+
const proc: ChildProcess = spawn(
27+
anvilBin,
28+
['--mnemonic', options.mnemonic, '--port', '0'],
29+
{ stdio: ['ignore', 'pipe', 'pipe'] },
30+
);
31+
32+
const port = await waitForReady(proc);
33+
const rpcUrl = `http://127.0.0.1:${port}`;
34+
35+
return {
36+
port,
37+
rpcUrl,
38+
stop: () => stopAnvil(proc),
39+
};
40+
}
41+
42+
async function getAnvilBinaryPath(): Promise<string> {
43+
const candidates = [
44+
resolve(__dirname, '../node_modules/.bin/anvil'),
45+
resolve(__dirname, '../../../node_modules/.bin/anvil'),
46+
];
47+
48+
for (const candidate of candidates) {
49+
try {
50+
await access(candidate);
51+
return candidate;
52+
} catch {
53+
// not found, try next
54+
}
55+
}
56+
57+
throw new Error(
58+
`Anvil binary not found. Run: yarn workspace @metamask/wallet run test:prepare`,
59+
);
60+
}
61+
62+
function waitForReady(proc: ChildProcess): Promise<number> {
63+
return new Promise((resolvePromise, reject) => {
64+
const timeout = setTimeout(() => {
65+
proc.kill();
66+
reject(new Error('Anvil failed to start within timeout'));
67+
}, ANVIL_STARTUP_TIMEOUT);
68+
69+
let output = '';
70+
proc.stdout?.on('data', (data: Buffer) => {
71+
output += data.toString();
72+
const match = output.match(/Listening on [^\s:]+:(\d+)/u);
73+
if (match) {
74+
clearTimeout(timeout);
75+
resolvePromise(Number(match[1]));
76+
}
77+
});
78+
79+
proc.stderr?.on('data', (data: Buffer) => {
80+
output += data.toString();
81+
});
82+
83+
proc.on('error', (error) => {
84+
clearTimeout(timeout);
85+
reject(error);
86+
});
87+
88+
proc.on('exit', (code) => {
89+
clearTimeout(timeout);
90+
if (code !== null && code !== 0) {
91+
reject(new Error(`Anvil exited with code ${code}:\n${output}`));
92+
}
93+
});
94+
});
95+
}
96+
97+
function stopAnvil(proc: ChildProcess): Promise<void> {
98+
return new Promise((resolvePromise) => {
99+
if (proc.killed || proc.exitCode !== null) {
100+
resolvePromise();
101+
return;
102+
}
103+
proc.on('exit', () => resolvePromise());
104+
proc.kill('SIGTERM');
105+
setTimeout(() => {
106+
if (!proc.killed) {
107+
proc.kill('SIGKILL');
108+
}
109+
}, 5000).unref();
110+
});
111+
}

0 commit comments

Comments
 (0)