Compare commits

..

1 Commits

Author SHA1 Message Date
44b0a153f0 fix: floor the entries of the site maps and the fraud list, and stop a failed save from failing silently (closes #362)
All checks were successful
check / check (push) Successful in 32s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 33s
allowedSites was checked as a container while its entries were dereferenced unchecked. A stored {"0x...": "notalist"} passed the state gate and rendered a completely healthy popup, then threw "base.map is not a function" inside saveState()'s per-hostname merge, so every save from that moment on failed and the user went on operating a wallet that was persisting nothing. Measured against the previous head: the popup showed the main view with no page errors, and chrome.storage.local.set was never called at all. deniedSites has the identical shape; fraudContracts the same class with a milder consequence, throwing "(state.fraudContracts || []).map is not a function" on the send screen; and the sweep for the class turned up selectedToken, which is truthiness-gated on restore and then dereferenced as text, blanking the popup outright with "tokenId.toLowerCase is not a function".

All four now get the floor issue 311 settled -- the container AND its entries, with a malformed entry dropped -- through textList() and siteMap() beside the existing tokenRefs() in persistedState.js, rather than a third mechanism. Site-map keys are written with defineProperty for the same reason networkEndpoints' keys are: a stored own "__proto__" key would otherwise be handed to the prototype setter. The background's allowed.includes(hostname) gate is covered by the same floor, where a stored string would have answered a substring match rather than merely throwing.

A save that fails is no longer swallowed. onSaveFailure() in state.js reports every failed save, awaited or not -- the save queue has to attach a rejection handler to keep advancing, which is what made a failure disappear entirely -- and the popup raises a persistent "NOT SAVED" banner naming the reason. The popup's background refresh loop no longer turns a save failure into an unhandled rejection instead of a report. Both halves are needed: the floor only covers the causes it knows about, and storage can still fail for a quota or a revoked permission.

The field-by-field categorisation in the header of stateSchema.js, and its mirror in README.md, were re-verified against the code and moved with the change; the fields left on a loose floor now carry the reason each one is still safe. The popup boot harness moved to tests/support/popupBoot.js so the new tests drive the real entry point rather than duplicating it.
2026-08-23 18:22:01 +00:00
13 changed files with 196 additions and 1550 deletions

View File

@@ -1015,24 +1015,20 @@ saved data cannot be read and that nothing was signed or sent, rather than the
generic `-32603` every request used to answer. generic `-32603` every request used to answer.
Every other field of the record is floored in `normalizePersisted()` rather than Every other field of the record is floored in `normalizePersisted()` rather than
gated, and the floor is not the same for every field. Some are type-checked as a gated. That floor is a type check for the fields something dereferences
container AND entry by entry, because `[1, 2]` is a list, `{"0x…": "notalist"}` structurally — `trackedTokens`, each address's `tokenBalances`, `allowedSites`,
is an object, and the dereference is one level below the container check; a `deniedSites`, `fraudContracts`, `viewStack`, `networkEndpoints`, plus the
malformed entry is dropped, except in `networkEndpoints`, where the entry is scalars `networkId`, `activeAddress` and `selectedToken` — and it checks the
coerced so an unknown network's endpoints are not lost, and in `viewStack`, ENTRIES as well as the container, because `[1, 2]` is a list,
where the stack is truncated at the first entry the popup will not reopen onto. `{"0x…": "notalist"}` is an object, and the dereference is one level below the
Some are type-checked as a scalar. The rest take the stored value verbatim, container check. A malformed entry is dropped. The remaining fields get a
because nothing dereferences them structurally. `saved.x || default` or a present-or-default passthrough that takes the stored
value verbatim, with no type check at all; which field is in which category, and
Which field is which is not written in prose anywhere, deliberately. why the loose floor is still enough for each of them, is listed in the header of
`tests/persistedFieldContract.test.js` is the list: one row per persisted field, `src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS
naming the property that field's floor is claimed to have and proving it by dereferenced walks through truthiness and throws on the first read, which is the
driving the real code with hostile values — including a boot of the real popup blank popup again by a longer route — so adding a field means choosing between
entry point for every field whose only defence is that nothing dereferences it. the two by what reads it.
A field added to `PERSISTED_FIELDS` with no row fails `make check`, and so does
a row whose claim is false. The per-field justification that used to live in the
header of `src/shared/stateSchema.js` shipped a false claim in three consecutive
changes, each caught only by a reviewer re-deriving thirty fields by hand.
The `allowedSites` case is why the entry check is not optional. A stored The `allowedSites` case is why the entry check is not optional. A stored
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it `{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
@@ -1100,14 +1096,6 @@ than only unhiding it, through the same dispatch and data guards as the restore
(`src/popup/viewRouter.js`), and falls back to Home when the state the target (`src/popup/viewRouter.js`), and falls back to Home when the state the target
would render is gone. would render is gone.
Those data guards check the ENTRIES of the stored `viewData`, not just the one
field each branch gates on, and the same goes for `selectedWallet` and
`selectedAddress`. `restoreView()` is not inside a `try`, so a `TypeError` in a
renderer skips the rest of popup init and leaves the user with no view, no
message and no control — the same blank popup by a longer route. Anything the
restore path dereferences is therefore either floored in `normalizePersisted()`
or refused by the guard, and the screen falls back to Home instead.
It renders only a screen this page load has not rendered yet. Forward navigation It renders only a screen this page load has not rendered yet. Forward navigation
renders as it goes, and `viewRouter.js` records every screen that reaches renders as it goes, and `viewRouter.js` records every screen that reaches
`showView()`, so "Back" onto a screen already on the page unhides it and nothing `showView()`, so "Back" onto a screen already on the page unhides it and nothing

58
TODO.md
View File

@@ -53,47 +53,23 @@ but the review is broader than any of them.
per-hostname merge, so every save from that moment on failed silently and the per-hostname merge, so every save from that moment on failed silently and the
user went on operating a wallet that was persisting nothing — measured as user went on operating a wallet that was persisting nothing — measured as
`chrome.storage.local.set` never being called at all. `deniedSites` has the `chrome.storage.local.set` never being called at all. `deniedSites` has the
same shape, `fraudContracts` the same class with a milder consequence, and the same shape, `fraudContracts` the same class with a milder consequence (a
sweep for the class turned up `selectedToken`, `rpcUrl` (handed whole to broken send screen, since the boot path only reaches it through
`new JsonRpcProvider()`, which throws synchronously outside any `try`), the `loadHomeTxs()`, which catches), and the sweep for the class turned up
entries of `viewData` (four restore branches gate on one truthy field and then `selectedToken`, which blanked the popup outright when restoring onto
dereference an address), and `selectedWallet`/`selectedAddress` (a stored address-token. All four now get the floor
`"map"` is TRUTHY against a real Array, so the restore guard does not [#311](https://git.eeqj.de/sneak/AutistMask/issues/311) settled — container
short-circuit). All of them are now floored in `src/shared/persistedState.js` AND entries, malformed entries dropped — through `textList()` and `siteMap()`
or refused by the per-branch guards in `src/popup/viewRouter.js`. Separately, beside the existing `tokenRefs()` in `src/shared/persistedState.js`. Site-map
a save that fails is no longer swallowed: `onSaveFailure()` in keys are written with `defineProperty` for the same reason `networkEndpoints`'
`src/shared/state.js` reports every failed save, awaited or not, and the popup are. Separately, a save that fails is no longer swallowed: `onSaveFailure()`
raises a persistent "NOT SAVED" banner. The hand-written per-field in `src/shared/state.js` reports every failed save, awaited or not, and the
justification in the header of `src/shared/stateSchema.js` — which had shipped popup raises a persistent "NOT SAVED" banner; the background refresh loop no
a false claim in three consecutive changes — is replaced by longer turns a failure into an unhandled rejection instead of a report. The
`tests/persistedFieldContract.test.js`, one row per persisted field, each field-by-field categorisation in the header of `src/shared/stateSchema.js`,
proven by driving the real code with hostile values; a field with no row, or a and its mirror in `README.md`, were re-verified against the code and moved
row whose claim is false, now fails `make check`. with the change.
- 2026-08-23: A swap amount and the token it is counted in now always come from
the same hop, on both sides of the approval screen
([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and
[#364](https://git.eeqj.de/sneak/AutistMask/issues/364), the output and input
halves of one gate, fixed as one unit). `src/shared/uniswap.js` gated the
token and the amount on truthiness and independently; an address is never
falsy once set but an amount of `0n` is, so a hop supplying a zero amount
fixed the token and left the amount open, and the next hop's figure was then
rendered against the first hop's token at that token's scale — 0.5 WETH shown
as `500000000000.0000 USDT`, and an earlier hop's `Min. received` shown for a
final leg that guarantees nothing. Both sides are now set as a pair through
explicit presence, a zero slippage floor reads `None (no minimum guaranteed)`,
and V4's `OPEN_DELTA` (an `amountIn` of zero, which `V4Router` reads as "swap
the whole open credit") reads `All available (V4 open delta)` instead of
`0.0000`.
- 2026-08-23: A swap whose input token the calldata never named is said to be
unknown instead of being called ETH
([#357](https://git.eeqj.de/sneak/AutistMask/issues/357)), the twin on the
input side of [#353](https://git.eeqj.de/sneak/AutistMask/issues/353). A null
`inputToken` rendered as `Token In: ETH (native)` and titled the swap
`Swap ETH -> X`, asserting the user was paying native ETH when nothing in the
calldata said so. The null-means-ETH collapse is now gone from `tokenInfo()`
itself rather than guarded at each call site: null is refused, and native ETH
keeps arriving as the explicit zero address that `WRAP_ETH` and V4's
`Currency.wrap(address(0))` both use.
- 2026-08-23: A swap whose output token the calldata never named is said to be - 2026-08-23: A swap whose output token the calldata never named is said to be
unknown instead of being called ETH unknown instead of being called ETH
([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)` ([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)`

View File

@@ -53,17 +53,12 @@ function resetRenderedViews() {
const ALWAYS_RENDER_ON_BACK = new Set(["main"]); const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
// Views that render an address the user picked and cannot be rendered // Views that render an address the user picked and cannot be rendered
// without one. "confirm-tx" is here because its Sign button dereferences // without one.
// `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([ const ADDRESS_VIEWS = new Set([
"address", "address",
"address-token", "address-token",
"receive", "receive",
"transaction", "transaction",
"confirm-tx",
]); ]);
function needsAddress(view) { function needsAddress(view) {
@@ -79,73 +74,6 @@ function hasValidAddress(state) {
); );
} }
// 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 // Render `view` from persisted state. Each view module shows itself, so a
// true return means the view is both rendered and on screen. // true return means the view is both rendered and on screen.
// //
@@ -179,11 +107,11 @@ function renderView(view, state, views) {
views.settingsAddToken.show(); views.settingsAddToken.show();
return true; return true;
case "confirm-tx": case "confirm-tx":
if (!isRenderablePendingTx(data.pendingTx)) return false; if (!data.pendingTx) return false;
views.confirmTx.restore(); views.confirmTx.restore();
return true; return true;
case "transaction": case "transaction":
if (!isRenderableTx(data.tx)) return false; if (!data.tx) return false;
views.transactionDetail.render(); views.transactionDetail.render();
return true; return true;
case "wait-tx": case "wait-tx":
@@ -192,13 +120,10 @@ function renderView(view, state, views) {
return Boolean(views.txStatus.restoreWait()); return Boolean(views.txStatus.restoreWait());
case "success-tx": case "success-tx":
if (!data.hash) return false; if (!data.hash) return false;
if (!isAddressText(data.to)) return false;
if (!isRenderableDecoded(data.decoded)) return false;
views.txStatus.renderSuccess(); views.txStatus.renderSuccess();
return true; return true;
case "error-tx": case "error-tx":
if (!data.message) return false; if (!data.message) return false;
if (!isAddressText(data.to)) return false;
views.txStatus.renderError(); views.txStatus.renderError();
return true; return true;
default: default:

View File

@@ -123,75 +123,25 @@ function textList(value) {
// could widen a site permission rather than merely throw. // could widen a site permission rather than merely throw.
// //
// An address key whose value is not a list of hostnames is dropped entirely: it // An address key whose value is not a list of hostnames is dropped entirely: it
// grants and denies nothing, and dropping it fails closed. A stored own // grants and denies nothing, and dropping it fails closed. Keys are written
// "__proto__" key — which JSON can carry — is dropped for the same reason: it // with defineProperty for the same reason networkEndpoints' are — a stored own
// can never be a wallet address, so it grants nothing either, and keeping it // "__proto__" key, which JSON can carry, would otherwise be handed to the
// only keeps a value that saveState()'s merge would hand to the prototype // prototype setter and recorded nowhere.
// setter on the next write. Keys are written with defineProperty so that no key
// reaching this function can consult a setter at all, whatever the rule above
// it becomes; mergeMapByKey() in src/shared/state.js writes the same way.
function siteMap(value) { function siteMap(value) {
const out = {}; const out = {};
if (!isRecord(value)) return out; if (!isRecord(value)) return out;
for (const address of Object.keys(value)) { for (const address of Object.keys(value)) {
if (address === "__proto__") continue;
const hostnames = textList(value[address]); const hostnames = textList(value[address]);
if (hostnames.length === 0) continue; if (hostnames.length === 0) continue;
defineOwn(out, address, hostnames); Object.defineProperty(out, address, {
} value: hostnames,
return out;
}
// An endpoint URL: non-empty text, or the fallback.
function url(value, fallback) {
return typeof value === "string" && value !== "" ? value : fallback;
}
// One remembered endpoint pair out of networkEndpoints, floored on the two
// fields applyChainSwitchFields() (src/shared/chainSwitchFields.js) assigns
// STRAIGHT ONTO s.rpcUrl / s.blockscoutUrl on the next chain switch: flooring
// the live fields alone would leave a non-string sitting one switch away from
// them. A field that is not text is deleted rather than replaced, so the
// switch falls through its own `|| net.defaultRpcUrl`. Anything else the pair
// carries is kept: a profile that has been on a build storing more per-network
// fields must not lose them by passing through this one.
function endpointPair(value) {
const pair = { ...(isRecord(value) ? value : {}) };
for (const field of ["rpcUrl", "blockscoutUrl"]) {
if (typeof pair[field] !== "string" || pair[field] === "") {
delete pair[field];
}
}
return pair;
}
// A list index into wallets / a wallet's addresses: a non-negative integer, or
// null for "nothing selected".
//
// hasValidAddress() (src/popup/viewRouter.js) guards the restore path with
// `state.wallets[state.selectedWallet] && …addresses[state.selectedAddress]`,
// which is safe for a stale INTEGER — out of range is undefined, and the `&&`
// short-circuits — and NOT safe for a string naming an Array.prototype member.
// `wallets["map"]` is truthy, so the guard does not short-circuit and
// `.addresses[…]` throws out of restoreView(): the dead popup. "length",
// "constructor" and "__proto__" answer the same way, and
// src/popup/views/confirmTx.js dereferences selectedWallet behind no guard at
// all.
function listIndex(value) {
return Number.isInteger(value) && value >= 0 ? value : null;
}
// Write `key` as an own data property, never through a setter. Plain
// assignment of "__proto__" replaces the object's prototype and records no
// entry; every map built from stored keys goes through this.
function defineOwn(obj, key, value) {
Object.defineProperty(obj, key, {
value: value,
writable: true, writable: true,
enumerable: true, enumerable: true,
configurable: true, configurable: true,
}); });
} }
return out;
}
// Keep only the leading run of stored views the popup is willing to render. // Keep only the leading run of stored views the popup is willing to render.
// //
@@ -288,15 +238,8 @@ function normalizePersisted(saved) {
out.networkId = isKnownNetworkId(saved.networkId) out.networkId = isKnownNetworkId(saved.networkId)
? saved.networkId ? saved.networkId
: DEFAULT_STATE.networkId; : DEFAULT_STATE.networkId;
// Non-empty text or the default, never anything else. getProvider() out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
// (src/shared/balances.js) hands rpcUrl straight to `new out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// JsonRpcProvider()`, which throws SYNCHRONOUSLY for a value that is not a
// string — out of src/popup/views/txStatus.js and src/popup/views/
// addWallet.js, neither of which is inside a try, and the first of which a
// stored `currentView: "wait-tx"` reaches through restoreView(). It is a
// scalar, so the type check is the whole fix.
out.rpcUrl = url(saved.rpcUrl, DEFAULT_STATE.rpcUrl);
out.blockscoutUrl = url(saved.blockscoutUrl, DEFAULT_STATE.blockscoutUrl);
// An actual object is required, not merely a truthy non-array: the code // An actual object is required, not merely a truthy non-array: the code
// below and applyChainSwitchFields() index and ASSIGN INTO this value, and // below and applyChainSwitchFields() index and ASSIGN INTO this value, and
// assigning a property to a string or a number is a silent no-op in // assigning a property to a string or a number is a silent no-op in
@@ -310,16 +253,19 @@ function normalizePersisted(saved) {
: {}; : {};
out.networkEndpoints = {}; out.networkEndpoints = {};
for (const netId of Object.keys(rawEndpoints)) { for (const netId of Object.keys(rawEndpoints)) {
// Keys other than the known network ids are kept rather than dropped, // defineProperty, not assignment: a stored map with an own
// so a profile that has been on a build with more networks does not // "__proto__" key — which JSON can carry and assignment treats as the
// lose their endpoints by passing through this one. That is why an own // prototype setter — would otherwise replace this object's prototype
// "__proto__" key survives here where siteMap() drops it, and why the // and record no entry at all. Keys other than the known network ids
// write has to go through defineOwn(). // are kept rather than dropped, so a profile that has been on a build
defineOwn( // with more networks does not lose their endpoints by passing through
out.networkEndpoints, // this one.
netId, Object.defineProperty(out.networkEndpoints, netId, {
endpointPair(rawEndpoints[netId]), value: { ...rawEndpoints[netId] },
); writable: true,
enumerable: true,
configurable: true,
});
} }
// A profile written before this map existed carries exactly one pair of // A profile written before this map existed carries exactly one pair of
// endpoints, belonging to whatever network it was last on. Adopt it as // endpoints, belonging to whatever network it was last on. Adopt it as
@@ -388,8 +334,10 @@ function normalizePersisted(saved) {
out.theme = saved.theme || "system"; out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false; out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
out.currentView = saved.currentView || null; out.currentView = saved.currentView || null;
out.selectedWallet = listIndex(saved.selectedWallet); out.selectedWallet =
out.selectedAddress = listIndex(saved.selectedAddress); saved.selectedWallet !== undefined ? saved.selectedWallet : null;
out.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
// "ETH", or a contract address, or null — never anything else. The popup // "ETH", or a contract address, or null — never anything else. The popup
// restores onto "address-token" behind a truthiness check on this field and // restores onto "address-token" behind a truthiness check on this field and
// then dereferences it as text (`tokenId.toLowerCase()` in // then dereferences it as text (`tokenId.toLowerCase()` in

View File

@@ -310,26 +310,12 @@ function mergeAddress(base, ours, theirs) {
// a key another page edited. Unlike an array's identity function, an object // a key another page edited. Unlike an array's identity function, an object
// key can't collide with a different logical entry (Object.keys() is // key can't collide with a different logical entry (Object.keys() is
// already deduplicated), so this needs no collision floor of its own. // already deduplicated), so this needs no collision floor of its own.
//
// Every write goes through defineProperty rather than assignment. The keys are
// whatever the stored record carries, and plain assignment of "__proto__" —
// which JSON can carry and normalizePersisted() keeps for networkEndpoints —
// replaces this object's prototype and records no entry. That would undo one
// layer downstream exactly what defineOwn() does in
// src/shared/persistedState.js.
function mergeMapByKey(base, ours, theirs, mergeLeaf) { function mergeMapByKey(base, ours, theirs, mergeLeaf) {
base = base || {}; base = base || {};
ours = ours || {}; ours = ours || {};
theirs = theirs || {}; theirs = theirs || {};
const result = {}; const result = {};
const seen = new Set(); const seen = new Set();
const put = (key, value) =>
Object.defineProperty(result, key, {
value: value,
writable: true,
enumerable: true,
configurable: true,
});
for (const key of Object.keys(theirs)) { for (const key of Object.keys(theirs)) {
seen.add(key); seen.add(key);
@@ -337,16 +323,16 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
const inOurs = Object.prototype.hasOwnProperty.call(ours, key); const inOurs = Object.prototype.hasOwnProperty.call(ours, key);
if (inBase && !inOurs) continue; // this page deleted the whole entry if (inBase && !inOurs) continue; // this page deleted the whole entry
if (inOurs) { if (inOurs) {
put(key, mergeLeaf(base[key], ours[key], theirs[key])); result[key] = mergeLeaf(base[key], ours[key], theirs[key]);
} else { } else {
put(key, theirs[key]); result[key] = theirs[key];
} }
} }
for (const key of Object.keys(ours)) { for (const key of Object.keys(ours)) {
if (seen.has(key)) continue; if (seen.has(key)) continue;
if (!Object.prototype.hasOwnProperty.call(base, key)) { if (!Object.prototype.hasOwnProperty.call(base, key)) {
put(key, ours[key]); result[key] = ours[key];
} }
} }

View File

@@ -26,31 +26,44 @@
// //
// What is checked HERE is what nothing downstream can floor: the wallet list, // What is checked HERE is what nothing downstream can floor: the wallet list,
// the version, and the network id that keys an object. Every other field is // the version, and the network id that keys an object. Every other field is
// normalizePersisted()'s to make safe, and what that function does is NOT // normalizePersisted()'s to make safe, and what that function does today is
// uniform across the record. // NOT uniform. The four kinds of floor it applies, listed so a reader can tell
// which one a given field has without reading it off:
// //
// WHICH FLOOR A GIVEN FIELD HAS IS NOT WRITTEN HERE. It is // Type-checked, container AND entries: trackedTokens, each address's
// tests/persistedFieldContract.test.js: one row per persisted field, naming // tokenBalances, allowedSites, deniedSites, fraudContracts, viewStack,
// the property that field's floor is claimed to have, and PROVING it by // networkEndpoints. These are the fields something dereferences
// driving the real code with hostile values — the gate for a field the gate // structurally — iterated, indexed, assigned into, or .toLowerCase()'d —
// refuses, normalizePersisted() for a field it floors, and a boot of the real // where a truthy value of the wrong type throws on the first read. The
// popup entry point for a field whose only defence is that nothing // entries matter as much as the container: [1, 2] IS a list, {"0x…":
// dereferences it structurally. A field added to PERSISTED_FIELDS with no row // "notalist"} IS an object, and the dereference is one level below the
// fails that suite; so does a row whose claim is false. // container check. A malformed entry is dropped. networkEndpoints is the
// one whose entries are coerced rather than dropped: each value is spread
// into a fresh record, so a stored scalar becomes a record with no
// rpcUrl/blockscoutUrl and falls to the network defaults.
// Type-checked scalar: networkId, activeAddress, selectedToken. Each is
// dereferenced as text or used as an object key, so a truthy value of the
// wrong type throws or lands somewhere it should not; each falls back to
// its default or to null.
// `saved.x || default`, no type check: rpcUrl, blockscoutUrl,
// lastBalanceRefresh, tokenHolderCache, theme, currentView, viewData.
// None of these is dereferenced structurally, which is why the loose
// floor is still enough, and each reason is a fact about the readers, not
// a promise: the two URLs are only concatenated into a fetch URL and
// handed to ethers, where a bad value fails the request on a path that
// already catches; lastBalanceRefresh is only arithmetic; theme and
// currentView are only compared and concatenated (both fall through to a
// default branch, and currentView is additionally gated by
// RESTORABLE_VIEWS.has() before anything renders from it); viewData is
// only read field-by-field, and a field of a string or a number is
// undefined rather than a throw; nothing in src/ reads tokenHolderCache at
// all — it is only ever reset wholesale.
// Present-or-default, value taken verbatim: every boolean flag,
// dustThresholdGwei, selectedWallet, selectedAddress.
// //
// That test exists because this comment did not work. It carried a // A field added to the record needs a check here or a floor there, chosen by
// hand-written justification per field, and it shipped a false one in three // what reads it: anything dereferenced structurally needs the type check, and
// consecutive changes — a different field each time, each caught only by a // neither of the last two kinds is one.
// reviewer re-deriving thirty fields by hand. A claim nobody can execute is
// worse than no claim, because it is believed.
//
// The trap is worth stating here, since it is what all three got wrong: a
// check on a CONTAINER is not a check on its ENTRIES, and the dereference is
// one level below the container. `[1, 2]` is a list, `{"0x…": "notalist"}` is
// a record, and `{"currentView":"success-tx","viewData":{"hash":"0x1"}}`
// passes the restore gate and throws on the address the renderer below it
// reads. A field added to the record needs a decision about its entries as
// well as its shape — and then a row in that test.
const { isKnownNetworkId } = require("./networks"); const { isKnownNetworkId } = require("./networks");

View File

@@ -48,79 +48,13 @@ function formatAmount(raw, decimals) {
return truncateAmountNeverZero(formatUnits(raw, decimals)); return truncateAmountNeverZero(formatUnits(raw, decimals));
} }
// One wording for either side of the screen: a currency the calldata never
// named. It reads as a refusal, the same stance unknownDecimalsAmount() takes
// on a scale — not as a token name, and not as a quantity.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
// value is the trap this decoder has now been bitten by five times.
function present(value) {
return value !== null && value !== undefined;
}
// Uniswap V4 spells "use the whole open delta" as an amount of zero:
// v4-periphery `src/libraries/ActionConstants.sol` declares
// `uint128 internal constant OPEN_DELTA = 0` ("used to signal that an action
// should use the input value of the open delta on the pool manager or of the
// balance that the contract holds"), and `src/V4Router.sol` substitutes the
// full open credit whenever an exact-in swap action's `amountIn` equals it:
//
// uint128 amountIn = params.amountIn;
// if (amountIn == ActionConstants.OPEN_DELTA) {
// amountIn = _getFullCredit(...).toUint128();
// }
//
// in both `_swapExactInputSingle` and `_swapExactInput`. Sentinel and literal
// zero are the same uint128 word, so the encoding CANNOT distinguish them —
// and the router does not try: it reads every zero as the sentinel, so in V4
// there is no such thing as an exact-in swap of literally zero. The amount is
// therefore not stated by the calldata at all; it is whatever credit is open
// at execution time. It is carried as this sentinel rather than as 0n because
// printing "0.0000" would state the exact inverse of what will happen —
// "nothing is being swapped" for a step that swaps the entire balance.
//
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
// so a zero `amountIn` there is a literal zero and is displayed as one.
const OPEN_DELTA = Symbol("v4-open-delta");
// The two amount lines that state a fact instead of a quantity. Same register
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
// as a number — and deliberately not a third phrasing of "not named": these
// say different things.
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// `decimals` is null when nothing knows this token's scale. It is not // `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the // defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as // ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are // 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
// on `state`; resolveTokenDecimals() reads the bundled list, then those. // on `state`; resolveTokenDecimals() reads the bundled list, then those.
//
// A null `address` means UNDETERMINED — the calldata named no currency for
// that side — and is refused rather than named. It is not native ETH: Uniswap
// V4 spells native ETH as `Currency.wrap(address(0))` (v4-core
// `type Currency is address`), and a Currency is ABI-encoded as a plain
// address word, so every decode site here gets back the truthy string
// "0x0000000000000000000000000000000000000000" for it — never null. WRAP_ETH
// sets that same explicit zero address, and an UNWRAP_WETH output is caught by
// its caller before this is consulted, so nothing that genuinely is ETH
// arrives null. Naming a null ETH states the wrong asset and formats its
// amount at the wrong scale.
function tokenInfo(address, sources) { function tokenInfo(address, sources) {
if (!address) { if (!address || address === "0x0000000000000000000000000000000000000000") {
return { symbol: null, decimals: null, address: null };
}
if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: "ETH", decimals: 18, address: null }; return { symbol: "ETH", decimals: 18, address: null };
} }
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase()); const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
@@ -271,14 +205,6 @@ const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b; const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e; const V4_TAKE = 0x0e;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
// comment above. The exact-OUT actions below decode no amounts at all, so
// their own OPEN_DELTA mapping on `amountOut` never reaches the screen.
function v4ExactInAmount(raw) {
return raw === 0n ? OPEN_DELTA : raw;
}
// Decode V4_SWAP (command 0x10) input bytes. // Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params). // The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions, // We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
@@ -327,17 +253,13 @@ function decodeV4Swap(input) {
], ],
params[i], params[i],
); );
if (!present(settleToken)) settleToken = s[0][0]; if (!settleToken) settleToken = s[0][0];
const path = s[0][1]; const path = s[0][1];
if (path.length > 0 && !present(takeToken)) { if (path.length > 0 && !takeToken) {
takeToken = path[path.length - 1][0]; takeToken = path[path.length - 1][0];
} }
if (!present(amountIn)) { if (!amountIn) amountIn = s[0][2];
amountIn = v4ExactInAmount(s[0][2]); if (!amountOutMin) amountOutMin = s[0][3];
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch { } catch {
// Fall through — SETTLE/TAKE will provide tokens // Fall through — SETTLE/TAKE will provide tokens
} }
@@ -353,20 +275,16 @@ function decodeV4Swap(input) {
); );
const poolKey = s[0][0]; const poolKey = s[0][0];
const zeroForOne = s[0][1]; const zeroForOne = s[0][1];
if (!present(settleToken)) if (!settleToken)
settleToken = zeroForOne settleToken = zeroForOne
? poolKey[0] ? poolKey[0]
: poolKey[1]; : poolKey[1];
if (!present(takeToken)) if (!takeToken)
takeToken = zeroForOne takeToken = zeroForOne
? poolKey[1] ? poolKey[1]
: poolKey[0]; : poolKey[0];
if (!present(amountIn)) { if (!amountIn) amountIn = s[0][2];
amountIn = v4ExactInAmount(s[0][2]); if (!amountOutMin) amountOutMin = s[0][3];
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch { } catch {
// Fall through // Fall through
} }
@@ -383,9 +301,9 @@ function decodeV4Swap(input) {
], ],
params[i], params[i],
); );
if (!present(takeToken)) takeToken = s[0][0]; if (!takeToken) takeToken = s[0][0];
const path = s[0][1]; const path = s[0][1];
if (path.length > 0 && !present(settleToken)) { if (path.length > 0 && !settleToken) {
settleToken = path[path.length - 1][0]; settleToken = path[path.length - 1][0];
} }
} catch { } catch {
@@ -401,11 +319,11 @@ function decodeV4Swap(input) {
); );
const poolKey = s[0][0]; const poolKey = s[0][0];
const zeroForOne = s[0][1]; const zeroForOne = s[0][1];
if (!present(settleToken)) if (!settleToken)
settleToken = zeroForOne settleToken = zeroForOne
? poolKey[0] ? poolKey[0]
: poolKey[1]; : poolKey[1];
if (!present(takeToken)) if (!takeToken)
takeToken = zeroForOne takeToken = zeroForOne
? poolKey[1] ? poolKey[1]
: poolKey[0]; : poolKey[0];
@@ -444,44 +362,11 @@ function decode(data, toAddress, sources) {
let inputToken = null; let inputToken = null;
let inputAmount = null; let inputAmount = null;
let inputEstablished = false;
let outputToken = null; let outputToken = null;
let minOutput = null; let minOutput = null;
let hasUnwrapWeth = false; let hasUnwrapWeth = false;
const commandNames = []; const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
// from the same hop. A figure is never rendered against a token that
// did not supply it.
//
// Both sides are therefore set as a PAIR — never field by field, and
// never on truthiness. An address is never falsy once set but an
// amount of 0n is, so gating the two halves independently let a hop
// with a zero amount fix the token and leave the amount open; the next
// hop's figure was then displayed against the first hop's token, at the
// first hop's scale. A V3 USDT->WETH hop with amountIn 0 followed by a
// V2 WETH->USDC hop of 0.5e18 rendered "500000000000.0000 USDT".
//
// The input side is fixed by the first hop that states either half, the
// output side by the last, because the final leg is what the user
// receives. A half the establishing hop did not state stays null and
// the line says so, rather than being filled in from a different hop.
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null;
inputEstablished = true;
};
const setInputOnce = (token, amount) => {
if (inputEstablished) return;
if (!present(token) && !present(amount)) return;
setInput(token, amount);
};
const setOutput = (token, amount) => {
if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null;
};
for (let i = 0; i < commandsBytes.length; i++) { for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & 0x1f; const cmdId = commandsBytes[i] & 0x1f;
commandNames.push( commandNames.push(
@@ -492,61 +377,69 @@ function decode(data, toAddress, sources) {
try { try {
if (cmdId === 0x0a) { if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]); const p = decodePermit2(inputs[i]);
// A permit states both halves itself, so it may replace an if (p) {
// input side an earlier hop established without breaking inputToken = p.token;
// the invariant. inputAmount = p.amount;
if (p) setInput(p.token, p.amount); }
} }
if (cmdId === 0x0e) { if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
if (b) setOutput(b.token, b.minBalance); if (b) {
outputToken = b.token;
minOutput = b.minBalance;
}
} }
if (cmdId === 0x00) { if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]); const s = decodeV3SwapExactIn(inputs[i]);
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountIn); if (!inputToken) inputToken = s.tokenIn;
if (!inputAmount) inputAmount = s.amountIn;
// Always update output: in multi-step swaps (V3 → V4), // Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token // the last swap step determines the final output token
// and minimum received amount. // and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin); outputToken = s.tokenOut;
minOutput = s.amountOutMin;
} }
} }
if (cmdId === 0x08) { if (cmdId === 0x08) {
const s = decodeV2SwapExactIn(inputs[i]); const s = decodeV2SwapExactIn(inputs[i]);
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountIn); if (!inputToken) inputToken = s.tokenIn;
setOutput(s.tokenOut, s.amountOutMin); if (!inputAmount) inputAmount = s.amountIn;
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
} }
} }
if (cmdId === 0x0b) { if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]); const w = decodeWrapEth(inputs[i]);
if (w) { if (w && !inputToken) {
setInputOnce( inputToken =
"0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000";
w.amount, inputAmount = w.amount;
);
} }
} }
if (cmdId === 0x10) { if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
if (v4) { if (v4) {
setInputOnce(v4.tokenIn, v4.amountIn); if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn;
// Always update output: last swap step wins. A step // Always update output: last swap step wins. A step
// that carries the Min. received figure but decoded no // that carries the Min. received figure but decoded no
// output currency makes the output *undetermined* — it // output currency makes the output *undetermined* — it
// is neither ETH nor whatever an earlier step named, // is neither ETH nor whatever an earlier step named,
// and that figure is no longer counted in that token. // and that figure is no longer counted in that token.
// Equally, a step that names an output currency but no if (v4.tokenOut) {
// minimum leaves Min. received unstated rather than outputToken = v4.tokenOut;
// keeping an earlier step's figure beside the new } else if (v4.amountOutMin) {
// token. setOutput() is both rules; a step that states outputToken = null;
// neither half leaves the output alone. }
setOutput(v4.tokenOut, v4.amountOutMin); if (v4.amountOutMin) minOutput = v4.amountOutMin;
} }
} }
@@ -558,14 +451,26 @@ function decode(data, toAddress, sources) {
} }
} }
// Resolve token info. A null token on either side means the calldata // Resolve token info.
// named no currency for it; tokenInfo() refuses rather than calling it //
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to // A null `outputToken` means undetermined, not native ETH, so it is
// decode, and it is answered here rather than left to that rule. // not handed to tokenInfo() — which maps null to ETH at 18 decimals
// for the input side's benefit. Uniswap V4 spells native ETH as
// `Currency.wrap(address(0))` (v4-core `type Currency is address`),
// and a Currency is ABI-encoded as a plain address word, so every
// decode site here gets back the truthy string
// "0x0000000000000000000000000000000000000000" for it — never null.
// tokenInfo() already names that ETH, and an UNWRAP_WETH output is
// caught above, so nothing that genuinely outputs ETH arrives null.
// Only a step whose output currency did not decode does, and naming
// that ETH states the wrong asset and formats Min. received at the
// wrong scale.
const inInfo = tokenInfo(inputToken, sources); const inInfo = tokenInfo(inputToken, sources);
const outInfo = hasUnwrapWeth const outInfo = hasUnwrapWeth
? { symbol: "ETH", decimals: 18, address: null } ? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources); : outputToken
? tokenInfo(outputToken, sources)
: { symbol: null, decimals: null, address: null };
const inSymbol = inInfo.symbol; const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol; const outSymbol = outInfo.symbol;
@@ -583,7 +488,7 @@ function decode(data, toAddress, sources) {
address: toAddress, address: toAddress,
}); });
if (present(inputToken) && present(inInfo.address)) { if (inputToken && inInfo.address) {
const label = inSymbol const label = inSymbol
? inSymbol + " (" + inputToken + ")" ? inSymbol + " (" + inputToken + ")"
: inputToken; : inputToken;
@@ -595,28 +500,18 @@ function decode(data, toAddress, sources) {
}); });
} else if (inSymbol === "ETH") { } else if (inSymbol === "ETH") {
details.push({ label: "Token In", value: "ETH (native)" }); details.push({ label: "Token In", value: "ETH (native)" });
} else {
// Nothing established the input token, so the line says that
// rather than going missing or naming a token by default. Same
// wording as the Token Out refusal below: the two sides of this
// screen must not describe the same condition in two ways.
details.push({ label: "Token In", value: UNNAMED_CURRENCY });
} }
if (present(inputAmount)) { if (inputAmount !== null && inputAmount !== undefined) {
// Two amounts need no scale to describe and are named rather than const maxUint160 = BigInt(
// formatted: V4's open delta, which is not a quantity at all (see "0xffffffffffffffffffffffffffffffffffffffff",
// OPEN_DELTA), and an unbounded permit. The open-delta test comes );
// first — the sentinel is not a bigint and cannot be compared with const isUnlimited = inputAmount >= maxUint160;
// one. // An unbounded permit needs no scale to describe, so it is still
let amount; // named rather than refused.
if (inputAmount === OPEN_DELTA) { const amount = isUnlimited
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; ? { raw: "Unlimited", display: "Unlimited" }
} else if (inputAmount >= MAX_UINT160) { : amountText(inputAmount, inInfo);
amount = { raw: "Unlimited", display: "Unlimited" };
} else {
amount = amountText(inputAmount, inInfo);
}
details.push({ details.push({
label: "Amount", label: "Amount",
value: amount.display, value: amount.display,
@@ -629,7 +524,7 @@ function decode(data, toAddress, sources) {
// entirely, leaving a Min. received figure with nothing saying what is // entirely, leaving a Min. received figure with nothing saying what is
// being received. The Token In line above already falls back to the // being received. The Token In line above already falls back to the
// address; this does the same. // address; this does the same.
if (present(outInfo.address)) { if (outInfo.address) {
const label = outSymbol const label = outSymbol
? outSymbol + " (" + outInfo.address + ")" ? outSymbol + " (" + outInfo.address + ")"
: outInfo.address; : outInfo.address;
@@ -643,25 +538,20 @@ function decode(data, toAddress, sources) {
details.push({ label: "Token Out", value: outSymbol }); details.push({ label: "Token Out", value: outSymbol });
} else { } else {
// Nothing established the output token, so the line says that // Nothing established the output token, so the line says that
// rather than going missing or naming a token by default, and a // rather than going missing or naming a token by default. It reads
// Min. received figure below it is never attached to a token the // as a refusal, the same stance unknownDecimalsAmount() takes on a
// calldata did not state. // scale, so a Min. received figure below it is never attached to a
details.push({ label: "Token Out", value: UNNAMED_CURRENCY }); // token the calldata did not state.
details.push({
label: "Token Out",
value: "Unknown (not named in the calldata)",
});
} }
if (present(minOutput)) { if (minOutput !== null && minOutput !== undefined) {
// A zero floor is the one case the user most needs stated: the
// swap guarantees nothing back. It is said in words, in the same
// register as UNNAMED_CURRENCY, because "0.0000 WETH" reads as an
// artifact of the four-decimal rule rather than as "this may
// return nothing" — and because it is true at every scale, so it
// holds even when the output token's decimals are unknown.
details.push({ details.push({
label: "Min. received", label: "Min. received",
value: value: amountText(minOutput, outInfo).display,
minOutput === 0n
? NO_MINIMUM
: amountText(minOutput, outInfo).display,
}); });
} }

View File

@@ -153,7 +153,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the transaction detail renders it", () => { test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], { reopenedOn("settings", ["main", "transaction"], {
viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } }, viewData: { tx: { hash: "0xdead" } },
}); });
goBack(); goBack();
expect(calls).toEqual(["transactionDetail"]); expect(calls).toEqual(["transactionDetail"]);
@@ -162,14 +162,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the transaction confirmation restores it", () => { test("Back onto the transaction confirmation restores it", () => {
reopenedOn("settings", ["main", "confirm-tx"], { reopenedOn("settings", ["main", "confirm-tx"], {
viewData: { viewData: { pendingTx: { to: ADDRESS, amount: "1" } },
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
},
},
}); });
goBack(); goBack();
expect(calls).toEqual(["confirmTx"]); expect(calls).toEqual(["confirmTx"]);
@@ -178,7 +171,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the success screen renders it", () => { test("Back onto the success screen renders it", () => {
reopenedOn("settings", ["main", "success-tx"], { reopenedOn("settings", ["main", "success-tx"], {
viewData: { hash: "0xdead", to: ADDRESS }, viewData: { hash: "0xdead" },
}); });
goBack(); goBack();
expect(calls).toEqual(["successTx"]); expect(calls).toEqual(["successTx"]);
@@ -187,7 +180,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the failure screen renders it", () => { test("Back onto the failure screen renders it", () => {
reopenedOn("settings", ["main", "error-tx"], { reopenedOn("settings", ["main", "error-tx"], {
viewData: { message: "execution reverted", to: ADDRESS }, viewData: { message: "execution reverted" },
}); });
goBack(); goBack();
expect(calls).toEqual(["errorTx"]); expect(calls).toEqual(["errorTx"]);

View File

@@ -93,12 +93,11 @@ describe("the floor under allowedSites and deniedSites", () => {
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] }); expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
}); });
test(`a stored own "__proto__" key in ${field} is dropped`, () => { test(`a stored own "__proto__" key in ${field} does not become a prototype`, () => {
// JSON can carry the key. It can never be a wallet address, so it // JSON can carry the key, and plain assignment would hand it to
// grants and denies nothing and goes the way of every other key // the prototype setter — recording no entry and, worse, moving the
// whose value is unusable; keeping it would only keep a value that // map's prototype. Same reason networkEndpoints uses
// saveState()'s merge hands to the prototype setter on the next // defineProperty.
// write.
const saved = JSON.parse( const saved = JSON.parse(
'{"' + field + '":{"__proto__":["evil.invalid"]}}', '{"' + field + '":{"__proto__":["evil.invalid"]}}',
); );
@@ -106,38 +105,12 @@ describe("the floor under allowedSites and deniedSites", () => {
const out = normalizePersisted(saved); const out = normalizePersisted(saved);
expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype); expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype);
expect(Object.keys(out[field])).toEqual([]); expect(Object.keys(out[field])).toEqual(["__proto__"]);
expect({}.length).toBeUndefined();
}); });
} }
}); });
describe('a stored own "__proto__" key surviving a save', () => {
// The floor writes map keys with defineProperty; saveState()'s merge is one
// layer downstream of it and used to write them with plain assignment,
// which hands "__proto__" to the prototype setter and records no entry.
// networkEndpoints is where a key that is not a known id is deliberately
// KEPT, so it is where that undoing shows.
test("does not move a prototype or vanish from networkEndpoints", async () => {
const profile = unversionedValidProfile();
profile.networkEndpoints = JSON.parse(
'{"__proto__":{"rpcUrl":"https://kept.invalid"}}',
);
const storage = makeStorageStub({ autistmask: profile });
const { state, loadState, saveState } = loadStateModule(storage);
await loadState();
state.theme = "dark";
await saveState();
// Not compared against Object.prototype by identity: the storage stub
// clones through structuredClone, which builds the result in the host
// realm, so the two Object.prototypes are different objects.
const stored = storage.read("autistmask").networkEndpoints;
expect(Object.getPrototypeOf(stored).rpcUrl).toBeUndefined();
expect(Object.keys(stored)).toContain("__proto__");
});
});
describe("the floor under fraudContracts", () => { describe("the floor under fraudContracts", () => {
test("fraudContracts that is not a list becomes an empty list", () => { test("fraudContracts that is not a list becomes an empty list", () => {
for (const bad of ["nope", 42, true, { a: 1 }]) { for (const bad of ["nope", 42, true, { a: 1 }]) {

View File

@@ -1,570 +0,0 @@
// What the floor under each persisted field actually guarantees — as a table
// that RUNS, one row per field.
//
// This file replaces a hand-written per-field justification in the header of
// src/shared/stateSchema.js. That comment shipped a false claim in three
// consecutive changes: every author wrote plausible prose about thirty fields,
// every reviewer re-derived it by hand, and it kept being wrong in a different
// place each time. The artifact was the problem. A claim nobody can execute is
// worse than no claim, because it is believed.
//
// So the claim is a row here instead:
//
// KIND.REFUSED assertStateUsable() refuses the record outright. Proven by
// stateProblem() naming a problem for every hostile value.
// KIND.ENTRIES normalizePersisted() floors the container AND its entries.
// Proven by holds() over the normalized value.
// KIND.SCALAR normalizePersisted() floors it to one scalar type, or to a
// fixed fallback. Proven the same way.
// KIND.LOOSE `saved.x || default`, no type check at all. The claim is
// that no structural dereference of it is reachable from a
// stored record — which cannot be argued, only driven, so the
// proof is a boot of the REAL popup entry point over a stored
// record carrying the hostile value.
//
// Every row is driven through the boot regardless of kind, and a LOOSE row
// must additionally prove it is loose: if someone floors the field and leaves
// the row saying LOOSE, the "survives verbatim" assertion fails. A field added
// to PERSISTED_FIELDS with no row fails the first test in the file.
//
// The three claims this replaced, all false, all caught here by construction:
// rpcUrl reaching `new JsonRpcProvider()` (a synchronous throw, not a caught
// request); viewData's ENTRIES being dereferenced by four restore branches
// that gate on one truthy field each; and selectedWallet, where a stale
// integer index is the SAFE case and `wallets["map"]` is the throwing one.
const {
PERSISTED_FIELDS,
normalizePersisted,
} = require("../src/shared/persistedState");
const { stateProblem } = require("../src/shared/stateSchema");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const KIND = {
REFUSED: "refused by the gate",
ENTRIES: "container and entries type-checked",
SCALAR: "scalar type-checked",
LOOSE: "loosely floored; safety proven by driving the popup",
};
const isText = (v) => typeof v === "string";
const isRecord = (v) =>
typeof v === "object" && v !== null && !Array.isArray(v);
const isIndexOrNull = (v) => v === null || (Number.isInteger(v) && v >= 0);
const isTextOrNull = (v) => v === null || (isText(v) && v !== "");
const everyEntry = (v, fn) => Array.isArray(v) && v.every(fn);
// ------------------------------------------------------------------ the table
//
// `hostile` is values a stored record can carry that nothing in src/ ever
// writes. Each one is driven through the floor AND through a real popup boot,
// so keep the list short and pointed. `floorOnly` is extra values checked
// against the floor alone, which is pure and free.
const CONTRACT = [
{
field: "wallets",
kind: KIND.REFUSED,
hostile: [42, "notastructure", { a: 1 }, [null], [{ addresses: 1 }]],
},
{
field: "networkId",
kind: KIND.REFUSED,
hostile: [42, "notanetwork", { a: 1 }, "__proto__"],
},
{
field: "trackedTokens",
kind: KIND.ENTRIES,
hostile: [42, "notalist", { a: 1 }],
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
},
{
field: "allowedSites",
kind: KIND.ENTRIES,
hostile: [42, "notarecord", { [ADDRESS]: "notalist" }],
floorOnly: [
[ADDRESS],
{ [ADDRESS]: 42 },
{ [ADDRESS]: [42, null, {}] },
JSON.parse('{"__proto__":["evil.invalid"]}'),
],
holds: siteMapHolds,
},
{
field: "deniedSites",
kind: KIND.ENTRIES,
hostile: [42, "notarecord", { [ADDRESS]: "notalist" }],
floorOnly: [
[ADDRESS],
{ [ADDRESS]: 42 },
{ [ADDRESS]: [42, null, {}] },
JSON.parse('{"__proto__":["evil.invalid"]}'),
],
holds: siteMapHolds,
},
{
field: "fraudContracts",
kind: KIND.ENTRIES,
hostile: [42, "notalist", { a: 1 }],
floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, isText),
},
{
field: "viewStack",
kind: KIND.ENTRIES,
hostile: [42, "notalist", ["main", "show-phrase", "settings"]],
floorOnly: [[1, 2], [null], [{}], ["export-privkey"]],
// Truncated at the first entry the popup will not reopen onto, rather
// than filtered: every surviving entry's Back target has to stay the
// one it had. restorableStack() may also substitute ["main"] under a
// view restored below the root, so this is the one ENTRIES field whose
// result is not always a subset of what was stored.
holds: (v) => everyEntry(v, (e) => RESTORABLE_VIEWS.has(e)),
},
{
field: "networkEndpoints",
kind: KIND.ENTRIES,
hostile: [42, "notarecord", { mainnet: "notapair" }],
floorOnly: [
[1, 2],
{ mainnet: { rpcUrl: 42, blockscoutUrl: {} } },
{ mainnet: { rpcUrl: "", blockscoutUrl: [] } },
{ sepolia: 42 },
],
// Entries are coerced rather than dropped: an unknown network id is
// KEPT, so a profile that has been on a build with more networks does
// not lose their endpoints here. What is floored is the two URL fields
// inside the pair, which applyChainSwitchFields() assigns straight onto
// s.rpcUrl / s.blockscoutUrl on the next switch.
holds: (v) =>
isRecord(v) &&
Object.keys(v).every((id) => {
const pair = v[id];
return (
isRecord(pair) &&
(pair.rpcUrl === undefined ||
(isText(pair.rpcUrl) && pair.rpcUrl !== "")) &&
(pair.blockscoutUrl === undefined ||
(isText(pair.blockscoutUrl) &&
pair.blockscoutUrl !== ""))
);
}),
},
{
field: "rpcUrl",
kind: KIND.SCALAR,
hostile: [42, true, { a: 1 }],
floorOnly: [[], "", null],
holds: (v) => isText(v) && v !== "",
// The claim this row replaced said a bad value "fails the request on a
// path that already catches". It does not: getProvider() hands rpcUrl
// to `new JsonRpcProvider()`, which throws SYNCHRONOUSLY, from two call
// sites outside any try — and a stored `currentView: "wait-tx"` reaches
// one of them through restoreView(). So the row proves the claim
// against the real constructor rather than describing it.
alsoProven: (normalized, hostile) => {
// requireActual: bootPopup() mocks this module out for the boots
// above, and a mocked getProvider() would prove nothing at all
// about the constructor this row is a claim about.
const { getProvider } = jest.requireActual(
"../src/shared/balances",
);
expect(() => getProvider(hostile, "mainnet")).toThrow();
const provider = getProvider(normalized, "mainnet");
expect(provider).toBeTruthy();
provider.destroy();
},
},
{
field: "blockscoutUrl",
kind: KIND.SCALAR,
hostile: [42, true, { a: 1 }],
floorOnly: [[], "", null],
holds: (v) => isText(v) && v !== "",
},
{
field: "activeAddress",
kind: KIND.SCALAR,
hostile: [42, true, { a: 1 }],
floorOnly: [[ADDRESS], ""],
holds: isTextOrNull,
},
{
field: "selectedToken",
kind: KIND.SCALAR,
hostile: [42, true, { a: 1 }],
floorOnly: [[TOKEN_ADDRESS], ""],
holds: isTextOrNull,
},
{
field: "selectedWallet",
kind: KIND.SCALAR,
// The prototype members are the whole point: `wallets["map"]` is
// TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and
// `.addresses[…]` throws. A stale INTEGER is the safe case.
hostile: ["map", "__proto__", { a: 1 }],
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
holds: isIndexOrNull,
},
{
field: "selectedAddress",
kind: KIND.SCALAR,
hostile: ["map", "__proto__", { a: 1 }],
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
holds: isIndexOrNull,
},
{
field: "currentView",
kind: KIND.LOOSE,
// Compared, and concatenated into the debug banner's textContent
// (src/popup/views/helpers.js) with no gate in front of it, which
// coerces. Nothing renders FROM it without RESTORABLE_VIEWS.has()
// first, and Set.has() answers false for any value.
hostile: [42, "no-such-view", { a: 1 }],
},
{
field: "viewData",
kind: KIND.LOOSE,
// The container is taken verbatim; what makes its ENTRIES safe is the
// per-branch guard in src/popup/viewRouter.js. Driven over every
// restorable view in "a malformed viewData" below, which is the proof
// this row rests on.
hostile: [42, "notarecord", { a: 1 }],
},
{
field: "lastBalanceRefresh",
kind: KIND.LOOSE,
// Arithmetic only: `now - (s.lastBalanceRefresh || 0)` compares false
// for a non-number and forces a refresh.
hostile: [true, "notatime", { a: 1 }],
},
{
field: "tokenHolderCache",
kind: KIND.LOOSE,
// Nothing DEREFERENCES it structurally. It is read by the
// field-agnostic snapshotPersisted()/deepEqual() in
// src/shared/state.js, which are safe for any value, and otherwise
// only reset wholesale in src/shared/chainSwitchFields.js.
hostile: [42, "notarecord", [1, 2]],
},
{
field: "theme",
kind: KIND.LOOSE,
// Compared against "dark"/"light" in applyTheme() and otherwise falls
// to the system branch; assigned into an input .value, which coerces.
hostile: [42, "chartreuse", { a: 1 }],
},
{
field: "dustThresholdGwei",
kind: KIND.LOOSE,
hostile: ["notanumber", true, { a: 1 }],
},
...[
"rememberSiteChoice",
"showZeroBalanceTokens",
"hideSpoofedSymbols",
"hideLowHolderTokens",
"hideFraudContracts",
"hideDustTransactions",
"utcTimestamps",
"debugMode",
].map((field) => ({
field,
kind: KIND.LOOSE,
// A flag: only ever tested for truthiness, and written back verbatim.
hostile: [42, "notabool", { a: 1 }],
})),
];
function siteMapHolds(v) {
return (
isRecord(v) &&
Object.getPrototypeOf(v) === Object.prototype &&
!Object.prototype.hasOwnProperty.call(v, "__proto__") &&
Object.keys(v).every((key) => everyEntry(v[key], isText))
);
}
afterEach(() => {
cleanupPopup();
});
// -------------------------------------------------------------- exhaustive
describe("the contract covers the record", () => {
test("every persisted field has exactly one row, and no row invents one", () => {
const rows = CONTRACT.map((row) => row.field);
expect([...rows].sort()).toEqual([...PERSISTED_FIELDS].sort());
});
test("every row declares a kind this file knows how to prove", () => {
const kinds = Object.values(KIND);
for (const row of CONTRACT) {
expect(kinds).toContain(row.kind);
expect(row.hostile.length).toBeGreaterThan(0);
}
});
});
// ------------------------------------------------------------- the floors
function profileWith(field, value) {
return unversionedValidProfile({ [field]: value });
}
describe("the floor each row claims", () => {
for (const row of CONTRACT) {
const values = [...row.hostile, ...(row.floorOnly || [])];
if (row.kind === KIND.REFUSED) {
test(`${row.field}: the gate refuses it`, () => {
for (const value of values) {
expect(
typeof stateProblem(profileWith(row.field, value)),
).toBe("string");
}
});
continue;
}
test(`${row.field}: ${row.kind}`, () => {
for (const value of values) {
const out = normalizePersisted(profileWith(row.field, value));
if (row.kind === KIND.LOOSE) {
// The claim IS that there is no floor. A field that grows
// one has to move to another kind rather than keep a row
// saying its readers are what make it safe.
continue;
}
expect({
value: value,
holds: row.holds(out[row.field]),
}).toEqual({ value: value, holds: true });
}
});
if (row.kind === KIND.LOOSE) {
test(`${row.field}: is genuinely unfloored`, () => {
const survived = values.some((value) => {
const out = normalizePersisted(
profileWith(row.field, value),
);
return (
JSON.stringify(out[row.field]) === JSON.stringify(value)
);
});
expect(survived).toBe(true);
});
}
}
});
// --------------------------------------------- driving the real popup boot
// A booted popup is healthy when nothing threw out of init() and something is
// on screen. A throw out of restoreView() is neither: init() does not guard it,
// so the rest of popup init never runs and the user gets a popup with no view,
// no message and no control on it.
async function bootHealth(profile) {
const env = await bootPopup(profile);
return {
errors: env.pageErrors,
blank: env.visibleViews().length === 0,
};
}
const HEALTHY = { errors: [], blank: false };
describe("a hostile value for one field, through the real popup", () => {
for (const row of CONTRACT) {
for (const value of row.hostile) {
test(`${row.field} = ${JSON.stringify(value)}`, async () => {
await expect(
bootHealth(profileWith(row.field, value)),
).resolves.toEqual(HEALTHY);
});
}
}
});
describe("a row's extra proof against the real reader", () => {
for (const row of CONTRACT) {
if (!row.alsoProven) continue;
test(row.field, () => {
for (const value of row.hostile) {
const out = normalizePersisted(profileWith(row.field, value));
row.alsoProven(out[row.field], value);
}
});
}
});
// ------------------------------------------------- viewData, entry by entry
// The views that read viewData.
const DATA_VIEWS = [
"confirm-tx",
"transaction",
"wait-tx",
"success-tx",
"error-tx",
];
// Each record below PASSES the gate of the branch it names, and then carries a
// value that branch's renderer dereferences. `views` is where it is driven from
// — the whole set for a value that is not a record at all, and otherwise the
// branch it targets, since the cross-view case is covered by EVERY_GATE below.
const HOSTILE_VIEW_DATA = [
{ data: 42, views: DATA_VIEWS },
{ data: "notarecord", views: DATA_VIEWS },
{ data: [1, 2], views: DATA_VIEWS },
// success-tx passes on `data.hash`, and renderSuccess() then calls
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
{ data: { hash: "0x1" }, views: ["success-tx"] },
{ data: { hash: "0x1", to: 42 }, views: ["success-tx"] },
{
data: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
views: ["success-tx"],
},
{
data: {
hash: "0x1",
to: ADDRESS,
decoded: { details: [{ address: 42 }] },
},
views: ["success-tx"],
},
// error-tx passes on `data.message`, same dereference.
{ data: { message: "boom" }, views: ["error-tx"] },
{ data: { message: "boom", to: 42 }, views: ["error-tx"] },
// transaction passes on `data.tx`.
{ data: { tx: { hash: "0x1" } }, views: ["transaction"] },
{
data: {
tx: {
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
contractAddress: 42,
},
},
views: ["transaction"],
},
// confirm-tx passes on `data.pendingTx`.
{ data: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] },
{
data: {
pendingTx: { token: 42, from: ADDRESS, to: ADDRESS, amount: "1" },
},
views: ["confirm-tx"],
},
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked the
// fields below it since it was written, and this is the regression guard.
{
data: { pendingWait: { hash: "0x1", txInfo: { to: 42, amount: "1" } } },
views: ["wait-tx"],
},
];
function restoringOnto(view, extra) {
return unversionedValidProfile({
currentView: view,
selectedWallet: 0,
selectedAddress: 0,
selectedToken: TOKEN_ADDRESS,
viewStack: ["main"],
...extra,
});
}
describe("a malformed viewData restoring onto", () => {
for (const { data, views } of HOSTILE_VIEW_DATA) {
for (const view of views) {
test(`${view}: ${JSON.stringify(data)}`, async () => {
await expect(
bootHealth(restoringOnto(view, { viewData: data })),
).resolves.toEqual(HEALTHY);
});
}
}
// Every restorable view, against one record that passes every branch's
// gate at once: a branch a view does not read must stay one it does not
// read, and each renderer must survive the fields another branch left.
const EVERY_GATE = {
hash: "0x1",
message: "boom",
tx: { hash: "0x1" },
pendingTx: { amount: "1" },
pendingWait: { hash: "0x1" },
};
for (const view of RESTORABLE_VIEWS) {
test(`${view}: a record passing every branch's gate at once`, async () => {
await expect(
bootHealth(restoringOnto(view, { viewData: EVERY_GATE })),
).resolves.toEqual(HEALTHY);
});
}
});
// --------------------------------------- selectedWallet / selectedAddress
// `wallets` is a real Array, so a selectedWallet naming an Array.prototype or
// Object.prototype member is TRUTHY: hasValidAddress()'s `&&` does not
// short-circuit, `.addresses` is undefined, and the index access throws out of
// restoreView(). A stale INTEGER is falsy-or-in-range and safe — the opposite
// way round from how this pair was described.
const HOSTILE_INDEX = [
{ selectedWallet: "map", selectedAddress: 0 },
{ selectedWallet: "length", selectedAddress: 0 },
{ selectedWallet: "__proto__", selectedAddress: 0 },
{ selectedWallet: "constructor", selectedAddress: 0 },
{ selectedWallet: 0, selectedAddress: "map" },
{ selectedWallet: 5, selectedAddress: 0 },
];
const INDEX_VIEWS = [
"address",
"address-token",
"receive",
"transaction",
"confirm-tx",
];
describe("a malformed wallet or address index restoring onto", () => {
const WELL_FORMED_DATA = {
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS },
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
balance: "2",
},
};
for (const view of INDEX_VIEWS) {
for (const indices of HOSTILE_INDEX) {
test(`${view}: ${JSON.stringify(indices)}`, async () => {
await expect(
bootHealth(
restoringOnto(view, {
...indices,
viewData: WELL_FORMED_DATA,
}),
),
).resolves.toEqual(HEALTHY);
});
}
}
});

View File

@@ -101,21 +101,6 @@ function makeElement(id, className) {
}, },
querySelector: () => null, querySelector: () => null,
querySelectorAll: () => [], querySelectorAll: () => [],
// The Receive view draws its QR onto #receive-qr through the qrcode
// package, which calls getContext("2d") and then createImageData/
// putImageData on the result. Without this the render throws from
// inside a promise the view does not await, which takes the whole node
// process down rather than failing a test — so a suite that boots onto
// Receive could not report anything.
getContext: () => ({
createImageData: (w, h) => ({
width: w,
height: h,
data: new Uint8ClampedArray(w * h * 4),
}),
putImageData: () => {},
clearRect: () => {},
}),
click: () => { click: () => {
el.clicked += 1; el.clicked += 1;
}, },

View File

@@ -94,69 +94,6 @@ function encodeV4Swap(actions, params) {
return coder.encode(["bytes", "bytes[]"], [actions, params]); return coder.encode(["bytes", "bytes[]"], [actions, params]);
} }
// V4 inner action IDs, as src/shared/uniswap.js names them.
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
const V4_SETTLE_ID = 0x0b;
const V4_TAKE_ID = 0x0e;
const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
// Helper: V4 SETTLE params — (address currency, uint256 maxAmount, bool payerIsUser)
function encodeV4Settle(currency) {
return coder.encode(["address", "uint256", "bool"], [currency, 0n, true]);
}
// Helper: V4 TAKE params — (address currency, address recipient, uint256 amount)
function encodeV4Take(currency) {
return coder.encode(
["address", "address", "uint256"],
[currency, USER_ADDR, 0n],
);
}
// Helper: V4 ExactInputParams — (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
function encodeV4ExactIn(currencyIn, pathTokens, amountIn, amountOutMin) {
return coder.encode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
[
[
currencyIn,
pathTokens.map((t) => [t, 3000, 60, ZERO_ADDR, "0x"]),
amountIn,
amountOutMin,
],
],
);
}
// Helper: V4 ExactInputSingleParams —
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
// uint128 amountIn, uint128 amountOutMin, bytes hookData)
function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
return coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currency0, currency1, 100, 1, ZERO_ADDR],
true, // zeroForOne: in = currency0, out = currency1
amountIn,
amountOutMin,
"0x",
],
],
);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
describe("uniswap decoder", () => { describe("uniswap decoder", () => {
test("returns null for non-execute calldata", () => { test("returns null for non-execute calldata", () => {
expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull(); expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull();
@@ -181,18 +118,6 @@ describe("uniswap decoder", () => {
expect(tokenIn.value).toContain("USDT"); expect(tokenIn.value).toContain("USDT");
expect(tokenIn.address.toLowerCase()).toBe(USDT_ADDR.toLowerCase()); expect(tokenIn.address.toLowerCase()).toBe(USDT_ADDR.toLowerCase());
// Genuine native ETH on the output side, on a real mainnet fixture:
// V4's TAKE names it as Currency.wrap(address(0)), which reaches the
// decoder as the explicit zero address and must still read as ETH.
const tokenOut = result.details.find((d) => d.label === "Token Out");
expect(tokenOut.value).toBe("ETH");
expect(result.details.find((d) => d.label === "Amount").value).toBe(
"Unlimited",
);
expect(
result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH");
const steps = result.details.find((d) => d.label === "Steps"); const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toContain("Permit2 Permit"); expect(steps.value).toContain("Permit2 Permit");
expect(steps.value).toContain("V4 Swap"); expect(steps.value).toContain("V4 Swap");
@@ -256,7 +181,8 @@ describe("uniswap decoder", () => {
expect(tokenIn.value).toBe("ETH (native)"); expect(tokenIn.value).toBe("ETH (native)");
const amount = result.details.find((d) => d.label === "Amount"); const amount = result.details.find((d) => d.label === "Amount");
expect(amount.value).toBe("1.0000 ETH"); expect(amount.value).toContain("1.0000");
expect(amount.value).toContain("ETH");
}); });
test("decodes UNWRAP_WETH as ETH output", () => { test("decodes UNWRAP_WETH as ETH output", () => {
@@ -412,211 +338,6 @@ describe("uniswap decoder", () => {
expect(steps.value).toContain("V4 Swap"); expect(steps.value).toContain("V4 Swap");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/364 — the input half.
//
// Fails against c9ebac8: `if (!inputAmount) inputAmount = s.amountIn`
// cannot tell the V3 hop's genuine 0n from "not yet set", so the V2 hop's
// 0.5 WETH overwrote it while Token In stayed pinned to the V3 hop's USDT.
// Observed there: Amount = "500000000000.0000 USDT".
test("a hop's zero amountIn is a real amount, not an opening for the next hop's figure", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, 0n, 0n, [USDT_ADDR, WETH_ADDR]),
encodeV2SwapExactIn(
USER_ADDR,
500000000000000000n, // 0.5 WETH
1000000n,
[WETH_ADDR, USDC_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
// The amount and the token it is counted in come from the same hop.
expect(detail(result, "Token In").address.toLowerCase()).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("0.0000 USDT");
expect(detail(result, "Amount").value).not.toContain("500000000000");
});
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
// the shape the issue measured: a V4 step that states a minimum of zero
// and names no output currency.
//
// Fails against c9ebac8: `if (v4.amountOutMin) minOutput = ...` and the
// `else if` beside it both read 0n as absent, so neither the figure nor
// the token moved. Observed there: Token Out = "WETH (0xC02aaA39...)" and
// Min. received = "0.5000 WETH" — the V3 hop's guarantee shown for a
// transaction whose final leg guarantees nothing.
test("a V4 step with a zero amountOutMin states no minimum instead of keeping an earlier hop's", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
encodeV4ExactIn(
WETH_ADDR,
[], // no path: this step names no output currency
1000000000000000000n,
0n, // no slippage floor at all
),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received").value).toBe(
"None (no minimum guaranteed)",
);
expect(detail(result, "Min. received").value).not.toContain("0.5000");
});
// The same zero floor, but with the final leg's output currency named:
// the figure must belong to the token beside it. Against c9ebac8 this
// rendered Token Out = USDC with Min. received = "500000000000.0000 USDC",
// the V3 hop's 0.5e18 WETH figure re-scaled to USDC's six decimals.
test("a zero minimum is stated against the token that supplied it", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(
new Uint8Array([
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
]),
[
encodeV4Settle(WETH_ADDR),
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
1000000000000000000n,
0n,
),
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
expect(detail(result, "Min. received").value).toBe(
"None (no minimum guaranteed)",
);
});
// The other half of the same invariant: a final leg that names an output
// currency but no minimum leaves Min. received unstated. Against c9ebac8
// the V3 hop's figure stayed on screen beside the new token, rendering
// "500000000000.0000 USDC".
test("a final leg with no minimum drops the line rather than keeping an earlier hop's figure", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([V4_SETTLE_ID, V4_TAKE_ID]), [
encodeV4Settle(WETH_ADDR),
encodeV4Take(USDC_ADDR),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
expect(detail(result, "Min. received")).toBeUndefined();
});
// V4 spells "swap the whole open delta" as an amountIn of zero
// (v4-periphery ActionConstants.OPEN_DELTA = 0, applied by V4Router's
// _swapExactInputSingle / _swapExactInput). It is not a quantity, and
// printing "0.0000 WETH" for it would state the exact inverse of what the
// step does. Against c9ebac8 the Amount line was omitted entirely.
test("a V4 open-delta amountIn is named, not printed as zero", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
]),
[
encodeV4Settle(WETH_ADDR),
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
0n, // ActionConstants.OPEN_DELTA
990000n,
),
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("WETH");
expect(detail(result, "Amount").value).toBe(
"All available (V4 open delta)",
);
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
});
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
// output currency, so it says so and titles itself "Uniswap Swap" rather
// than inventing "Token Out: ETH".
test("a PERMIT2_PERMIT-only execute() invents no output token", () => {
const data = buildExecute(
"0x0a",
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR)],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toContain("USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Token Out").address).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
});
test("handles unknown tokens gracefully", () => { test("handles unknown tokens gracefully", () => {
const fakeToken = "0x1111111111111111111111111111111111111111"; const fakeToken = "0x1111111111111111111111111111111111111111";
const data = buildExecute( const data = buildExecute(

View File

@@ -1,182 +0,0 @@
// What the dApp approval screen says the input token of a swap is when the
// calldata did not name one.
//
// Issue #357, the twin on the input side of
// https://git.eeqj.de/sneak/AutistMask/issues/353: `tokenInfo(null)` answered
// `{symbol: "ETH", decimals: 18}`, so a null `inputToken` rendered as
// `Token In: ETH (native)` and titled the swap `Swap ETH -> X`. An
// undetermined input was therefore asserted to the user as native ETH — the
// same class as https://git.eeqj.de/sneak/AutistMask/issues/340 and
// https://git.eeqj.de/sneak/AutistMask/issues/306, naming the wrong asset
// rather than merely mis-scaling it.
//
// The determination this file pins is the one #353 established, checked here
// on the input side: null is NOT how native ETH arrives. v4-core declares
// `type Currency is address` and wraps `address(0)` for native ETH, and a
// user-defined value type over `address` carries the plain `address` ABI
// encoding, so a native-ETH currency reaches the decoder as the truthy string
// "0x0000000000000000000000000000000000000000". WRAP_ETH sets that same
// explicit zero address. Both halves are asserted below: the zero address
// stays ETH, and null refuses.
const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ZERO = "0x0000000000000000000000000000000000000000";
// Both in the bundled list: USDT at 6 decimals, USDC at 6.
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// The same wording the output side refuses with — one screen, one vocabulary.
const REFUSAL = "Unknown (not named in the calldata)";
const ONE_ETH = 1000000000000000000n;
const V4_SWAP_EXACT_IN = 0x07;
const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
function execute(commands, inputs) {
return routerIface.encodeFunctionData("execute", [
commands,
inputs,
9999999999n,
]);
}
function v4Input(actions, params) {
return coder.encode(
["bytes", "bytes[]"],
[new Uint8Array(actions), params],
);
}
// IV4Router.ExactInputParams as the decoder reads it:
// (Currency currencyIn, PathKey[] path, uint128 amountIn, uint128 minOut).
function exactInParams(currencyIn, path, amountIn, amountOutMin) {
return coder.encode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
[[currencyIn, path, amountIn, amountOutMin]],
);
}
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
// uint256 minBalance). It names the output token and nothing about the input.
function balanceCheck(token, minBalance) {
return coder.encode(
["address", "address", "uint256"],
[USER, token, minBalance],
);
}
function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, {});
expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label);
}
describe("an execute() whose input currency never decoded", () => {
// A V4_SWAP whose sub-action params do not decode — an action encoding
// this decoder does not know — leaves every V4 token null. The
// BALANCE_CHECK still names the output, so the screen has a Min. received
// figure and a Token Out, and previously claimed the user was paying ETH
// for them.
const data = () =>
execute(solidityPacked(["uint8", "uint8"], [0x10, 0x0e]), [
coder.encode(["bytes", "bytes[]"], ["0x07", ["0x"]]),
balanceCheck(USDC, 2000000n),
]);
test("says the input token is unknown instead of naming ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL);
});
test("keeps a Token In line, so the screen never omits what is paid", () => {
const tokenIn = detail(data(), "Token In");
expect(tokenIn).toBeDefined();
// A refusal is not a token: nothing to link to an explorer.
expect(tokenIn.address).toBeUndefined();
expect(tokenIn.isToken).toBeUndefined();
});
test("does not name ETH in the swap title either", () => {
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap");
});
});
describe("an execute() that names only an output token", () => {
// A lone BALANCE_CHECK_ERC20: nothing in it says what is being paid.
const data = () => execute("0x0e", [balanceCheck(USDT, 2000000n)]);
test("refuses the input rather than defaulting it to ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL);
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap");
});
test("still states the output it did establish", () => {
expect(detail(data(), "Token Out").value).toContain("USDT");
expect(detail(data(), "Min. received").value).toBe("2.0000 USDT");
});
});
describe("native ETH in, which V4 spells as the zero address", () => {
test("a Currency of address(0) decodes to a truthy address string", () => {
// The fact the whole determination rests on: an absent input currency
// and a native-ETH one are distinguishable here, because the ABI
// decoder never yields null for an address word.
const [currency] = coder.decode(
["address", "uint256", "bool"],
coder.encode(["address", "uint256", "bool"], [ZERO, ONE_ETH, true]),
);
expect(currency).toBe(ZERO);
expect(Boolean(currency)).toBe(true);
});
test("a V4 SETTLE of the zero address is still ETH at 18 decimals", () => {
const data = execute("0x10", [
v4Input(
[V4_SETTLE, V4_SWAP_EXACT_IN, V4_TAKE],
[
coder.encode(
["address", "uint256", "bool"],
[ZERO, ONE_ETH, true],
),
exactInParams(
ZERO,
[[USDT, 500, 10, ZERO, "0x"]],
ONE_ETH,
2000000n,
),
coder.encode(
["address", "address", "uint256"],
[USDT, USER, 0n],
),
],
),
]);
expect(detail(data, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
expect(uniswap.decode(data, ROUTER, {}).name).toBe("Swap ETH → USDT");
});
test("WRAP_ETH is still ETH at 18 decimals", () => {
// WRAP_ETH names no currency of its own; the decoder supplies the
// explicit zero address for it, which is why it survives this change.
const data = execute("0x0b", [
coder.encode(["address", "uint256"], [ROUTER, ONE_ETH]),
]);
expect(detail(data, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
});
});