A stored allowedSites whose value was not a list rendered a working popup and then made every subsequent save fail silently, so the user operated a wallet that persisted nothing -- worse than a blank popup, which is at least visibly broken. fraudContracts and selectedToken had the same shape: a container floored by truthiness or not at all, while its entries were dereferenced. Entries are now floored as well as containers, following the idiom #311 established, and a failed save raises a persistent banner instead of vanishing into a swallowed rejection. The per-field justifications that used to live in a hand-written header are replaced by a contract test that drives each field's hostile and falsy values through a real popup boot, so a claim about a field answers to the code rather than to prose. Its guarantee is stated narrowly and deliberately: no structural dereference on the code paths a wholly-corrupted profile takes, which is not every path a stored record takes. The paths it does not drive are named where the claim is made, and are tracked in #379.
243 lines
9.3 KiB
JavaScript
243 lines
9.3 KiB
JavaScript
// Rendering a view the popup lands on without having navigated to it
|
|
// forward: on restore, and on Back. In both cases the view may never have
|
|
// been rendered in this page load — a reopened popup renders only the
|
|
// wallet list and the view it restores onto, so every other view is still
|
|
// the blank static template from index.html — so unhiding it is not enough.
|
|
//
|
|
// Forward navigation renders as it goes and must NOT come through here:
|
|
// rendering a second time would re-fetch and clobber whatever the view has
|
|
// in flight.
|
|
//
|
|
// The view modules are injected and nothing here touches the DOM, so the
|
|
// dispatch and its data guards can be tested directly; src/popup/index.js
|
|
// cannot be required outside a browser.
|
|
|
|
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
|
|
|
|
// The views this page load has rendered.
|
|
//
|
|
// The Back path cannot otherwise tell its two cases apart. A view the popup
|
|
// never rendered is still the blank template from index.html and has to be
|
|
// rendered; a view already on the page must NOT be rendered again, because
|
|
// a second render re-fetches and overwrites whatever the user has typed
|
|
// into it and not yet saved.
|
|
//
|
|
// Registration is showView() in views/helpers.js, which is the last thing
|
|
// every render path runs — restoreView()'s, the Back path's, and every
|
|
// forward show(). That is the point of putting it there rather than in the
|
|
// individual views: a view added later registers itself with no one having
|
|
// to remember it, so this cannot decay.
|
|
//
|
|
// Module scope is page-load scope: the popup loads this module once per
|
|
// page load, and a reopened popup gets a fresh, empty set — which is
|
|
// exactly the state that makes the Back path render.
|
|
const renderedViews = new Set();
|
|
|
|
function markViewRendered(view) {
|
|
if (view) renderedViews.add(view);
|
|
}
|
|
|
|
// Begin a fresh page-load scope. The popup gets one by being loaded; the
|
|
// unit tests, which simulate several page loads against one module
|
|
// instance, ask for one.
|
|
function resetRenderedViews() {
|
|
renderedViews.clear();
|
|
}
|
|
|
|
// Home is the exception: Back re-renders it every time, which is what the
|
|
// popup did before this router existed (index.js registered
|
|
// renderWalletList() as setRenderMain(), and goBack() called it on every
|
|
// Back onto "main"). It must stay that way — the wallet list has to reflect
|
|
// what changed while the user was away from it, such as a wallet renamed or
|
|
// an address removed in Settings — and Home holds no unsaved input to lose.
|
|
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
|
|
|
|
// Views that render an address the user picked and cannot be rendered
|
|
// without one. "confirm-tx" is here because its Sign button dereferences
|
|
// `state.wallets[state.selectedWallet].encryptedSecret`
|
|
// (src/popup/views/confirmTx.js) behind no guard of its own — a screen that
|
|
// can only throw when the user presses its one button must not be restored
|
|
// onto.
|
|
const ADDRESS_VIEWS = new Set([
|
|
"address",
|
|
"address-token",
|
|
"receive",
|
|
"transaction",
|
|
"confirm-tx",
|
|
]);
|
|
|
|
function needsAddress(view) {
|
|
return ADDRESS_VIEWS.has(view);
|
|
}
|
|
|
|
function hasValidAddress(state) {
|
|
return Boolean(
|
|
state.selectedWallet !== null &&
|
|
state.selectedAddress !== null &&
|
|
state.wallets[state.selectedWallet] &&
|
|
state.wallets[state.selectedWallet].addresses[state.selectedAddress],
|
|
);
|
|
}
|
|
|
|
// The stored viewData ENTRIES each branch below dereferences, as opposed to
|
|
// the one field it gates on.
|
|
//
|
|
// A gate on a single truthy field checks the container, not the entries, and
|
|
// the dereference is one level below it: a stored `{"currentView":
|
|
// "success-tx","viewData":{"hash":"0x1"}}` passes `data.hash` and then throws
|
|
// on `address.toLowerCase()` inside addressTitle() (src/popup/views/
|
|
// helpers.js), out of restoreView(), which src/popup/index.js does not guard —
|
|
// so the rest of popup init never runs. txStatus.restoreWait() has checked its
|
|
// own branch's fields since it was written; these are the other four.
|
|
//
|
|
// Only what actually throws is required. Fields that are compared,
|
|
// concatenated or escaped coerce (escapeHtml() and displaySymbol() both
|
|
// String() their argument), so requiring them would refuse a restorable screen
|
|
// over a cosmetic value.
|
|
function isText(value) {
|
|
return typeof value === "string";
|
|
}
|
|
|
|
function isRecord(value) {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
// An address handed to renderAddressHtml()/addressTitle(): both reach
|
|
// `address.slice()` and `address.toLowerCase()` with no guard.
|
|
function isAddressText(value) {
|
|
return isText(value);
|
|
}
|
|
|
|
// Decoded calldata, as decodedDetailsHtml() (src/popup/views/txStatus.js)
|
|
// walks it: `for (const d of decoded.details)` needs an iterable, and each
|
|
// entry's `address` reaches toAddressHtml(). Absent or falsy is the ordinary
|
|
// case and short-circuits before either.
|
|
function isRenderableDecoded(value) {
|
|
if (!value) return true;
|
|
if (!isRecord(value)) return false;
|
|
if (!value.details) return true;
|
|
if (!Array.isArray(value.details)) return false;
|
|
return value.details.every(
|
|
(entry) =>
|
|
isRecord(entry) && (!entry.address || isAddressText(entry.address)),
|
|
);
|
|
}
|
|
|
|
// The pending transaction confirmTx.show() renders: `token` reaches
|
|
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
|
|
// addressTitle(), makeBlockie() and getLocalWarnings().
|
|
function isRenderablePendingTx(value) {
|
|
return (
|
|
isRecord(value) &&
|
|
isText(value.token) &&
|
|
isAddressText(value.from) &&
|
|
isAddressText(value.to)
|
|
);
|
|
}
|
|
|
|
// The stored transaction transactionDetail.render() shows. contractAddress is
|
|
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
|
|
function isRenderableTx(value) {
|
|
return (
|
|
isRecord(value) &&
|
|
isAddressText(value.from) &&
|
|
isAddressText(value.to) &&
|
|
(!value.contractAddress || isAddressText(value.contractAddress))
|
|
);
|
|
}
|
|
|
|
// Render `view` from persisted state. Each view module shows itself, so a
|
|
// true return means the view is both rendered and on screen.
|
|
//
|
|
// Returns false when the view is not one the popup renders from state, or
|
|
// when the state it would render is gone — a token no longer selected, a
|
|
// transaction no longer persisted. The caller falls back rather than
|
|
// putting an empty template on screen.
|
|
function renderView(view, state, views) {
|
|
if (!view || !RESTORABLE_VIEWS.has(view)) return false;
|
|
if (needsAddress(view) && !hasValidAddress(state)) return false;
|
|
if (view === "address-token" && !state.selectedToken) return false;
|
|
|
|
const data = state.viewData || {};
|
|
switch (view) {
|
|
case "main":
|
|
views.main.show();
|
|
return true;
|
|
case "address":
|
|
views.addressDetail.show();
|
|
return true;
|
|
case "address-token":
|
|
views.addressToken.show();
|
|
return true;
|
|
case "receive":
|
|
views.receive.show();
|
|
return true;
|
|
case "settings":
|
|
views.settings.show();
|
|
return true;
|
|
case "settings-addtoken":
|
|
views.settingsAddToken.show();
|
|
return true;
|
|
case "confirm-tx":
|
|
if (!isRenderablePendingTx(data.pendingTx)) return false;
|
|
views.confirmTx.restore();
|
|
return true;
|
|
case "transaction":
|
|
if (!isRenderableTx(data.tx)) return false;
|
|
views.transactionDetail.render();
|
|
return true;
|
|
case "wait-tx":
|
|
// Resumes the receipt poll from the persisted broadcast time,
|
|
// and answers false when there is nothing resumable left.
|
|
return Boolean(views.txStatus.restoreWait());
|
|
case "success-tx":
|
|
if (!data.hash) return false;
|
|
if (!isAddressText(data.to)) return false;
|
|
if (!isRenderableDecoded(data.decoded)) return false;
|
|
views.txStatus.renderSuccess();
|
|
return true;
|
|
case "error-tx":
|
|
if (!data.message) return false;
|
|
if (!isAddressText(data.to)) return false;
|
|
views.txStatus.renderError();
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The Back-path renderer, registered with setBackRenderer() in
|
|
// views/helpers.js.
|
|
//
|
|
// Returns false — leaving goBack() to unhide the view, as it always did —
|
|
// in the two cases where the view is known to be on the page already:
|
|
//
|
|
// - It is not one the popup renders from persisted state. The restored
|
|
// stack is filtered against RESTORABLE_VIEWS, so such a view can only
|
|
// be on the stack from this page load, where forward navigation
|
|
// rendered it on the way in.
|
|
// - This page load has rendered it. Re-rendering would re-fetch and
|
|
// clobber what it holds; Home is rendered anyway, see above.
|
|
//
|
|
// What is left is the case the router exists for: a view on the stack that
|
|
// this page load has never rendered, whose template is still blank.
|
|
function makeBackRenderer(state, views) {
|
|
return function renderBack(view) {
|
|
if (!RESTORABLE_VIEWS.has(view)) return false;
|
|
if (renderedViews.has(view) && !ALWAYS_RENDER_ON_BACK.has(view)) {
|
|
return false;
|
|
}
|
|
if (!renderView(view, state, views)) {
|
|
views.main.show();
|
|
}
|
|
return true;
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
renderView,
|
|
makeBackRenderer,
|
|
markViewRendered,
|
|
resetRenderedViews,
|
|
};
|