Compare commits
3 Commits
44b0a153f0
...
78a5573d86
| Author | SHA1 | Date | |
|---|---|---|---|
| 78a5573d86 | |||
| 75a5fa9891 | |||
| c9ebac822a |
40
README.md
40
README.md
@@ -1015,20 +1015,24 @@ saved data cannot be read and that nothing was signed or sent, rather than the
|
||||
generic `-32603` every request used to answer.
|
||||
|
||||
Every other field of the record is floored in `normalizePersisted()` rather than
|
||||
gated. That floor is a type check for the fields something dereferences
|
||||
structurally — `trackedTokens`, each address's `tokenBalances`, `allowedSites`,
|
||||
`deniedSites`, `fraudContracts`, `viewStack`, `networkEndpoints`, plus the
|
||||
scalars `networkId`, `activeAddress` and `selectedToken` — and it checks the
|
||||
ENTRIES as well as the container, because `[1, 2]` is a list,
|
||||
`{"0x…": "notalist"}` is an object, and the dereference is one level below the
|
||||
container check. A malformed entry is dropped. The remaining fields get a
|
||||
`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
|
||||
why the loose floor is still enough for each of them, is listed in the header of
|
||||
`src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS
|
||||
dereferenced walks through truthiness and throws on the first read, which is the
|
||||
blank popup again by a longer route — so adding a field means choosing between
|
||||
the two by what reads it.
|
||||
gated, and the floor is not the same for every field. Some are type-checked as a
|
||||
container AND entry by entry, because `[1, 2]` is a list, `{"0x…": "notalist"}`
|
||||
is an object, and the dereference is one level below the container check; a
|
||||
malformed entry is dropped, except in `networkEndpoints`, where the entry is
|
||||
coerced so an unknown network's endpoints are not lost, and in `viewStack`,
|
||||
where the stack is truncated at the first entry the popup will not reopen onto.
|
||||
Some are type-checked as a scalar. The rest take the stored value verbatim,
|
||||
because nothing dereferences them structurally.
|
||||
|
||||
Which field is which is not written in prose anywhere, deliberately.
|
||||
`tests/persistedFieldContract.test.js` is the list: one row per persisted field,
|
||||
naming the property that field's floor is claimed to have and proving it by
|
||||
driving the real code with hostile values — including a boot of the real popup
|
||||
entry point for every field whose only defence is that nothing dereferences 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
|
||||
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
||||
@@ -1096,6 +1100,14 @@ 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
|
||||
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
|
||||
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
|
||||
|
||||
58
TODO.md
58
TODO.md
@@ -53,23 +53,47 @@ but the review is broader than any of them.
|
||||
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
|
||||
`chrome.storage.local.set` never being called at all. `deniedSites` has the
|
||||
same shape, `fraudContracts` the same class with a milder consequence (a
|
||||
broken send screen, since the boot path only reaches it through
|
||||
`loadHomeTxs()`, which catches), and the sweep for the class turned up
|
||||
`selectedToken`, which blanked the popup outright when restoring onto
|
||||
address-token. All four now get the floor
|
||||
[#311](https://git.eeqj.de/sneak/AutistMask/issues/311) settled — container
|
||||
AND entries, malformed entries dropped — through `textList()` and `siteMap()`
|
||||
beside the existing `tokenRefs()` in `src/shared/persistedState.js`. Site-map
|
||||
keys are written with `defineProperty` for the same reason `networkEndpoints`'
|
||||
are. Separately, a save that fails is no longer swallowed: `onSaveFailure()`
|
||||
in `src/shared/state.js` reports every failed save, awaited or not, and the
|
||||
popup raises a persistent "NOT SAVED" banner; the background refresh loop no
|
||||
longer turns a failure into an unhandled rejection instead of a report. The
|
||||
field-by-field categorisation in the header of `src/shared/stateSchema.js`,
|
||||
and its mirror in `README.md`, were re-verified against the code and moved
|
||||
with the change.
|
||||
|
||||
same shape, `fraudContracts` the same class with a milder consequence, and the
|
||||
sweep for the class turned up `selectedToken`, `rpcUrl` (handed whole to
|
||||
`new JsonRpcProvider()`, which throws synchronously outside any `try`), the
|
||||
entries of `viewData` (four restore branches gate on one truthy field and then
|
||||
dereference an address), and `selectedWallet`/`selectedAddress` (a stored
|
||||
`"map"` is TRUTHY against a real Array, so the restore guard does not
|
||||
short-circuit). All of them are now floored in `src/shared/persistedState.js`
|
||||
or refused by the per-branch guards in `src/popup/viewRouter.js`. Separately,
|
||||
a save that fails is no longer swallowed: `onSaveFailure()` in
|
||||
`src/shared/state.js` reports every failed save, awaited or not, and the popup
|
||||
raises a persistent "NOT SAVED" banner. The hand-written per-field
|
||||
justification in the header of `src/shared/stateSchema.js` — which had shipped
|
||||
a false claim in three consecutive changes — is replaced by
|
||||
`tests/persistedFieldContract.test.js`, one row per persisted field, each
|
||||
proven by driving the real code with hostile values; a field with no row, or a
|
||||
row whose claim is false, now fails `make check`.
|
||||
- 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
|
||||
unknown instead of being called ETH
|
||||
([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)`
|
||||
|
||||
@@ -53,12 +53,17 @@ function resetRenderedViews() {
|
||||
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
|
||||
|
||||
// Views that render an address the user picked and cannot be rendered
|
||||
// without one.
|
||||
// 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) {
|
||||
@@ -74,6 +79,73 @@ 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
|
||||
// true return means the view is both rendered and on screen.
|
||||
//
|
||||
@@ -107,11 +179,11 @@ function renderView(view, state, views) {
|
||||
views.settingsAddToken.show();
|
||||
return true;
|
||||
case "confirm-tx":
|
||||
if (!data.pendingTx) return false;
|
||||
if (!isRenderablePendingTx(data.pendingTx)) return false;
|
||||
views.confirmTx.restore();
|
||||
return true;
|
||||
case "transaction":
|
||||
if (!data.tx) return false;
|
||||
if (!isRenderableTx(data.tx)) return false;
|
||||
views.transactionDetail.render();
|
||||
return true;
|
||||
case "wait-tx":
|
||||
@@ -120,10 +192,13 @@ function renderView(view, state, views) {
|
||||
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:
|
||||
|
||||
@@ -123,26 +123,76 @@ function textList(value) {
|
||||
// could widen a site permission rather than merely throw.
|
||||
//
|
||||
// An address key whose value is not a list of hostnames is dropped entirely: it
|
||||
// grants and denies nothing, and dropping it fails closed. Keys are written
|
||||
// with defineProperty for the same reason networkEndpoints' are — a stored own
|
||||
// "__proto__" key, which JSON can carry, would otherwise be handed to the
|
||||
// prototype setter and recorded nowhere.
|
||||
// grants and denies nothing, and dropping it fails closed. A stored own
|
||||
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
|
||||
// can never be a wallet address, so it grants nothing either, and keeping it
|
||||
// only keeps a value that saveState()'s merge would hand to the prototype
|
||||
// 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) {
|
||||
const out = {};
|
||||
if (!isRecord(value)) return out;
|
||||
for (const address of Object.keys(value)) {
|
||||
if (address === "__proto__") continue;
|
||||
const hostnames = textList(value[address]);
|
||||
if (hostnames.length === 0) continue;
|
||||
Object.defineProperty(out, address, {
|
||||
value: hostnames,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
defineOwn(out, address, 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,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Keep only the leading run of stored views the popup is willing to render.
|
||||
//
|
||||
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
||||
@@ -238,8 +288,15 @@ function normalizePersisted(saved) {
|
||||
out.networkId = isKnownNetworkId(saved.networkId)
|
||||
? saved.networkId
|
||||
: DEFAULT_STATE.networkId;
|
||||
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
||||
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
|
||||
// Non-empty text or the default, never anything else. getProvider()
|
||||
// (src/shared/balances.js) hands rpcUrl straight to `new
|
||||
// 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
|
||||
// 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
|
||||
@@ -253,19 +310,16 @@ function normalizePersisted(saved) {
|
||||
: {};
|
||||
out.networkEndpoints = {};
|
||||
for (const netId of Object.keys(rawEndpoints)) {
|
||||
// defineProperty, not assignment: a stored map with an own
|
||||
// "__proto__" key — which JSON can carry and assignment treats as the
|
||||
// prototype setter — would otherwise replace this object's prototype
|
||||
// and record no entry at all. Keys other than the known network ids
|
||||
// are kept rather than dropped, so a profile that has been on a build
|
||||
// with more networks does not lose their endpoints by passing through
|
||||
// this one.
|
||||
Object.defineProperty(out.networkEndpoints, netId, {
|
||||
value: { ...rawEndpoints[netId] },
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
// Keys other than the known network ids are kept rather than dropped,
|
||||
// so a profile that has been on a build with more networks does not
|
||||
// lose their endpoints by passing through this one. That is why an own
|
||||
// "__proto__" key survives here where siteMap() drops it, and why the
|
||||
// write has to go through defineOwn().
|
||||
defineOwn(
|
||||
out.networkEndpoints,
|
||||
netId,
|
||||
endpointPair(rawEndpoints[netId]),
|
||||
);
|
||||
}
|
||||
// A profile written before this map existed carries exactly one pair of
|
||||
// endpoints, belonging to whatever network it was last on. Adopt it as
|
||||
@@ -334,10 +388,8 @@ function normalizePersisted(saved) {
|
||||
out.theme = saved.theme || "system";
|
||||
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
|
||||
out.currentView = saved.currentView || null;
|
||||
out.selectedWallet =
|
||||
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
|
||||
out.selectedAddress =
|
||||
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
|
||||
out.selectedWallet = listIndex(saved.selectedWallet);
|
||||
out.selectedAddress = listIndex(saved.selectedAddress);
|
||||
// "ETH", or a contract address, or null — never anything else. The popup
|
||||
// restores onto "address-token" behind a truthiness check on this field and
|
||||
// then dereferences it as text (`tokenId.toLowerCase()` in
|
||||
|
||||
@@ -310,12 +310,26 @@ function mergeAddress(base, ours, theirs) {
|
||||
// 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
|
||||
// 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) {
|
||||
base = base || {};
|
||||
ours = ours || {};
|
||||
theirs = theirs || {};
|
||||
const result = {};
|
||||
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)) {
|
||||
seen.add(key);
|
||||
@@ -323,16 +337,16 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
||||
const inOurs = Object.prototype.hasOwnProperty.call(ours, key);
|
||||
if (inBase && !inOurs) continue; // this page deleted the whole entry
|
||||
if (inOurs) {
|
||||
result[key] = mergeLeaf(base[key], ours[key], theirs[key]);
|
||||
put(key, mergeLeaf(base[key], ours[key], theirs[key]));
|
||||
} else {
|
||||
result[key] = theirs[key];
|
||||
put(key, theirs[key]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(ours)) {
|
||||
if (seen.has(key)) continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(base, key)) {
|
||||
result[key] = ours[key];
|
||||
put(key, ours[key]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,44 +26,31 @@
|
||||
//
|
||||
// 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
|
||||
// normalizePersisted()'s to make safe, and what that function does today is
|
||||
// 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:
|
||||
// normalizePersisted()'s to make safe, and what that function does is NOT
|
||||
// uniform across the record.
|
||||
//
|
||||
// Type-checked, container AND entries: trackedTokens, each address's
|
||||
// tokenBalances, allowedSites, deniedSites, fraudContracts, viewStack,
|
||||
// networkEndpoints. These are the fields something dereferences
|
||||
// structurally — iterated, indexed, assigned into, or .toLowerCase()'d —
|
||||
// where a truthy value of the wrong type throws on the first read. The
|
||||
// entries matter as much as the container: [1, 2] IS a list, {"0x…":
|
||||
// "notalist"} IS an object, and the dereference is one level below the
|
||||
// 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.
|
||||
// WHICH FLOOR A GIVEN FIELD HAS IS NOT WRITTEN HERE. It is
|
||||
// tests/persistedFieldContract.test.js: one row per persisted field, naming
|
||||
// the property that field's floor is claimed to have, and PROVING it by
|
||||
// driving the real code with hostile values — the gate for a field the gate
|
||||
// refuses, normalizePersisted() for a field it floors, and a boot of the real
|
||||
// popup entry point for a field whose only defence is that nothing
|
||||
// dereferences it structurally. A field added to PERSISTED_FIELDS with no row
|
||||
// fails that suite; so does a row whose claim is false.
|
||||
//
|
||||
// A field added to the record needs a check here or a floor there, chosen by
|
||||
// what reads it: anything dereferenced structurally needs the type check, and
|
||||
// neither of the last two kinds is one.
|
||||
// That test exists because this comment did not work. It carried a
|
||||
// hand-written justification per field, and it shipped a false one in three
|
||||
// consecutive changes — a different field each time, each caught only by a
|
||||
// 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");
|
||||
|
||||
|
||||
@@ -48,13 +48,79 @@ function formatAmount(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
|
||||
// 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
|
||||
// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
|
||||
// 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) {
|
||||
if (!address || address === "0x0000000000000000000000000000000000000000") {
|
||||
if (!address) {
|
||||
return { symbol: null, decimals: null, address: null };
|
||||
}
|
||||
if (address === "0x0000000000000000000000000000000000000000") {
|
||||
return { symbol: "ETH", decimals: 18, address: null };
|
||||
}
|
||||
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||
@@ -205,6 +271,14 @@ const V4_SWAP_EXACT_OUT = 0x09;
|
||||
const V4_SETTLE = 0x0b;
|
||||
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.
|
||||
// The input is ABI-encoded as (bytes actions, bytes[] params).
|
||||
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
|
||||
@@ -253,13 +327,17 @@ function decodeV4Swap(input) {
|
||||
],
|
||||
params[i],
|
||||
);
|
||||
if (!settleToken) settleToken = s[0][0];
|
||||
if (!present(settleToken)) settleToken = s[0][0];
|
||||
const path = s[0][1];
|
||||
if (path.length > 0 && !takeToken) {
|
||||
if (path.length > 0 && !present(takeToken)) {
|
||||
takeToken = path[path.length - 1][0];
|
||||
}
|
||||
if (!amountIn) amountIn = s[0][2];
|
||||
if (!amountOutMin) amountOutMin = s[0][3];
|
||||
if (!present(amountIn)) {
|
||||
amountIn = v4ExactInAmount(s[0][2]);
|
||||
}
|
||||
if (!present(amountOutMin)) {
|
||||
amountOutMin = s[0][3];
|
||||
}
|
||||
} catch {
|
||||
// Fall through — SETTLE/TAKE will provide tokens
|
||||
}
|
||||
@@ -275,16 +353,20 @@ function decodeV4Swap(input) {
|
||||
);
|
||||
const poolKey = s[0][0];
|
||||
const zeroForOne = s[0][1];
|
||||
if (!settleToken)
|
||||
if (!present(settleToken))
|
||||
settleToken = zeroForOne
|
||||
? poolKey[0]
|
||||
: poolKey[1];
|
||||
if (!takeToken)
|
||||
if (!present(takeToken))
|
||||
takeToken = zeroForOne
|
||||
? poolKey[1]
|
||||
: poolKey[0];
|
||||
if (!amountIn) amountIn = s[0][2];
|
||||
if (!amountOutMin) amountOutMin = s[0][3];
|
||||
if (!present(amountIn)) {
|
||||
amountIn = v4ExactInAmount(s[0][2]);
|
||||
}
|
||||
if (!present(amountOutMin)) {
|
||||
amountOutMin = s[0][3];
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
@@ -301,9 +383,9 @@ function decodeV4Swap(input) {
|
||||
],
|
||||
params[i],
|
||||
);
|
||||
if (!takeToken) takeToken = s[0][0];
|
||||
if (!present(takeToken)) takeToken = s[0][0];
|
||||
const path = s[0][1];
|
||||
if (path.length > 0 && !settleToken) {
|
||||
if (path.length > 0 && !present(settleToken)) {
|
||||
settleToken = path[path.length - 1][0];
|
||||
}
|
||||
} catch {
|
||||
@@ -319,11 +401,11 @@ function decodeV4Swap(input) {
|
||||
);
|
||||
const poolKey = s[0][0];
|
||||
const zeroForOne = s[0][1];
|
||||
if (!settleToken)
|
||||
if (!present(settleToken))
|
||||
settleToken = zeroForOne
|
||||
? poolKey[0]
|
||||
: poolKey[1];
|
||||
if (!takeToken)
|
||||
if (!present(takeToken))
|
||||
takeToken = zeroForOne
|
||||
? poolKey[1]
|
||||
: poolKey[0];
|
||||
@@ -362,11 +444,44 @@ function decode(data, toAddress, sources) {
|
||||
|
||||
let inputToken = null;
|
||||
let inputAmount = null;
|
||||
let inputEstablished = false;
|
||||
let outputToken = null;
|
||||
let minOutput = null;
|
||||
let hasUnwrapWeth = false;
|
||||
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++) {
|
||||
const cmdId = commandsBytes[i] & 0x1f;
|
||||
commandNames.push(
|
||||
@@ -377,69 +492,61 @@ function decode(data, toAddress, sources) {
|
||||
try {
|
||||
if (cmdId === 0x0a) {
|
||||
const p = decodePermit2(inputs[i]);
|
||||
if (p) {
|
||||
inputToken = p.token;
|
||||
inputAmount = p.amount;
|
||||
}
|
||||
// A permit states both halves itself, so it may replace an
|
||||
// input side an earlier hop established without breaking
|
||||
// the invariant.
|
||||
if (p) setInput(p.token, p.amount);
|
||||
}
|
||||
|
||||
if (cmdId === 0x0e) {
|
||||
const b = decodeBalanceCheck(inputs[i]);
|
||||
if (b) {
|
||||
outputToken = b.token;
|
||||
minOutput = b.minBalance;
|
||||
}
|
||||
if (b) setOutput(b.token, b.minBalance);
|
||||
}
|
||||
|
||||
if (cmdId === 0x00) {
|
||||
const s = decodeV3SwapExactIn(inputs[i]);
|
||||
if (s) {
|
||||
if (!inputToken) inputToken = s.tokenIn;
|
||||
if (!inputAmount) inputAmount = s.amountIn;
|
||||
setInputOnce(s.tokenIn, s.amountIn);
|
||||
// Always update output: in multi-step swaps (V3 → V4),
|
||||
// the last swap step determines the final output token
|
||||
// and minimum received amount.
|
||||
outputToken = s.tokenOut;
|
||||
minOutput = s.amountOutMin;
|
||||
setOutput(s.tokenOut, s.amountOutMin);
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdId === 0x08) {
|
||||
const s = decodeV2SwapExactIn(inputs[i]);
|
||||
if (s) {
|
||||
if (!inputToken) inputToken = s.tokenIn;
|
||||
if (!inputAmount) inputAmount = s.amountIn;
|
||||
outputToken = s.tokenOut;
|
||||
minOutput = s.amountOutMin;
|
||||
setInputOnce(s.tokenIn, s.amountIn);
|
||||
setOutput(s.tokenOut, s.amountOutMin);
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdId === 0x0b) {
|
||||
const w = decodeWrapEth(inputs[i]);
|
||||
if (w && !inputToken) {
|
||||
inputToken =
|
||||
"0x0000000000000000000000000000000000000000";
|
||||
inputAmount = w.amount;
|
||||
if (w) {
|
||||
setInputOnce(
|
||||
"0x0000000000000000000000000000000000000000",
|
||||
w.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdId === 0x10) {
|
||||
const v4 = decodeV4Swap(inputs[i]);
|
||||
if (v4) {
|
||||
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
|
||||
if (!inputAmount && v4.amountIn)
|
||||
inputAmount = v4.amountIn;
|
||||
setInputOnce(v4.tokenIn, v4.amountIn);
|
||||
// Always update output: last swap step wins. A step
|
||||
// that carries the Min. received figure but decoded no
|
||||
// output currency makes the output *undetermined* — it
|
||||
// is neither ETH nor whatever an earlier step named,
|
||||
// and that figure is no longer counted in that token.
|
||||
if (v4.tokenOut) {
|
||||
outputToken = v4.tokenOut;
|
||||
} else if (v4.amountOutMin) {
|
||||
outputToken = null;
|
||||
}
|
||||
if (v4.amountOutMin) minOutput = v4.amountOutMin;
|
||||
// Equally, a step that names an output currency but no
|
||||
// minimum leaves Min. received unstated rather than
|
||||
// keeping an earlier step's figure beside the new
|
||||
// token. setOutput() is both rules; a step that states
|
||||
// neither half leaves the output alone.
|
||||
setOutput(v4.tokenOut, v4.amountOutMin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,26 +558,14 @@ function decode(data, toAddress, sources) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve token info.
|
||||
//
|
||||
// A null `outputToken` means undetermined, not native ETH, so it is
|
||||
// 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.
|
||||
// Resolve token info. A null token on either side means the calldata
|
||||
// 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
|
||||
// decode, and it is answered here rather than left to that rule.
|
||||
const inInfo = tokenInfo(inputToken, sources);
|
||||
const outInfo = hasUnwrapWeth
|
||||
? { symbol: "ETH", decimals: 18, address: null }
|
||||
: outputToken
|
||||
? tokenInfo(outputToken, sources)
|
||||
: { symbol: null, decimals: null, address: null };
|
||||
: tokenInfo(outputToken, sources);
|
||||
|
||||
const inSymbol = inInfo.symbol;
|
||||
const outSymbol = outInfo.symbol;
|
||||
@@ -488,7 +583,7 @@ function decode(data, toAddress, sources) {
|
||||
address: toAddress,
|
||||
});
|
||||
|
||||
if (inputToken && inInfo.address) {
|
||||
if (present(inputToken) && present(inInfo.address)) {
|
||||
const label = inSymbol
|
||||
? inSymbol + " (" + inputToken + ")"
|
||||
: inputToken;
|
||||
@@ -500,18 +595,28 @@ function decode(data, toAddress, sources) {
|
||||
});
|
||||
} else if (inSymbol === "ETH") {
|
||||
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 (inputAmount !== null && inputAmount !== undefined) {
|
||||
const maxUint160 = BigInt(
|
||||
"0xffffffffffffffffffffffffffffffffffffffff",
|
||||
);
|
||||
const isUnlimited = inputAmount >= maxUint160;
|
||||
// An unbounded permit needs no scale to describe, so it is still
|
||||
// named rather than refused.
|
||||
const amount = isUnlimited
|
||||
? { raw: "Unlimited", display: "Unlimited" }
|
||||
: amountText(inputAmount, inInfo);
|
||||
if (present(inputAmount)) {
|
||||
// Two amounts need no scale to describe and are named rather than
|
||||
// formatted: V4's open delta, which is not a quantity at all (see
|
||||
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
|
||||
// first — the sentinel is not a bigint and cannot be compared with
|
||||
// one.
|
||||
let amount;
|
||||
if (inputAmount === OPEN_DELTA) {
|
||||
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
||||
} else if (inputAmount >= MAX_UINT160) {
|
||||
amount = { raw: "Unlimited", display: "Unlimited" };
|
||||
} else {
|
||||
amount = amountText(inputAmount, inInfo);
|
||||
}
|
||||
details.push({
|
||||
label: "Amount",
|
||||
value: amount.display,
|
||||
@@ -524,7 +629,7 @@ function decode(data, toAddress, sources) {
|
||||
// entirely, leaving a Min. received figure with nothing saying what is
|
||||
// being received. The Token In line above already falls back to the
|
||||
// address; this does the same.
|
||||
if (outInfo.address) {
|
||||
if (present(outInfo.address)) {
|
||||
const label = outSymbol
|
||||
? outSymbol + " (" + outInfo.address + ")"
|
||||
: outInfo.address;
|
||||
@@ -538,20 +643,25 @@ function decode(data, toAddress, sources) {
|
||||
details.push({ label: "Token Out", value: outSymbol });
|
||||
} else {
|
||||
// Nothing established the output token, so the line says that
|
||||
// rather than going missing or naming a token by default. It reads
|
||||
// as a refusal, the same stance unknownDecimalsAmount() takes on a
|
||||
// scale, so a Min. received figure below it is never attached to a
|
||||
// token the calldata did not state.
|
||||
details.push({
|
||||
label: "Token Out",
|
||||
value: "Unknown (not named in the calldata)",
|
||||
});
|
||||
// rather than going missing or naming a token by default, and a
|
||||
// Min. received figure below it is never attached to a token the
|
||||
// calldata did not state.
|
||||
details.push({ label: "Token Out", value: UNNAMED_CURRENCY });
|
||||
}
|
||||
|
||||
if (minOutput !== null && minOutput !== undefined) {
|
||||
if (present(minOutput)) {
|
||||
// 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({
|
||||
label: "Min. received",
|
||||
value: amountText(minOutput, outInfo).display,
|
||||
value:
|
||||
minOutput === 0n
|
||||
? NO_MINIMUM
|
||||
: amountText(minOutput, outInfo).display,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
||||
|
||||
test("Back onto the transaction detail renders it", () => {
|
||||
reopenedOn("settings", ["main", "transaction"], {
|
||||
viewData: { tx: { hash: "0xdead" } },
|
||||
viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["transactionDetail"]);
|
||||
@@ -162,7 +162,14 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
||||
|
||||
test("Back onto the transaction confirmation restores it", () => {
|
||||
reopenedOn("settings", ["main", "confirm-tx"], {
|
||||
viewData: { pendingTx: { to: ADDRESS, amount: "1" } },
|
||||
viewData: {
|
||||
pendingTx: {
|
||||
token: "ETH",
|
||||
from: ADDRESS,
|
||||
to: ADDRESS,
|
||||
amount: "1",
|
||||
},
|
||||
},
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["confirmTx"]);
|
||||
@@ -171,7 +178,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
||||
|
||||
test("Back onto the success screen renders it", () => {
|
||||
reopenedOn("settings", ["main", "success-tx"], {
|
||||
viewData: { hash: "0xdead" },
|
||||
viewData: { hash: "0xdead", to: ADDRESS },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["successTx"]);
|
||||
@@ -180,7 +187,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
||||
|
||||
test("Back onto the failure screen renders it", () => {
|
||||
reopenedOn("settings", ["main", "error-tx"], {
|
||||
viewData: { message: "execution reverted" },
|
||||
viewData: { message: "execution reverted", to: ADDRESS },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["errorTx"]);
|
||||
|
||||
@@ -93,11 +93,12 @@ describe("the floor under allowedSites and deniedSites", () => {
|
||||
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||
});
|
||||
|
||||
test(`a stored own "__proto__" key in ${field} does not become a prototype`, () => {
|
||||
// JSON can carry the key, and plain assignment would hand it to
|
||||
// the prototype setter — recording no entry and, worse, moving the
|
||||
// map's prototype. Same reason networkEndpoints uses
|
||||
// defineProperty.
|
||||
test(`a stored own "__proto__" key in ${field} is dropped`, () => {
|
||||
// JSON can carry the key. It can never be a wallet address, so it
|
||||
// grants and denies nothing and goes the way of every other key
|
||||
// whose value is unusable; keeping it would only keep a value that
|
||||
// saveState()'s merge hands to the prototype setter on the next
|
||||
// write.
|
||||
const saved = JSON.parse(
|
||||
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
|
||||
);
|
||||
@@ -105,12 +106,38 @@ describe("the floor under allowedSites and deniedSites", () => {
|
||||
const out = normalizePersisted(saved);
|
||||
|
||||
expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype);
|
||||
expect(Object.keys(out[field])).toEqual(["__proto__"]);
|
||||
expect({}.length).toBeUndefined();
|
||||
expect(Object.keys(out[field])).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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", () => {
|
||||
test("fraudContracts that is not a list becomes an empty list", () => {
|
||||
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
||||
|
||||
570
tests/persistedFieldContract.test.js
Normal file
570
tests/persistedFieldContract.test.js
Normal file
@@ -0,0 +1,570 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -101,6 +101,21 @@ function makeElement(id, className) {
|
||||
},
|
||||
querySelector: () => null,
|
||||
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: () => {
|
||||
el.clicked += 1;
|
||||
},
|
||||
|
||||
@@ -94,6 +94,69 @@ function encodeV4Swap(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", () => {
|
||||
test("returns null for non-execute calldata", () => {
|
||||
expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull();
|
||||
@@ -118,6 +181,18 @@ describe("uniswap decoder", () => {
|
||||
expect(tokenIn.value).toContain("USDT");
|
||||
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");
|
||||
expect(steps.value).toContain("Permit2 Permit");
|
||||
expect(steps.value).toContain("V4 Swap");
|
||||
@@ -181,8 +256,7 @@ describe("uniswap decoder", () => {
|
||||
expect(tokenIn.value).toBe("ETH (native)");
|
||||
|
||||
const amount = result.details.find((d) => d.label === "Amount");
|
||||
expect(amount.value).toContain("1.0000");
|
||||
expect(amount.value).toContain("ETH");
|
||||
expect(amount.value).toBe("1.0000 ETH");
|
||||
});
|
||||
|
||||
test("decodes UNWRAP_WETH as ETH output", () => {
|
||||
@@ -338,6 +412,211 @@ describe("uniswap decoder", () => {
|
||||
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", () => {
|
||||
const fakeToken = "0x1111111111111111111111111111111111111111";
|
||||
const data = buildExecute(
|
||||
|
||||
182
tests/uniswapUndeterminedTokenIn.test.js
Normal file
182
tests/uniswapUndeterminedTokenIn.test.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user