|
| 1 | +import {test, expect, byModel} from './fixtures.mjs'; |
| 2 | + |
| 3 | +// The hermetic tests below cover the settings/keys UI only — scanning |
| 4 | +// needs a block-dn server. The full pipeline (header sync, binary tweak |
| 5 | +// download, ECDH matching, block identification, spent-ness lookup) is |
| 6 | +// covered by the opt-in @live test at the bottom, which scans real signet |
| 7 | +// data for a known payment: |
| 8 | +// |
| 9 | +// E2E_LIVE=1 npx playwright test silentpayments --project chromium-prod |
| 10 | +// |
| 11 | +// It requires a signet block-dn that serves the binary /sp/tweaks format |
| 12 | +// (block-dn v1.4.1+) and re-downloads chain data into a fresh browser |
| 13 | +// profile, so expect a few minutes of wall time. |
| 14 | +const scanButton = (page) => page.getByRole('button', {name: 'Scan'}); |
| 15 | +const resultRows = (page) => page.locator('table tbody tr'); |
| 16 | + |
| 17 | +// A real signet payment used as a permanent test vector: 100,000 sats to |
| 18 | +// the address below, mined in block 313,552. |
| 19 | +const LIVE = { |
| 20 | + scanPriv: |
| 21 | + 'd93a9e640712daf16349c137b5b10b60aae1329fe4be304dd438918e0537241d', |
| 22 | + spendPub: |
| 23 | + '0333e80ffc460ec39f9db07f99a55f393860a19d038e173ef6916935429e80db60', |
| 24 | + address: |
| 25 | + 'tsp1qqw5e8ymuncmhf5hwcmxz96ujhxsqfgnvaywm8zrw3w7nxu6e8vxnuqenaq8lc3' + |
| 26 | + 'swcw0emvrlnxj47wfcvzse6quwzul0dytfx4pfaqxmvqlahfz2', |
| 27 | + fromHeight: '312000', |
| 28 | + outpoint: |
| 29 | + 'd804ace479eab81985d01db7aa0914b0f56479e48dc1358ed5efcdb9b50293f5:0', |
| 30 | + height: '313552', |
| 31 | + // Deterministic for these keys + transaction; cross-verified through the |
| 32 | + // Node engine (tools/sp-demo.mjs) against the same server data. |
| 33 | + privKeyTweak: |
| 34 | + 'b8b36ea2a2a8401d78f0b23de29b403e8f02247b8e7c5d442e992918d636cf9b', |
| 35 | +}; |
| 36 | + |
| 37 | +test.describe('silentpayments', () => { |
| 38 | + test.beforeEach(async ({gotoPage}) => { |
| 39 | + await gotoPage('/silentpayments'); |
| 40 | + }); |
| 41 | + |
| 42 | + test('defaults: Signet preselected, dust level 0, empty result table @smoke', async ({page}) => { |
| 43 | + const network = byModel(page, 'vm.network'); |
| 44 | + await expect(network.locator('option').first()).toHaveText('Signet'); |
| 45 | + await expect(network.locator('option:checked')).toHaveText('Signet'); |
| 46 | + await expect(byModel(page, 'vm.server')) |
| 47 | + .toHaveValue('https://signet.block-dn.org'); |
| 48 | + await expect(byModel(page, 'vm.batchSize')).toHaveValue('4'); |
| 49 | + |
| 50 | + const dust = byModel(page, 'vm.dustLimit'); |
| 51 | + await expect(dust.locator('option')).toHaveCount(4); |
| 52 | + await expect(dust.locator('option:checked')) |
| 53 | + .toHaveText('0 sats (complete scan)'); |
| 54 | + await expect(dust.locator('option').last()) |
| 55 | + .toHaveText('3750 sats (fastest, economical outputs only)'); |
| 56 | + |
| 57 | + await expect(scanButton(page)).toBeEnabled(); |
| 58 | + await expect(page.getByText('none found yet')).toBeVisible(); |
| 59 | + }); |
| 60 | + |
| 61 | + test('key validation rejects malformed input without scanning', async ({page}) => { |
| 62 | + // Empty keys: the scan private key is checked first. |
| 63 | + await scanButton(page).click(); |
| 64 | + await expect(page.locator('.alert-danger')) |
| 65 | + .toContainText('scan private key must be 32 bytes'); |
| 66 | + |
| 67 | + // Valid scan key, malformed spend key. |
| 68 | + await byModel(page, 'vm.scanPrivKey').fill(LIVE.scanPriv); |
| 69 | + await byModel(page, 'vm.spendPubKey').fill('02abcd'); |
| 70 | + await scanButton(page).click(); |
| 71 | + await expect(page.locator('.alert-danger')) |
| 72 | + .toContainText('spend public key must be a 33-byte'); |
| 73 | + |
| 74 | + // Validation fails synchronously — no engine was opened, the page |
| 75 | + // stays idle. |
| 76 | + await expect(scanButton(page)).toBeEnabled(); |
| 77 | + await expect(page.getByText('none found yet')).toBeVisible(); |
| 78 | + }); |
| 79 | + |
| 80 | + test('network switch auto-fills the server, custom entries persist', async ({page}) => { |
| 81 | + const server = byModel(page, 'vm.server'); |
| 82 | + await byModel(page, 'vm.network').selectOption({label: 'Mainnet'}); |
| 83 | + await expect(server).toHaveValue('https://block-dn.org'); |
| 84 | + |
| 85 | + // A self-hosted server survives switching networks. |
| 86 | + await server.fill('http://localhost:8080'); |
| 87 | + await byModel(page, 'vm.network').selectOption({label: 'Signet'}); |
| 88 | + await expect(server).toHaveValue('http://localhost:8080'); |
| 89 | + }); |
| 90 | + |
| 91 | + test('real signet scan finds the block-313552 payment @live', async ({page, consoleErrors}) => { |
| 92 | + test.skip(!process.env.E2E_LIVE, |
| 93 | + 'live network test — set E2E_LIVE=1 to run against signet'); |
| 94 | + // Fresh profile: full signet header + filter-header sync, then a |
| 95 | + // ~1,600-block scan over a spam-heavy range. |
| 96 | + test.setTimeout(900_000); |
| 97 | + |
| 98 | + await byModel(page, 'vm.scanPrivKey').fill(LIVE.scanPriv); |
| 99 | + await byModel(page, 'vm.spendPubKey').fill(LIVE.spendPub); |
| 100 | + await byModel(page, 'vm.fromHeight').fill(LIVE.fromHeight); |
| 101 | + await byModel(page, 'vm.dustLimit') |
| 102 | + .selectOption({label: '0 sats (complete scan)'}); |
| 103 | + await scanButton(page).click(); |
| 104 | + |
| 105 | + // The scan announces itself, derives the address, and streams |
| 106 | + // per-range timing lines into the log. |
| 107 | + const log = page.locator('pre'); |
| 108 | + await expect(log).toContainText( |
| 109 | + 'scanning from height 312000 at dust filter level 0', |
| 110 | + {timeout: 120_000}); |
| 111 | + await expect(page.locator('body')).toContainText(LIVE.address, |
| 112 | + {timeout: 300_000}); |
| 113 | + |
| 114 | + // The known payment appears as a found output... |
| 115 | + const row = resultRows(page).filter({hasText: LIVE.outpoint}); |
| 116 | + await expect(row).toHaveCount(1, {timeout: 840_000}); |
| 117 | + await expect(row).toContainText('100,000'); |
| 118 | + await expect(row).toContainText(LIVE.height); |
| 119 | + await expect(row).toContainText('base'); |
| 120 | + // ...with a resolved spent-ness (not the '?' fallback) and the exact |
| 121 | + // private key tweak. Regexes don't get Playwright's whitespace |
| 122 | + // normalization, so tolerate the template's cell padding explicitly. |
| 123 | + await expect(row.locator('td').nth(5)).toHaveText(/^\s*(yes|spent)\s*$/); |
| 124 | + await expect(row.locator('td').last()) |
| 125 | + .toHaveText(new RegExp(`^\\s*${LIVE.privKeyTweak}\\s*$`)); |
| 126 | + |
| 127 | + // The run completes with stats + timing breakdown lines (the stats |
| 128 | + // line shows both as the summary paragraph and in the log). |
| 129 | + await expect(log).toContainText('outputs found', {timeout: 120_000}); |
| 130 | + await expect(log).toContainText('ecdh derive'); |
| 131 | + await expect(page.locator('p').filter({hasText: 'eligible txs scanned'})) |
| 132 | + .toBeVisible(); |
| 133 | + |
| 134 | + // A live CDN occasionally serves a transient 5xx that the client |
| 135 | + // retries transparently; the browser still logs it as a resource |
| 136 | + // error. Those are expected here — real failures (page errors, |
| 137 | + // application console.error calls) still fail the test. |
| 138 | + const transient = (e) => e.startsWith('console.error: Failed to load resource'); |
| 139 | + for (let i = consoleErrors.length - 1; i >= 0; i--) { |
| 140 | + if (transient(consoleErrors[i])) { |
| 141 | + consoleErrors.splice(i, 1); |
| 142 | + } |
| 143 | + } |
| 144 | + }); |
| 145 | +}); |
0 commit comments