240 lines
9.3 KiB
TypeScript
240 lines
9.3 KiB
TypeScript
/**
|
|
* send-blurt-modal — cp424 (wallet security pass, Send UI).
|
|
*
|
|
* The transfer op + signing are proven by wallet-op-builders-smoke; this
|
|
* pins the Send UI glue that can't run in the sandbox (no DOM / network /
|
|
* key entry). Send is riskier than the staking modals because the
|
|
* RECIPIENT is user-entered, so the guards below matter most:
|
|
*
|
|
* 1. Two-stage recipient validation — instant FORMAT check
|
|
* (isValidBlurtAccount) + not-self, THEN a debounced ON-CHAIN
|
|
* existence check (fetchAccountBalance → 404 = not_found). Send stays
|
|
* disabled until the recipient resolves to a REAL account, so a typo
|
|
* can't fire BLURT into a void.
|
|
* 2. The lookup is debounced AND guarded against a stale field (the
|
|
* value may change while a timer/round-trip is in flight).
|
|
* 3. The amount is bounded to the balance and only reaches the signer
|
|
* through the throwing formatter.
|
|
* 4. The memo carries a PROMINENT plaintext-public privacy warning.
|
|
* 5. Signs inside runWithActiveKey, broadcasts outside, wipes password.
|
|
* 6. MyBalanceCard offers Send only to a signing-capable session and
|
|
* lazy-loads the modal; profile.send.* exists in all 10 locales.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const repo = join(here, '..', '..', '..');
|
|
const read = (rel: string): string => readFileSync(join(repo, rel), 'utf8');
|
|
|
|
let failures = 0;
|
|
// cp442 — `total` was hardcoded as 29 in the summary line while the file ran
|
|
// more checks than that, so the battery under-counted this smoke's assertions.
|
|
// Count for real.
|
|
let total = 0;
|
|
function check(name: string, cond: boolean, detail = ''): void {
|
|
total++;
|
|
if (cond) {
|
|
console.log(` ✓ ${name}`);
|
|
} else {
|
|
console.log(` ✗ ${name}${detail ? `\n ${detail}` : ''}`);
|
|
failures++;
|
|
}
|
|
}
|
|
|
|
const modal = read('apps/web/src/lib/components/SendBlurtModal.svelte');
|
|
|
|
// ─── 1. Transfer op + signer wiring ────────────────────────────────────
|
|
check(
|
|
'imports the transfer op builder + signer + broadcast',
|
|
/prepareUnsignedTransfer\b/.test(modal) &&
|
|
/signTransferWithKey/.test(modal) &&
|
|
/broadcastSignedTransaction/.test(modal)
|
|
);
|
|
check(
|
|
'signs INSIDE runWithActiveKey (key never outlives the sync sign)',
|
|
/runWithActiveKey\(passwordInput,\s*async \(activePriv\)/.test(modal) &&
|
|
/signTransferWithKey\(unsignedTx, activePriv\)/.test(modal)
|
|
);
|
|
check(
|
|
'broadcasts after signing + wipes the password',
|
|
/broadcastSignedTransaction\(r\.value\)/.test(modal) && /passwordInput = '';/.test(modal)
|
|
);
|
|
check(
|
|
'the transfer is built from the NORMALIZED recipient (not the raw field)',
|
|
/const to = normalizeAccount\(recipient\)/.test(modal) &&
|
|
/prepareUnsignedTransfer\(\s*account,\s*to,/.test(modal)
|
|
);
|
|
|
|
// ─── 2. Two-stage recipient validation ─────────────────────────────────
|
|
check(
|
|
'stage 1 — instant FORMAT check via isValidBlurtAccount',
|
|
/isValidBlurtAccount\(norm\)/.test(modal)
|
|
);
|
|
check(
|
|
'stage 1 — rejects sending to yourself',
|
|
/norm === account[\s\S]*?recipientState = 'self'/.test(modal)
|
|
);
|
|
check(
|
|
'stage 2 — ON-CHAIN existence via fetchAccountBalance',
|
|
/fetchAccountBalance\(resolveOrigin\(MORPHIT_INDEXER_ORIGIN\), norm\)/.test(modal)
|
|
);
|
|
check(
|
|
"stage 2 — a 404 maps to 'not_found' (typo can't send into a void)",
|
|
/result\.kind === 'not_found'[\s\S]*?recipientState = 'not_found'/.test(modal) ||
|
|
/'not_found'/.test(modal)
|
|
);
|
|
check(
|
|
'the on-chain lookup is DEBOUNCED (not fired every keystroke)',
|
|
/debounceTimer = setTimeout\(/.test(modal) && /clearTimeout\(debounceTimer\)/.test(modal)
|
|
);
|
|
check(
|
|
'the lookup is guarded against a stale field (before AND after the round-trip)',
|
|
(modal.match(/normalizeAccount\(recipient\) !== norm/g) || []).length >= 2
|
|
);
|
|
check(
|
|
'the recipient is normalized (strip @, lowercase)',
|
|
/replace\(\/\^@\+\/,\s*''\)\.toLowerCase\(\)/.test(modal)
|
|
);
|
|
|
|
// ─── 3. Send is gated on a VALID recipient + a valid amount ─────────────
|
|
check(
|
|
"canSend requires recipientState === 'valid'",
|
|
/canSend = \$derived\([\s\S]*?recipientState === 'valid'/.test(modal)
|
|
);
|
|
// cp442 — the bound moved into the pure `$lib/blurt/sendValidation` module (so
|
|
// it can be unit-tested), but it must still be ENFORCED and the amount must
|
|
// still reach the signer through the throwing formatter.
|
|
const sendValidationSrc = readFileSync(
|
|
join(import.meta.dirname, '..', 'src', 'lib', 'blurt', 'sendValidation.ts'),
|
|
'utf-8'
|
|
);
|
|
check(
|
|
'the amount is bounded to the balance + reaches the signer via the throwing formatter',
|
|
/validateBlurtAmount\(amountInput, blurtBalance\)/.test(modal) &&
|
|
/n <= balance \+ 1e-6/.test(sendValidationSrc) &&
|
|
/formatBlurtAmount\(amountNum\)/.test(modal)
|
|
);
|
|
check(
|
|
'the amount cannot be silently ROUNDED by toFixed(3) (1.0006 -> 1.001)',
|
|
/\^\\d\*\(\\\.\\d\{0,3\}\)\?\$/.test(sendValidationSrc)
|
|
);
|
|
check(
|
|
'canSend also requires the active-key password',
|
|
/canSend = \$derived\([\s\S]*?passwordFilled/.test(modal)
|
|
);
|
|
|
|
// ─── 4. Memo privacy ───────────────────────────────────────────────────
|
|
check(
|
|
'the memo shows a PROMINENT plaintext-public privacy warning (⚠ + key)',
|
|
/profile\.send\.memo_privacy_warning/.test(modal) && /⚠/.test(modal)
|
|
);
|
|
check('the memo is trimmed before it goes on chain', /memo\.trim\(\)/.test(modal));
|
|
check('the debounce timer is cleared on destroy', /onDestroy\([\s\S]*?clearTimeout\(debounceTimer\)/.test(modal));
|
|
|
|
// ─── 5. MyBalanceCard: gating, lazy render, refresh ────────────────────
|
|
const card = read('apps/web/src/lib/components/MyBalanceCard.svelte');
|
|
check('the Send button is gated on hasActiveKey', /#if hasActiveKey}\s*<button[\s\S]*?openSend/.test(card) || (/openSend/.test(card) && /#if hasActiveKey/.test(card)));
|
|
check('the Send button opens the modal (openSend)', /onclick=\{openSend\}/.test(card));
|
|
check(
|
|
'the Send modal is lazy-loaded with a LazyLoadError fallback',
|
|
/loadSendModal\(\) then SendBlurtModal/.test(card) && /<SendBlurtModal/.test(card)
|
|
);
|
|
check(
|
|
'a successful send refreshes the balance (onSendDone → refresh)',
|
|
/function onSendDone\(\)[\s\S]*?triggerBalanceRefresh\(\)[\s\S]*?refresh\(\{ hard: true \}\)/.test(
|
|
card
|
|
)
|
|
);
|
|
|
|
// ─── 6. Recipient QR scanner (untrusted decode → same validation) ──────
|
|
const scanner = read('apps/web/src/lib/components/RecipientQrScanner.svelte');
|
|
|
|
check(
|
|
'SendBlurtModal has a scan button that opens the scanner',
|
|
/onclick=\{openScanner\}/.test(modal) && /scanning = true/.test(modal)
|
|
);
|
|
check(
|
|
'the QR scan button is mobile-only (sm:hidden — hidden on desktop/PC)',
|
|
/onclick=\{openScanner\}[\s\S]{0,400}sm:hidden/.test(modal)
|
|
);
|
|
check(
|
|
'the scanner is lazy-loaded with a LazyLoadError fallback',
|
|
/loadScanner\(\) then RecipientQrScanner/.test(modal) && /{:catch}\s*<LazyLoadError/.test(modal)
|
|
);
|
|
check(
|
|
'a scanned value is treated as UNTRUSTED — re-run through onRecipientInput',
|
|
/function onScannedRecipient[\s\S]*?recipient = candidate[\s\S]*?onRecipientInput\(\)/.test(modal)
|
|
);
|
|
check(
|
|
'the scanner extracts only the account via extractRecipientFromQr',
|
|
/import \{ extractRecipientFromQr \}/.test(scanner) &&
|
|
/extractRecipientFromQr\(data\)/.test(scanner)
|
|
);
|
|
check(
|
|
'an empty extraction shows an error, it does NOT fill the field',
|
|
/candidate\.length === 0[\s\S]*?phase = 'invalid'/.test(scanner) &&
|
|
/onScanned\(candidate\)/.test(scanner)
|
|
);
|
|
check(
|
|
'the camera starts only on a user gesture (requestCamera, not on mount)',
|
|
/onclick=\{requestCamera\}/.test(scanner) && !/onMount\([\s\S]*?startScanner/.test(scanner)
|
|
);
|
|
check(
|
|
'the camera is torn down on destroy (onDestroy stopScanner)',
|
|
/onDestroy\(stopScanner\)/.test(scanner) && /inst\?\.destroy\?\.\(\)/.test(scanner)
|
|
);
|
|
|
|
|
|
// ─── 7. Locale coverage ────────────────────────────────────────────────
|
|
const LOC_DIR = 'apps/web/src/lib/i18n/locales';
|
|
const locales = readdirSync(join(repo, LOC_DIR))
|
|
.filter((f) => f.endsWith('.json'))
|
|
.map((f) => f.replace(/\.json$/, ''));
|
|
const SEND_KEYS = [
|
|
'title',
|
|
'subtitle',
|
|
'recipient_label',
|
|
'recipient_placeholder',
|
|
'recipient_checking',
|
|
'recipient_valid',
|
|
'recipient_not_found',
|
|
'recipient_invalid',
|
|
'recipient_self',
|
|
'recipient_error',
|
|
'amount_placeholder',
|
|
'memo_label',
|
|
'memo_placeholder',
|
|
'memo_privacy_warning',
|
|
'error_no_active_key',
|
|
'send_button',
|
|
'confirm',
|
|
'qr_heading',
|
|
'qr_body',
|
|
'qr_start_button',
|
|
'qr_scanning_hint',
|
|
'qr_camera_denied',
|
|
'qr_no_camera',
|
|
'qr_invalid'
|
|
];
|
|
let miss = 0;
|
|
for (const loc of locales) {
|
|
const send = JSON.parse(read(`${LOC_DIR}/${loc}.json`))?.profile?.send;
|
|
for (const k of SEND_KEYS) {
|
|
if (typeof send?.[k] !== 'string' || send[k].length === 0) miss++;
|
|
}
|
|
}
|
|
check(
|
|
`all ${SEND_KEYS.length} profile.send.* keys present in all ${locales.length} locales`,
|
|
miss === 0,
|
|
`${miss} missing`
|
|
);
|
|
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${total} send-blurt-modal scenarios passed`);
|
|
} else {
|
|
console.log(`\n✗ ${failures}/${total} send-blurt-modal scenarios failed`);
|
|
process.exit(1);
|
|
}
|