Skip to content

Commit fcfec5b

Browse files
Merge branch 'master' into CECHO-962
2 parents a324313 + 04686fd commit fcfec5b

32 files changed

Lines changed: 2282 additions & 664 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ playwright/.cache/
2121

2222
# Idea IntelliJ
2323
.idea/
24+
*.tsbuildinfo

e2e/sign-psbt.spec.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Sign PSBT E2E Tests', () => {
4+
test.beforeEach(async ({ page }) => {
5+
// Navigate to test environment home page
6+
// The playwright config has webServer configured to start vite on localhost:5173
7+
await page.goto('http://localhost:5173/#/test');
8+
await page.waitForLoadState('networkidle');
9+
});
10+
11+
test('Sign PSBT link should be accessible from home page', async ({ page }) => {
12+
// Navigate directly to the sign-psbt page instead of finding the link
13+
await page.goto('http://localhost:5173/#/test/sign-psbt');
14+
await page.waitForURL('**/sign-psbt');
15+
expect(page.url()).toContain('sign-psbt');
16+
});
17+
18+
test('should render Sign PSBT form correctly', async ({ page }) => {
19+
// Navigate directly to sign-psbt page
20+
await page.goto('http://localhost:5173/#/test/sign-psbt');
21+
await page.waitForLoadState('networkidle');
22+
23+
// Check that the form heading is visible
24+
const heading = page.locator('h4:has-text("Sign Unsigned PSBT")');
25+
await expect(heading).toBeVisible();
26+
});
27+
28+
test('Sign PSBT form should render all required fields', async ({ page }) => {
29+
// Navigate directly to sign-psbt page
30+
await page.goto('http://localhost:5173/#/test/sign-psbt');
31+
await page.waitForLoadState('networkidle');
32+
33+
// Check for all form fields using more specific selectors
34+
await expect(page.locator('label:has-text("Coin")')).toBeVisible();
35+
await expect(page.locator('label:has-text("Unsigned PSBT")')).toBeVisible();
36+
await expect(page.locator('label:has-text("User Key")')).toBeVisible();
37+
await expect(page.locator('button:has-text("Sign PSBT")')).toBeVisible();
38+
// Cancel is rendered as a Link component
39+
await expect(page.locator('a:has-text("Cancel")')).toBeVisible();
40+
});
41+
42+
test('Sign PSBT form should show optional recipient fields', async ({ page }) => {
43+
await page.goto('http://localhost:5173/#/test/sign-psbt');
44+
await page.waitForLoadState('networkidle');
45+
46+
// Check optional fields using more specific selectors
47+
await expect(page.locator('label:has-text("Recipient Address")')).toBeVisible();
48+
await expect(page.locator('label:has-text("Fee Rate")')).toBeVisible();
49+
});
50+
51+
test('Passphrase field should be hidden initially', async ({ page }) => {
52+
await page.goto('http://localhost:5173/#/test/sign-psbt');
53+
await page.waitForLoadState('networkidle');
54+
55+
// Passphrase should not be visible when no userKey is entered
56+
const passphraseLabel = page.locator('text=Wallet Passphrase');
57+
await expect(passphraseLabel).not.toBeVisible();
58+
});
59+
60+
test('Coin selector should have UTXO options', async ({ page }) => {
61+
await page.goto('http://localhost:5173/#/test/sign-psbt');
62+
await page.waitForLoadState('networkidle');
63+
64+
// Find the coin selector
65+
const coinSelect = page.locator('[name="coin"]');
66+
await expect(coinSelect).toBeVisible();
67+
68+
// Check for expected coin options (option elements exist in DOM)
69+
const tbtcOption = coinSelect.locator('option:has-text("TBTC")');
70+
const btcOption = coinSelect.locator('option:has-text("BTC")');
71+
72+
// Options exist in DOM but may be hidden, so check they're attached
73+
expect(await tbtcOption.count()).toBeGreaterThan(0);
74+
expect(await btcOption.count()).toBeGreaterThan(0);
75+
});
76+
77+
test('Form should accept PSBT and user key input', async ({ page }) => {
78+
await page.goto('http://localhost:5173/#/test/sign-psbt');
79+
await page.waitForLoadState('networkidle');
80+
81+
// Fill in the form
82+
const coinSelect = page.locator('[name="coin"]');
83+
await coinSelect.selectOption('tbtc');
84+
85+
const psbtTextarea = page.locator('[name="psbt"]');
86+
await psbtTextarea.fill('70736274ff010000');
87+
88+
const userKeyTextarea = page.locator('[name="userKey"]');
89+
await userKeyTextarea.fill('tprvAA2oWoHxCkMDMedFAEgCAJEdSYq5vTTPZp9VuCFJnNxYQhGJxp7JGZoAZiXNkFsLXQtqeQFZefFxvTFSzz2kDCzKe3tKo8GJqQ4pA3mbMK');
90+
91+
// Verify values are set
92+
const psbtValue = await psbtTextarea.inputValue();
93+
expect(psbtValue).toBe('70736274ff010000');
94+
});
95+
96+
test('Passphrase field should show when encrypted key is entered', async ({ page }) => {
97+
await page.goto('http://localhost:5173/#/test/sign-psbt');
98+
await page.waitForLoadState('networkidle');
99+
100+
const userKeyTextarea = page.locator('[name="userKey"]');
101+
102+
// Enter an encrypted key (JSON format, not starting with xprv/tprv)
103+
const encryptedKey = '{"iv":"abc123"}';
104+
await userKeyTextarea.fill(encryptedKey);
105+
106+
// Passphrase field should now be visible
107+
const passphraseField = page.locator('[name="walletPassphrase"]');
108+
await expect(passphraseField).toBeVisible();
109+
});
110+
111+
test('Sign button should be enabled initially', async ({ page }) => {
112+
await page.goto('http://localhost:5173/#/test/sign-psbt');
113+
await page.waitForLoadState('networkidle');
114+
115+
// Get the sign button
116+
const signButton = page.locator('button:has-text("Sign PSBT")');
117+
await expect(signButton).toBeVisible();
118+
119+
// Button should be enabled initially
120+
const isDisabled = await signButton.isDisabled();
121+
expect(isDisabled).toBe(false);
122+
});
123+
124+
test('Cancel button should navigate back to home', async ({ page }) => {
125+
await page.goto('http://localhost:5173/#/test/sign-psbt');
126+
await page.waitForLoadState('networkidle');
127+
128+
// Cancel is rendered as a Link component, not a button
129+
const cancelLink = page.locator('a:has-text("Cancel")');
130+
await expect(cancelLink).toBeVisible();
131+
132+
// Click cancel and wait for navigation
133+
await cancelLink.click();
134+
await page.waitForLoadState('networkidle');
135+
136+
// Verify we navigated away from sign-psbt
137+
expect(page.url()).not.toContain('sign-psbt');
138+
});
139+
});
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+
});

e2e/utxo/blockchain-recovery.spec.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { test, expect, _electron as electron } from '@playwright/test';
1+
import { test, expect } from '@playwright/test';
22
import type { ElectronApplication, Page } from '@playwright/test';
3+
import { launchApp } from './helpers';
34

45
function stubRecoveryHandlers(app: ElectronApplication) {
56
return app.evaluate(({ ipcMain }) => {
@@ -18,9 +19,7 @@ test.describe('UTXO blockchain recovery', () => {
1819
let page: Page;
1920

2021
test.beforeEach(async () => {
21-
app = await electron.launch({ args: ['.', '--no-sandbox'] });
22-
page = await app.firstWindow();
23-
await page.waitForSelector('#root');
22+
({ app, page } = await launchApp());
2423
await stubRecoveryHandlers(app);
2524
});
2625

e2e/utxo/helpers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { _electron as electron } from '@playwright/test';
2+
import type { ElectronApplication, Page } from '@playwright/test';
3+
4+
export async function launchApp(): Promise<{ app: ElectronApplication; page: Page }> {
5+
const app = await electron.launch({
6+
args: ['.', '--no-sandbox'],
7+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '' },
8+
});
9+
const page = await app.firstWindow();
10+
await app.evaluate(({ BrowserWindow }) => {
11+
BrowserWindow.getAllWindows()[0].setSize(1440, 900);
12+
});
13+
await page.waitForSelector('#root');
14+
return { app, page };
15+
}

e2e/utxo/sign-psbt.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { test, expect } from '@playwright/test';
2+
import type { ElectronApplication, Page } from '@playwright/test';
3+
import { fixedScriptWallet, BIP32 } from '@bitgo/wasm-utxo';
4+
import { launchApp } from './helpers';
5+
6+
const { BitGoPsbt, RootWalletKeys, ChainCode } = fixedScriptWallet;
7+
8+
const RECIPIENT = 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx';
9+
const FEE_RATE = '10';
10+
11+
function buildFixture() {
12+
const userKey = BIP32.fromSeedSha256('default.0');
13+
const backupKey = BIP32.fromSeedSha256('default.1');
14+
const bitgoKey = BIP32.fromSeedSha256('default.2');
15+
16+
const walletKeys = RootWalletKeys.from({
17+
triple: [userKey, backupKey, bitgoKey],
18+
derivationPrefixes: ['m/0/0', 'm/0/0', 'm/0/0'],
19+
});
20+
21+
const psbt = BitGoPsbt.createEmpty('tbtc', walletKeys, { version: 2, lockTime: 0 });
22+
23+
const externalChain = ChainCode.value('p2wsh', 'external');
24+
psbt.addWalletInput(
25+
{ txid: '0'.repeat(64), vout: 0, value: BigInt(1_000_000) },
26+
walletKeys,
27+
{ scriptId: { chain: externalChain, index: 0 }, signPath: { signer: 'user', cosigner: 'bitgo' } },
28+
);
29+
// No outputs — the backend derives the output from inputs + fee rate.
30+
31+
return {
32+
psbtBase64: Buffer.from(psbt.serialize()).toString('base64'),
33+
userXprv: userKey.toBase58(),
34+
};
35+
}
36+
37+
function stubFileHandlers(app: ElectronApplication) {
38+
return app.evaluate(({ ipcMain }) => {
39+
for (const ch of ['showSaveDialog', 'writeFile']) ipcMain.removeHandler(ch);
40+
ipcMain.handle('showSaveDialog', () => ({ filePath: '/tmp/test-signed.psbt', canceled: false }));
41+
ipcMain.handle('writeFile', () => undefined);
42+
});
43+
}
44+
45+
test.describe('Sign PSBT – real signing', () => {
46+
let app: ElectronApplication;
47+
let page: Page;
48+
49+
test.beforeEach(async () => {
50+
({ app, page } = await launchApp());
51+
await stubFileHandlers(app);
52+
});
53+
54+
test.afterEach(async () => {
55+
await app.close();
56+
});
57+
58+
test('signs a tbtc p2wsh PSBT with the user key', async () => {
59+
const { psbtBase64, userXprv } = buildFixture();
60+
61+
await page.evaluate(() => { window.location.hash = '/test/sign-psbt'; });
62+
await page.waitForSelector('[name="coin"]');
63+
await page.screenshot({ path: 'test-results/sign-psbt-1-form-loaded.png' });
64+
65+
await page.selectOption('[name="coin"]', 'tbtc');
66+
await page.fill('[name="psbt"]', psbtBase64);
67+
await page.fill('[name="userKey"]', userXprv);
68+
await page.fill('[name="recipientAddress"]', RECIPIENT);
69+
await page.fill('[name="feeRateSatVB"]', FEE_RATE);
70+
await page.screenshot({ path: 'test-results/sign-psbt-2-filled.png' });
71+
72+
await page.click('button[type="submit"]');
73+
await page.screenshot({ path: 'test-results/sign-psbt-3-submitted.png' });
74+
75+
const errorDiv = page.locator('.tw-text-red-700');
76+
await Promise.race([
77+
page.waitForURL(/sign-psbt\/success/, { timeout: 20_000 }),
78+
errorDiv.waitFor({ state: 'visible', timeout: 20_000 }).then(async () => {
79+
const msg = await errorDiv.textContent();
80+
throw new Error(`Sign PSBT failed: ${msg}`);
81+
}),
82+
]);
83+
84+
await expect(page.getByText('We recommend')).toBeVisible();
85+
});
86+
});

0 commit comments

Comments
 (0)