Skip to content

Commit de91184

Browse files
committed
feat: add Solana nested ATA recovery to wallet-recovery-wizard
Adds a new "SOL Nested ATA" / "TSOL Nested ATA" flow under the Non-BitGo Recovery section of the wizard, allowing clients to self-serve recovery of SPL tokens stuck in a nested Associated Token Account without running scripts manually. - New NestedATAForm with all required keycard and ATA address fields - New recoverNestedAta IPC command wiring Sol.recoverNestedAta() - Coin metadata entries for solNestedATA / tsolNestedATA - Success page now shows txId and Solscan link when available - Bumps node engine constraint to >=20.10.0 and pins uuid to ^8.3.2 to resolve ESM/CJS conflict in @solana/buffer-layout-utils TICKET: COINS-148
1 parent 31351e9 commit de91184

10 files changed

Lines changed: 508 additions & 6 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { test, expect, _electron as electron } from '@playwright/test';
2+
import type { ElectronApplication, Page } from '@playwright/test';
3+
4+
const MOCK_TX_ID = 'mockTxId1234567890abcdef';
5+
6+
function stubNestedAtaHandlers(app: ElectronApplication) {
7+
return app.evaluate(
8+
({ ipcMain }, txId) => {
9+
for (const ch of ['setBitGoEnvironment', 'recoverNestedAta'])
10+
ipcMain.removeHandler(ch);
11+
ipcMain.handle('setBitGoEnvironment', () => undefined);
12+
ipcMain.handle('recoverNestedAta', () => ({ txId }));
13+
},
14+
MOCK_TX_ID
15+
);
16+
}
17+
18+
test.describe('Solana nested ATA recovery', () => {
19+
let app: ElectronApplication;
20+
let page: Page;
21+
22+
test.beforeEach(async () => {
23+
app = await electron.launch({ args: ['.', '--no-sandbox'] });
24+
page = await app.firstWindow();
25+
await page.waitForSelector('#root');
26+
await stubNestedAtaHandlers(app);
27+
});
28+
29+
test.afterEach(async () => {
30+
await app.close();
31+
});
32+
33+
test('renders NestedATAForm for solNestedATA coin', async () => {
34+
await page.evaluate(() => {
35+
window.location.hash = '/prod/non-bitgo-recovery/solNestedATA';
36+
});
37+
38+
await page.waitForSelector('[name="nestedAtaAddress"]');
39+
40+
await expect(page.locator('[name="userKey"]')).toBeVisible();
41+
await expect(page.locator('[name="backupKey"]')).toBeVisible();
42+
await expect(page.locator('[name="bitgoKey"]')).toBeVisible();
43+
await expect(page.locator('[name="walletPassphrase"]')).toBeVisible();
44+
await expect(page.locator('[name="recoveryDestination"]')).toBeVisible();
45+
await expect(page.locator('[name="nestedAtaAddress"]')).toBeVisible();
46+
await expect(page.locator('[name="ownerAtaAddress"]')).toBeVisible();
47+
await expect(page.locator('[name="tokenMintAddress"]')).toBeVisible();
48+
});
49+
50+
test('shows validation errors when submitted empty', async () => {
51+
await page.evaluate(() => {
52+
window.location.hash = '/prod/non-bitgo-recovery/solNestedATA';
53+
});
54+
await page.waitForSelector('[name="nestedAtaAddress"]');
55+
56+
await page.click('button[type="submit"]');
57+
58+
// Formik marks fields as touched on submit — the Textarea component signals
59+
// errors via tw-border-red-500 (not aria-invalid)
60+
await expect(page.locator('[name="userKey"]')).toHaveClass(/tw-border-red-500/);
61+
await expect(page.locator('[name="nestedAtaAddress"]')).toHaveClass(/tw-border-red-500/);
62+
});
63+
64+
test('completes recovery and shows txId on success page', async () => {
65+
await page.evaluate(() => {
66+
window.location.hash = '/prod/non-bitgo-recovery/solNestedATA';
67+
});
68+
await page.waitForSelector('[name="nestedAtaAddress"]');
69+
70+
await page.fill('[name="userKey"]', '{"iv":"abc","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","ct":"encryptedUserKey"}');
71+
await page.fill('[name="backupKey"]', '{"iv":"def","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","ct":"encryptedBackupKey"}');
72+
await page.fill('[name="bitgoKey"]', 'xpub661MyMwAqRbcGHoJePkfLFqgzNBjqAqMjZaRVPMd3su2mTJ7HJQNFVkBkzVHBG7yWVZvLgXSZDmDj8mqqVFnzWijVhKanGj6ygSWfzFQ6eB');
73+
await page.fill('[name="walletPassphrase"]', 'test-passphrase-123');
74+
await page.fill('[name="recoveryDestination"]', '7YbcLmVorrH7KCKMj38rFidsruisWi2CmvCTs4cygf8K');
75+
await page.fill('[name="nestedAtaAddress"]', 'FGuZSBhtreqSUsE86xokyjKz2i8VBtJzy6uMXXKyGHug');
76+
await page.fill('[name="ownerAtaAddress"]', 'Zfm98ZpVafydhFTYcsY6bHgubhB4cFgWFvbdEJxYhTA');
77+
await page.fill('[name="tokenMintAddress"]', 'ZBCNpuD7YMXzTHB2fhGkGi78MNsHGLRXUhRewNRm9RU');
78+
79+
await page.click('button[type="submit"]');
80+
81+
await page.waitForURL(/non-bitgo-recovery\/solNestedATA\/success/, { timeout: 10_000 });
82+
await expect(page.getByText(MOCK_TX_ID)).toBeVisible();
83+
await expect(page.getByText('View on Solscan')).toBeVisible();
84+
});
85+
86+
test('renders NestedATAForm for tsolNestedATA coin (testnet)', async () => {
87+
await page.evaluate(() => {
88+
window.location.hash = '/test/non-bitgo-recovery/tsolNestedATA';
89+
});
90+
await page.waitForSelector('[name="nestedAtaAddress"]');
91+
await expect(page.locator('[name="nestedAtaAddress"]')).toBeVisible();
92+
});
93+
});

electron/main/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,12 @@ async function createWindow() {
458458
);
459459
});
460460

461+
ipcMain.handle('recoverNestedAta', async (event, coin, parameters) => {
462+
const baseCoin = sdk.coin(coin) as Sol;
463+
const openSSLBytes = loadWebAssembly().buffer;
464+
return await baseCoin.recoverNestedAta({ ...parameters, openSSLBytes });
465+
});
466+
461467
ipcMain.handle('broadcastTransaction', async (event, coin, parameters) => {
462468
const baseCoin = sdk.coin(coin);
463469
return await baseCoin.broadcastTransaction(parameters);

electron/preload/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ type Commands = {
8181
showSaveDialog(
8282
options: Electron.SaveDialogOptions
8383
): Promise<Electron.SaveDialogReturnValue>;
84+
recoverNestedAta(
85+
coin: string,
86+
parameters: {
87+
userKey: string;
88+
backupKey: string;
89+
bitgoKey: string;
90+
walletPassphrase: string;
91+
recoveryDestination: string;
92+
nestedAtaAddress: string;
93+
ownerAtaAddress: string;
94+
tokenMintAddress: string;
95+
apiKey?: string;
96+
seed?: string;
97+
}
98+
): Promise<{ txId?: string }>;
8499
recover(
85100
coin: string,
86101
parameters: RecoverParams & {
@@ -205,6 +220,9 @@ const commands: Commands = {
205220
showSaveDialog(options) {
206221
return ipcRenderer.invoke('showSaveDialog', options);
207222
},
223+
recoverNestedAta(coin, parameters) {
224+
return ipcRenderer.invoke('recoverNestedAta', coin, parameters);
225+
},
208226
recover(coin, parameters) {
209227
return ipcRenderer.invoke('recover', coin, parameters);
210228
},

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default tseslint.config(
2121
'eslint.config.mjs',
2222
// Fails at the parser level — must use ignores, not per-file overrides
2323
'src/components/Title/Title.test.tsx',
24+
'src/containers/NonBitGoRecoveryCoin/NestedATAForm.test.tsx',
2425
],
2526
},
2627

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2+
import { MemoryRouter } from 'react-router-dom';
3+
import { vi } from 'vitest';
4+
import { NestedATAForm } from './NestedATAForm';
5+
6+
function renderForm(onSubmit = vi.fn()) {
7+
const result = render(
8+
<MemoryRouter>
9+
<NestedATAForm onSubmit={onSubmit} />
10+
</MemoryRouter>
11+
);
12+
const q = (name: string) =>
13+
result.container.querySelector<HTMLElement>(`[name="${name}"]`)!;
14+
return { ...result, q };
15+
}
16+
17+
function fillValidForm(q: (name: string) => HTMLElement) {
18+
fireEvent.change(q('userKey'), {
19+
target: { value: '{"iv":"abc","ct":"encryptedUserKey"}' },
20+
});
21+
fireEvent.change(q('backupKey'), {
22+
target: { value: '{"iv":"def","ct":"encryptedBackupKey"}' },
23+
});
24+
fireEvent.change(q('bitgoKey'), {
25+
target: { value: 'xpubBitGoKey' },
26+
});
27+
fireEvent.change(q('walletPassphrase'), {
28+
target: { value: 'test-passphrase' },
29+
});
30+
fireEvent.change(q('recoveryDestination'), {
31+
target: { value: '7YbcLmVorrH7KCKMj38rFidsruisWi2CmvCTs4cygf8K' },
32+
});
33+
fireEvent.change(q('nestedAtaAddress'), {
34+
target: { value: 'FGuZSBhtreqSUsE86xokyjKz2i8VBtJzy6uMXXKyGHug' },
35+
});
36+
fireEvent.change(q('ownerAtaAddress'), {
37+
target: { value: 'Zfm98ZpVafydhFTYcsY6bHgubhB4cFgWFvbdEJxYhTA' },
38+
});
39+
fireEvent.change(q('tokenMintAddress'), {
40+
target: { value: 'ZBCNpuD7YMXzTHB2fhGkGi78MNsHGLRXUhRewNRm9RU' },
41+
});
42+
}
43+
44+
describe('NestedATAForm', () => {
45+
it('renders all required fields', () => {
46+
const { q } = renderForm();
47+
48+
expect(q('userKey')).not.toBeNull();
49+
expect(q('backupKey')).not.toBeNull();
50+
expect(q('bitgoKey')).not.toBeNull();
51+
expect(q('walletPassphrase')).not.toBeNull();
52+
expect(q('recoveryDestination')).not.toBeNull();
53+
expect(q('nestedAtaAddress')).not.toBeNull();
54+
expect(q('ownerAtaAddress')).not.toBeNull();
55+
expect(q('tokenMintAddress')).not.toBeNull();
56+
expect(q('apiKey')).not.toBeNull();
57+
});
58+
59+
it('renders submit button with correct label', () => {
60+
renderForm();
61+
expect(screen.getByRole('button', { name: /recover tokens/i })).not.toBeNull();
62+
});
63+
64+
it('calls onSubmit with correct values when form is valid', async () => {
65+
const onSubmit = vi.fn();
66+
const { q } = renderForm(onSubmit);
67+
68+
fillValidForm(q);
69+
fireEvent.click(screen.getByRole('button', { name: /recover tokens/i }));
70+
71+
await waitFor(() => {
72+
expect(onSubmit).toHaveBeenCalledOnce();
73+
const [values] = onSubmit.mock.calls[0] as [Record<string, string>][];
74+
expect(values.nestedAtaAddress).toBe('FGuZSBhtreqSUsE86xokyjKz2i8VBtJzy6uMXXKyGHug');
75+
expect(values.ownerAtaAddress).toBe('Zfm98ZpVafydhFTYcsY6bHgubhB4cFgWFvbdEJxYhTA');
76+
expect(values.tokenMintAddress).toBe('ZBCNpuD7YMXzTHB2fhGkGi78MNsHGLRXUhRewNRm9RU');
77+
expect(values.recoveryDestination).toBe('7YbcLmVorrH7KCKMj38rFidsruisWi2CmvCTs4cygf8K');
78+
});
79+
});
80+
81+
it('does not call onSubmit when required fields are empty', async () => {
82+
const onSubmit = vi.fn();
83+
renderForm(onSubmit);
84+
85+
fireEvent.click(screen.getByRole('button', { name: /recover tokens/i }));
86+
87+
await waitFor(() => {
88+
expect(onSubmit).not.toHaveBeenCalled();
89+
});
90+
});
91+
92+
it('rejects API key that looks like a URL', async () => {
93+
const onSubmit = vi.fn();
94+
const { q } = renderForm(onSubmit);
95+
96+
fillValidForm(q);
97+
fireEvent.change(q('apiKey'), {
98+
target: { value: 'https://eth-mainnet.g.alchemy.com/v2/somekey' },
99+
});
100+
fireEvent.click(screen.getByRole('button', { name: /recover tokens/i }));
101+
102+
await waitFor(() => {
103+
expect(onSubmit).not.toHaveBeenCalled();
104+
expect(screen.getByText(/api key should not be a url/i)).not.toBeNull();
105+
});
106+
});
107+
108+
it('accepts a valid optional API key', async () => {
109+
const onSubmit = vi.fn();
110+
const { q } = renderForm(onSubmit);
111+
112+
fillValidForm(q);
113+
fireEvent.change(q('apiKey'), {
114+
target: { value: 'validApiKey123' },
115+
});
116+
fireEvent.click(screen.getByRole('button', { name: /recover tokens/i }));
117+
118+
await waitFor(() => {
119+
expect(onSubmit).toHaveBeenCalledOnce();
120+
});
121+
});
122+
});

0 commit comments

Comments
 (0)