227 lines
9.1 KiB
TypeScript
227 lines
9.1 KiB
TypeScript
/**
|
|
* wallet-power-modal — cp424 (wallet security pass, staking UI).
|
|
*
|
|
* The op/math/signing this flow stands on is proven end-to-end by
|
|
* wallet-op-builders-smoke (28/28: VESTS math, exact-precision
|
|
* formatters, builder validation, genuine round-trip signing for all
|
|
* three ops incl. the hand-serialized withdraw_vesting). This smoke
|
|
* pins the UI GLUE around it — the parts that don't run in the sandbox
|
|
* (no DOM / no key entry) and so can't be exercised by the op smoke:
|
|
*
|
|
* 1. PowerModal wires each mode to the CORRECT op + signer, signs
|
|
* inside runWithActiveKey (active key never outlives the sync sign),
|
|
* broadcasts outside it, and wipes the password.
|
|
* 2. Power-DOWN sends the EXACT on-chain vesting_shares for "power down
|
|
* everything" (dust-free), converts a partial amount BP→VESTS, and
|
|
* shows the honest ~4-week release notice (never implied instant).
|
|
* 3. The amount reaches the signer only through the throwing
|
|
* formatters (a malformed number can't be signed) and is bounded to
|
|
* the available balance.
|
|
* 4. MyBalanceCard offers the buttons ONLY to a session that can sign
|
|
* active ops (a posting-only login can't), captures the pool figures
|
|
* the modal needs, and renders the modal lazily.
|
|
* 5. The card header reads "wallet" (not "balance") and the
|
|
* profile.wallet.* keys exist 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;
|
|
function check(name: string, cond: boolean, detail = ''): void {
|
|
if (cond) {
|
|
console.log(` ✓ ${name}`);
|
|
} else {
|
|
console.log(` ✗ ${name}${detail ? `\n ${detail}` : ''}`);
|
|
failures++;
|
|
}
|
|
}
|
|
|
|
// ─── 1. PowerModal: op + signer wiring ─────────────────────────────────
|
|
const modal = read('apps/web/src/lib/components/PowerModal.svelte');
|
|
|
|
check(
|
|
'imports both op builders (transfer_to_vesting + withdraw_vesting)',
|
|
/prepareUnsignedTransferToVesting/.test(modal) &&
|
|
/prepareUnsignedWithdrawVesting/.test(modal)
|
|
);
|
|
check(
|
|
'imports both signers (signTransferWithKey + signWithdrawVestingWithKey)',
|
|
/signTransferWithKey/.test(modal) && /signWithdrawVestingWithKey/.test(modal)
|
|
);
|
|
check(
|
|
'power-UP builds a SELF transfer_to_vesting (from === to === account)',
|
|
/prepareUnsignedTransferToVesting\(\s*account,\s*account,/.test(modal)
|
|
);
|
|
check(
|
|
'power-UP signs with signTransferWithKey; power-DOWN with signWithdrawVestingWithKey',
|
|
/mode === 'up'[\s\S]*?signTransferWithKey\(unsignedTx, activePriv\)[\s\S]*?signWithdrawVestingWithKey\(unsignedTx, activePriv\)/.test(
|
|
modal
|
|
)
|
|
);
|
|
check(
|
|
'signs INSIDE runWithActiveKey (key never outlives the sync sign)',
|
|
/runWithActiveKey\(passwordInput,\s*async \(activePriv\)/.test(modal)
|
|
);
|
|
check(
|
|
'broadcasts via broadcastSignedTransaction after signing (r.value → broadcastSigned → broadcastSignedTransaction)',
|
|
/broadcastSigned\(r\.value\)/.test(modal) && /broadcastSignedTransaction\(/.test(modal)
|
|
);
|
|
check('wipes the password after the active-key call', /passwordInput = '';/.test(modal));
|
|
|
|
// ─── 2. Power-down: dust-free "everything" + BP→VESTS + honest schedule ─
|
|
check(
|
|
'"power down everything" sends the EXACT on-chain vesting_shares (no round-trip)',
|
|
/usingFullBalance\s*\?\s*vestingSharesRaw/.test(modal)
|
|
);
|
|
check(
|
|
'a partial power-down converts BP→VESTS via blurtPowerToVests + formatVestsAmount',
|
|
/formatVestsAmount\(blurtPowerToVests\(amountNum, vestingFund, totalVests\)\)/.test(modal)
|
|
);
|
|
check(
|
|
'a manual amount edit clears the full-balance flag (usingFullBalance = false)',
|
|
/function onAmountInput\(\)[\s\S]*?usingFullBalance = false/.test(modal)
|
|
);
|
|
check(
|
|
'power-DOWN shows the honest release notice (schedule key + ⏳)',
|
|
/mode === 'down'[\s\S]*?profile\.wallet\.power_down_schedule/.test(modal) &&
|
|
/⏳/.test(modal)
|
|
);
|
|
check(
|
|
'the schedule copy says "over 4 weeks" and that it is NOT instant',
|
|
/over 4 weeks/i.test(read('apps/web/src/lib/i18n/locales/en.json')) &&
|
|
/isn't instant/i.test(read('apps/web/src/lib/i18n/locales/en.json'))
|
|
);
|
|
|
|
// ─── 3. Amount safety ──────────────────────────────────────────────────
|
|
check(
|
|
'the amount reaches the signer only via the throwing formatters',
|
|
/formatBlurtAmount\(amountNum\)/.test(modal)
|
|
);
|
|
check(
|
|
'the amount is bounded to the available balance (cannot exceed it)',
|
|
/amountNum <= available/.test(modal)
|
|
);
|
|
|
|
// ─── 4. MyBalanceCard: gating, capture, lazy render ────────────────────
|
|
const card = read('apps/web/src/lib/components/MyBalanceCard.svelte');
|
|
|
|
// tt.txt #11 — CAPABILITY, not provenance. A 'posting-active' session keeps a
|
|
// verified Active key on this device and can power up/down; asking
|
|
// `origin === 'morphit-seed'` would deny it. Power-up is signed with the same
|
|
// active key a transfer is, so it rides the same gate.
|
|
check(
|
|
'active-key gate: hasActiveKey derives from the KEY, not the origin',
|
|
/hasActiveKey = \$derived\(\(\$liveIdentity\?\.activePublicKey \?\? null\) !== null\)/.test(card)
|
|
);
|
|
// Match CODE, not prose: the fix's own doc-comment quotes the buggy expression
|
|
// it replaced, and a naive grep sees it and "fails". Recurring lesson.
|
|
const cardCode = card
|
|
.replace(/<!--[\s\S]*?-->/g, '')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.split('\n')
|
|
.filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('//'))
|
|
.join('\n');
|
|
check('…and it is NOT the old provenance check', !/origin === 'morphit-seed'/.test(cardCode));
|
|
check(
|
|
'the power-up/down buttons are gated on hasActiveKey',
|
|
/#if hasActiveKey && blurtBalance > 0/.test(card) &&
|
|
/#if hasActiveKey && bpBalance > 0/.test(card)
|
|
);
|
|
check(
|
|
'the buttons open the right mode (openPower up / down)',
|
|
/openPower\('up'\)/.test(card) && /openPower\('down'\)/.test(card)
|
|
);
|
|
check(
|
|
'the card captures the raw pool figures + exact vesting_shares for the modal',
|
|
/vestingFund =/.test(card) && /totalVests =/.test(card) && /vestingSharesRaw =/.test(card)
|
|
);
|
|
check(
|
|
'the modal is lazy-loaded with a LazyLoadError fallback',
|
|
/loadPowerModal\(\) then PowerModal/.test(card) && /{:catch}\s*<LazyLoadError/.test(card)
|
|
);
|
|
check(
|
|
'a successful op refreshes the balance (onPowerDone → refresh)',
|
|
/function onPowerDone\(\)[\s\S]*?triggerBalanceRefresh\(\)[\s\S]*?refresh\(\{ hard: true \}\)/.test(
|
|
card
|
|
)
|
|
);
|
|
|
|
// ─── 5. Header rename + 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 WALLET_KEYS = [
|
|
'power_up_action',
|
|
'power_down_action',
|
|
'power_up_title',
|
|
'power_down_title',
|
|
'power_up_subtitle',
|
|
'power_down_subtitle',
|
|
'amount_label',
|
|
'power_up_placeholder',
|
|
'power_down_placeholder',
|
|
'use_full',
|
|
'available_blurt',
|
|
'available_bp',
|
|
'power_down_schedule',
|
|
'password_label',
|
|
'error_password_required',
|
|
'error_bad_password',
|
|
'error_no_active_key',
|
|
'error_broadcast',
|
|
'error_amount'
|
|
];
|
|
|
|
let localeMisses = 0;
|
|
let titleStillBalance = 0;
|
|
for (const loc of locales) {
|
|
const j = JSON.parse(read(`${LOC_DIR}/${loc}.json`));
|
|
const w = j?.profile?.wallet;
|
|
for (const k of WALLET_KEYS) {
|
|
if (typeof w?.[k] !== 'string' || w[k].length === 0) localeMisses++;
|
|
}
|
|
// The title interpolates {account}; it must no longer read "balance"
|
|
// in EN (the rename), and must be a non-empty string everywhere.
|
|
const title = j?.profile?.my_balance?.title;
|
|
if (typeof title !== 'string' || title.length === 0) titleStillBalance++;
|
|
}
|
|
check(
|
|
`all ${WALLET_KEYS.length} profile.wallet.* keys present in all ${locales.length} locales`,
|
|
localeMisses === 0,
|
|
`${localeMisses} missing`
|
|
);
|
|
const enTitle = JSON.parse(read(`${LOC_DIR}/en.json`)).profile.my_balance.title as string;
|
|
check(
|
|
'the EN header now says "wallet", not "balance"',
|
|
/wallet/i.test(enTitle) && !/balance/i.test(enTitle)
|
|
);
|
|
check('every locale has a non-empty header title', titleStillBalance === 0);
|
|
|
|
// cp453 — "Use full balance" must FLOOR to 3dp, never toFixed-ROUND (which for a
|
|
// raw balance like 74.8176 yields "74.818", a hair above the real ceiling, so
|
|
// the fill then failed the `<= available` check — the reported power-down bug).
|
|
check(
|
|
'"Use full balance" fills the FLOORED ceiling (availableFloor via floorToBlurtPrecision)',
|
|
/const availableFloor = \$derived\(floorToBlurtPrecision\(available\)\)/.test(modal) &&
|
|
/enteredAmount = availableFloor;/.test(modal) &&
|
|
/import \{ floorToBlurtPrecision \}/.test(modal)
|
|
);
|
|
check(
|
|
'the full-balance fill is NOT the rounding available.toFixed(3) (could exceed available)',
|
|
!/enteredAmount = available\.toFixed\(3\)/.test(modal)
|
|
);
|
|
|
|
if (failures === 0) {
|
|
console.log('✓ all 25 wallet-power-modal scenarios passed');
|
|
} else {
|
|
console.log(`\n✗ ${failures}/25 wallet-power-modal scenarios failed`);
|
|
process.exit(1);
|
|
}
|