Compare commits
1 Commits
78a5573d86
...
44b0a153f0
| Author | SHA1 | Date | |
|---|---|---|---|
| 44b0a153f0 |
40
README.md
40
README.md
@@ -1015,24 +1015,20 @@ saved data cannot be read and that nothing was signed or sent, rather than the
|
|||||||
generic `-32603` every request used to answer.
|
generic `-32603` every request used to answer.
|
||||||
|
|
||||||
Every other field of the record is floored in `normalizePersisted()` rather than
|
Every other field of the record is floored in `normalizePersisted()` rather than
|
||||||
gated, and the floor is not the same for every field. Some are type-checked as a
|
gated. That floor is a type check for the fields something dereferences
|
||||||
container AND entry by entry, because `[1, 2]` is a list, `{"0x…": "notalist"}`
|
structurally — `trackedTokens`, each address's `tokenBalances`, `allowedSites`,
|
||||||
is an object, and the dereference is one level below the container check; a
|
`deniedSites`, `fraudContracts`, `viewStack`, `networkEndpoints`, plus the
|
||||||
malformed entry is dropped, except in `networkEndpoints`, where the entry is
|
scalars `networkId`, `activeAddress` and `selectedToken` — and it checks the
|
||||||
coerced so an unknown network's endpoints are not lost, and in `viewStack`,
|
ENTRIES as well as the container, because `[1, 2]` is a list,
|
||||||
where the stack is truncated at the first entry the popup will not reopen onto.
|
`{"0x…": "notalist"}` is an object, and the dereference is one level below the
|
||||||
Some are type-checked as a scalar. The rest take the stored value verbatim,
|
container check. A malformed entry is dropped. The remaining fields get a
|
||||||
because nothing dereferences them structurally.
|
`saved.x || default` or a present-or-default passthrough that takes the stored
|
||||||
|
value verbatim, with no type check at all; which field is in which category, and
|
||||||
Which field is which is not written in prose anywhere, deliberately.
|
why the loose floor is still enough for each of them, is listed in the header of
|
||||||
`tests/persistedFieldContract.test.js` is the list: one row per persisted field,
|
`src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS
|
||||||
naming the property that field's floor is claimed to have and proving it by
|
dereferenced walks through truthiness and throws on the first read, which is the
|
||||||
driving the real code with hostile values — including a boot of the real popup
|
blank popup again by a longer route — so adding a field means choosing between
|
||||||
entry point for every field whose only defence is that nothing dereferences it.
|
the two by what reads it.
|
||||||
A field added to `PERSISTED_FIELDS` with no row fails `make check`, and so does
|
|
||||||
a row whose claim is false. The per-field justification that used to live in the
|
|
||||||
header of `src/shared/stateSchema.js` shipped a false claim in three consecutive
|
|
||||||
changes, each caught only by a reviewer re-deriving thirty fields by hand.
|
|
||||||
|
|
||||||
The `allowedSites` case is why the entry check is not optional. A stored
|
The `allowedSites` case is why the entry check is not optional. A stored
|
||||||
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
||||||
@@ -1100,14 +1096,6 @@ than only unhiding it, through the same dispatch and data guards as the restore
|
|||||||
(`src/popup/viewRouter.js`), and falls back to Home when the state the target
|
(`src/popup/viewRouter.js`), and falls back to Home when the state the target
|
||||||
would render is gone.
|
would render is gone.
|
||||||
|
|
||||||
Those data guards check the ENTRIES of the stored `viewData`, not just the one
|
|
||||||
field each branch gates on, and the same goes for `selectedWallet` and
|
|
||||||
`selectedAddress`. `restoreView()` is not inside a `try`, so a `TypeError` in a
|
|
||||||
renderer skips the rest of popup init and leaves the user with no view, no
|
|
||||||
message and no control — the same blank popup by a longer route. Anything the
|
|
||||||
restore path dereferences is therefore either floored in `normalizePersisted()`
|
|
||||||
or refused by the guard, and the screen falls back to Home instead.
|
|
||||||
|
|
||||||
It renders only a screen this page load has not rendered yet. Forward navigation
|
It renders only a screen this page load has not rendered yet. Forward navigation
|
||||||
renders as it goes, and `viewRouter.js` records every screen that reaches
|
renders as it goes, and `viewRouter.js` records every screen that reaches
|
||||||
`showView()`, so "Back" onto a screen already on the page unhides it and nothing
|
`showView()`, so "Back" onto a screen already on the page unhides it and nothing
|
||||||
|
|||||||
58
TODO.md
58
TODO.md
@@ -53,47 +53,23 @@ but the review is broader than any of them.
|
|||||||
per-hostname merge, so every save from that moment on failed silently and the
|
per-hostname merge, so every save from that moment on failed silently and the
|
||||||
user went on operating a wallet that was persisting nothing — measured as
|
user went on operating a wallet that was persisting nothing — measured as
|
||||||
`chrome.storage.local.set` never being called at all. `deniedSites` has the
|
`chrome.storage.local.set` never being called at all. `deniedSites` has the
|
||||||
same shape, `fraudContracts` the same class with a milder consequence, and the
|
same shape, `fraudContracts` the same class with a milder consequence (a
|
||||||
sweep for the class turned up `selectedToken`, `rpcUrl` (handed whole to
|
broken send screen, since the boot path only reaches it through
|
||||||
`new JsonRpcProvider()`, which throws synchronously outside any `try`), the
|
`loadHomeTxs()`, which catches), and the sweep for the class turned up
|
||||||
entries of `viewData` (four restore branches gate on one truthy field and then
|
`selectedToken`, which blanked the popup outright when restoring onto
|
||||||
dereference an address), and `selectedWallet`/`selectedAddress` (a stored
|
address-token. All four now get the floor
|
||||||
`"map"` is TRUTHY against a real Array, so the restore guard does not
|
[#311](https://git.eeqj.de/sneak/AutistMask/issues/311) settled — container
|
||||||
short-circuit). All of them are now floored in `src/shared/persistedState.js`
|
AND entries, malformed entries dropped — through `textList()` and `siteMap()`
|
||||||
or refused by the per-branch guards in `src/popup/viewRouter.js`. Separately,
|
beside the existing `tokenRefs()` in `src/shared/persistedState.js`. Site-map
|
||||||
a save that fails is no longer swallowed: `onSaveFailure()` in
|
keys are written with `defineProperty` for the same reason `networkEndpoints`'
|
||||||
`src/shared/state.js` reports every failed save, awaited or not, and the popup
|
are. Separately, a save that fails is no longer swallowed: `onSaveFailure()`
|
||||||
raises a persistent "NOT SAVED" banner. The hand-written per-field
|
in `src/shared/state.js` reports every failed save, awaited or not, and the
|
||||||
justification in the header of `src/shared/stateSchema.js` — which had shipped
|
popup raises a persistent "NOT SAVED" banner; the background refresh loop no
|
||||||
a false claim in three consecutive changes — is replaced by
|
longer turns a failure into an unhandled rejection instead of a report. The
|
||||||
`tests/persistedFieldContract.test.js`, one row per persisted field, each
|
field-by-field categorisation in the header of `src/shared/stateSchema.js`,
|
||||||
proven by driving the real code with hostile values; a field with no row, or a
|
and its mirror in `README.md`, were re-verified against the code and moved
|
||||||
row whose claim is false, now fails `make check`.
|
with the change.
|
||||||
- 2026-08-23: A swap amount and the token it is counted in now always come from
|
|
||||||
the same hop, on both sides of the approval screen
|
|
||||||
([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and
|
|
||||||
[#364](https://git.eeqj.de/sneak/AutistMask/issues/364), the output and input
|
|
||||||
halves of one gate, fixed as one unit). `src/shared/uniswap.js` gated the
|
|
||||||
token and the amount on truthiness and independently; an address is never
|
|
||||||
falsy once set but an amount of `0n` is, so a hop supplying a zero amount
|
|
||||||
fixed the token and left the amount open, and the next hop's figure was then
|
|
||||||
rendered against the first hop's token at that token's scale — 0.5 WETH shown
|
|
||||||
as `500000000000.0000 USDT`, and an earlier hop's `Min. received` shown for a
|
|
||||||
final leg that guarantees nothing. Both sides are now set as a pair through
|
|
||||||
explicit presence, a zero slippage floor reads `None (no minimum guaranteed)`,
|
|
||||||
and V4's `OPEN_DELTA` (an `amountIn` of zero, which `V4Router` reads as "swap
|
|
||||||
the whole open credit") reads `All available (V4 open delta)` instead of
|
|
||||||
`0.0000`.
|
|
||||||
- 2026-08-23: A swap whose input token the calldata never named is said to be
|
|
||||||
unknown instead of being called ETH
|
|
||||||
([#357](https://git.eeqj.de/sneak/AutistMask/issues/357)), the twin on the
|
|
||||||
input side of [#353](https://git.eeqj.de/sneak/AutistMask/issues/353). A null
|
|
||||||
`inputToken` rendered as `Token In: ETH (native)` and titled the swap
|
|
||||||
`Swap ETH -> X`, asserting the user was paying native ETH when nothing in the
|
|
||||||
calldata said so. The null-means-ETH collapse is now gone from `tokenInfo()`
|
|
||||||
itself rather than guarded at each call site: null is refused, and native ETH
|
|
||||||
keeps arriving as the explicit zero address that `WRAP_ETH` and V4's
|
|
||||||
`Currency.wrap(address(0))` both use.
|
|
||||||
- 2026-08-23: A swap whose output token the calldata never named is said to be
|
- 2026-08-23: A swap whose output token the calldata never named is said to be
|
||||||
unknown instead of being called ETH
|
unknown instead of being called ETH
|
||||||
([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)`
|
([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)`
|
||||||
|
|||||||
@@ -53,17 +53,12 @@ function resetRenderedViews() {
|
|||||||
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
|
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
|
||||||
|
|
||||||
// Views that render an address the user picked and cannot be rendered
|
// Views that render an address the user picked and cannot be rendered
|
||||||
// without one. "confirm-tx" is here because its Sign button dereferences
|
// without one.
|
||||||
// `state.wallets[state.selectedWallet].encryptedSecret`
|
|
||||||
// (src/popup/views/confirmTx.js) behind no guard of its own — a screen that
|
|
||||||
// can only throw when the user presses its one button must not be restored
|
|
||||||
// onto.
|
|
||||||
const ADDRESS_VIEWS = new Set([
|
const ADDRESS_VIEWS = new Set([
|
||||||
"address",
|
"address",
|
||||||
"address-token",
|
"address-token",
|
||||||
"receive",
|
"receive",
|
||||||
"transaction",
|
"transaction",
|
||||||
"confirm-tx",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function needsAddress(view) {
|
function needsAddress(view) {
|
||||||
@@ -79,73 +74,6 @@ function hasValidAddress(state) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The stored viewData ENTRIES each branch below dereferences, as opposed to
|
|
||||||
// the one field it gates on.
|
|
||||||
//
|
|
||||||
// A gate on a single truthy field checks the container, not the entries, and
|
|
||||||
// the dereference is one level below it: a stored `{"currentView":
|
|
||||||
// "success-tx","viewData":{"hash":"0x1"}}` passes `data.hash` and then throws
|
|
||||||
// on `address.toLowerCase()` inside addressTitle() (src/popup/views/
|
|
||||||
// helpers.js), out of restoreView(), which src/popup/index.js does not guard —
|
|
||||||
// so the rest of popup init never runs. txStatus.restoreWait() has checked its
|
|
||||||
// own branch's fields since it was written; these are the other four.
|
|
||||||
//
|
|
||||||
// Only what actually throws is required. Fields that are compared,
|
|
||||||
// concatenated or escaped coerce (escapeHtml() and displaySymbol() both
|
|
||||||
// String() their argument), so requiring them would refuse a restorable screen
|
|
||||||
// over a cosmetic value.
|
|
||||||
function isText(value) {
|
|
||||||
return typeof value === "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value) {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// An address handed to renderAddressHtml()/addressTitle(): both reach
|
|
||||||
// `address.slice()` and `address.toLowerCase()` with no guard.
|
|
||||||
function isAddressText(value) {
|
|
||||||
return isText(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decoded calldata, as decodedDetailsHtml() (src/popup/views/txStatus.js)
|
|
||||||
// walks it: `for (const d of decoded.details)` needs an iterable, and each
|
|
||||||
// entry's `address` reaches toAddressHtml(). Absent or falsy is the ordinary
|
|
||||||
// case and short-circuits before either.
|
|
||||||
function isRenderableDecoded(value) {
|
|
||||||
if (!value) return true;
|
|
||||||
if (!isRecord(value)) return false;
|
|
||||||
if (!value.details) return true;
|
|
||||||
if (!Array.isArray(value.details)) return false;
|
|
||||||
return value.details.every(
|
|
||||||
(entry) =>
|
|
||||||
isRecord(entry) && (!entry.address || isAddressText(entry.address)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The pending transaction confirmTx.show() renders: `token` reaches
|
|
||||||
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
|
|
||||||
// addressTitle(), makeBlockie() and getLocalWarnings().
|
|
||||||
function isRenderablePendingTx(value) {
|
|
||||||
return (
|
|
||||||
isRecord(value) &&
|
|
||||||
isText(value.token) &&
|
|
||||||
isAddressText(value.from) &&
|
|
||||||
isAddressText(value.to)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The stored transaction transactionDetail.render() shows. contractAddress is
|
|
||||||
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
|
|
||||||
function isRenderableTx(value) {
|
|
||||||
return (
|
|
||||||
isRecord(value) &&
|
|
||||||
isAddressText(value.from) &&
|
|
||||||
isAddressText(value.to) &&
|
|
||||||
(!value.contractAddress || isAddressText(value.contractAddress))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render `view` from persisted state. Each view module shows itself, so a
|
// Render `view` from persisted state. Each view module shows itself, so a
|
||||||
// true return means the view is both rendered and on screen.
|
// true return means the view is both rendered and on screen.
|
||||||
//
|
//
|
||||||
@@ -179,11 +107,11 @@ function renderView(view, state, views) {
|
|||||||
views.settingsAddToken.show();
|
views.settingsAddToken.show();
|
||||||
return true;
|
return true;
|
||||||
case "confirm-tx":
|
case "confirm-tx":
|
||||||
if (!isRenderablePendingTx(data.pendingTx)) return false;
|
if (!data.pendingTx) return false;
|
||||||
views.confirmTx.restore();
|
views.confirmTx.restore();
|
||||||
return true;
|
return true;
|
||||||
case "transaction":
|
case "transaction":
|
||||||
if (!isRenderableTx(data.tx)) return false;
|
if (!data.tx) return false;
|
||||||
views.transactionDetail.render();
|
views.transactionDetail.render();
|
||||||
return true;
|
return true;
|
||||||
case "wait-tx":
|
case "wait-tx":
|
||||||
@@ -192,13 +120,10 @@ function renderView(view, state, views) {
|
|||||||
return Boolean(views.txStatus.restoreWait());
|
return Boolean(views.txStatus.restoreWait());
|
||||||
case "success-tx":
|
case "success-tx":
|
||||||
if (!data.hash) return false;
|
if (!data.hash) return false;
|
||||||
if (!isAddressText(data.to)) return false;
|
|
||||||
if (!isRenderableDecoded(data.decoded)) return false;
|
|
||||||
views.txStatus.renderSuccess();
|
views.txStatus.renderSuccess();
|
||||||
return true;
|
return true;
|
||||||
case "error-tx":
|
case "error-tx":
|
||||||
if (!data.message) return false;
|
if (!data.message) return false;
|
||||||
if (!isAddressText(data.to)) return false;
|
|
||||||
views.txStatus.renderError();
|
views.txStatus.renderError();
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -123,75 +123,25 @@ function textList(value) {
|
|||||||
// could widen a site permission rather than merely throw.
|
// could widen a site permission rather than merely throw.
|
||||||
//
|
//
|
||||||
// An address key whose value is not a list of hostnames is dropped entirely: it
|
// An address key whose value is not a list of hostnames is dropped entirely: it
|
||||||
// grants and denies nothing, and dropping it fails closed. A stored own
|
// grants and denies nothing, and dropping it fails closed. Keys are written
|
||||||
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
|
// with defineProperty for the same reason networkEndpoints' are — a stored own
|
||||||
// can never be a wallet address, so it grants nothing either, and keeping it
|
// "__proto__" key, which JSON can carry, would otherwise be handed to the
|
||||||
// only keeps a value that saveState()'s merge would hand to the prototype
|
// prototype setter and recorded nowhere.
|
||||||
// setter on the next write. Keys are written with defineProperty so that no key
|
|
||||||
// reaching this function can consult a setter at all, whatever the rule above
|
|
||||||
// it becomes; mergeMapByKey() in src/shared/state.js writes the same way.
|
|
||||||
function siteMap(value) {
|
function siteMap(value) {
|
||||||
const out = {};
|
const out = {};
|
||||||
if (!isRecord(value)) return out;
|
if (!isRecord(value)) return out;
|
||||||
for (const address of Object.keys(value)) {
|
for (const address of Object.keys(value)) {
|
||||||
if (address === "__proto__") continue;
|
|
||||||
const hostnames = textList(value[address]);
|
const hostnames = textList(value[address]);
|
||||||
if (hostnames.length === 0) continue;
|
if (hostnames.length === 0) continue;
|
||||||
defineOwn(out, address, hostnames);
|
Object.defineProperty(out, address, {
|
||||||
}
|
value: hostnames,
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// An endpoint URL: non-empty text, or the fallback.
|
|
||||||
function url(value, fallback) {
|
|
||||||
return typeof value === "string" && value !== "" ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One remembered endpoint pair out of networkEndpoints, floored on the two
|
|
||||||
// fields applyChainSwitchFields() (src/shared/chainSwitchFields.js) assigns
|
|
||||||
// STRAIGHT ONTO s.rpcUrl / s.blockscoutUrl on the next chain switch: flooring
|
|
||||||
// the live fields alone would leave a non-string sitting one switch away from
|
|
||||||
// them. A field that is not text is deleted rather than replaced, so the
|
|
||||||
// switch falls through its own `|| net.defaultRpcUrl`. Anything else the pair
|
|
||||||
// carries is kept: a profile that has been on a build storing more per-network
|
|
||||||
// fields must not lose them by passing through this one.
|
|
||||||
function endpointPair(value) {
|
|
||||||
const pair = { ...(isRecord(value) ? value : {}) };
|
|
||||||
for (const field of ["rpcUrl", "blockscoutUrl"]) {
|
|
||||||
if (typeof pair[field] !== "string" || pair[field] === "") {
|
|
||||||
delete pair[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pair;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A list index into wallets / a wallet's addresses: a non-negative integer, or
|
|
||||||
// null for "nothing selected".
|
|
||||||
//
|
|
||||||
// hasValidAddress() (src/popup/viewRouter.js) guards the restore path with
|
|
||||||
// `state.wallets[state.selectedWallet] && …addresses[state.selectedAddress]`,
|
|
||||||
// which is safe for a stale INTEGER — out of range is undefined, and the `&&`
|
|
||||||
// short-circuits — and NOT safe for a string naming an Array.prototype member.
|
|
||||||
// `wallets["map"]` is truthy, so the guard does not short-circuit and
|
|
||||||
// `.addresses[…]` throws out of restoreView(): the dead popup. "length",
|
|
||||||
// "constructor" and "__proto__" answer the same way, and
|
|
||||||
// src/popup/views/confirmTx.js dereferences selectedWallet behind no guard at
|
|
||||||
// all.
|
|
||||||
function listIndex(value) {
|
|
||||||
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write `key` as an own data property, never through a setter. Plain
|
|
||||||
// assignment of "__proto__" replaces the object's prototype and records no
|
|
||||||
// entry; every map built from stored keys goes through this.
|
|
||||||
function defineOwn(obj, key, value) {
|
|
||||||
Object.defineProperty(obj, key, {
|
|
||||||
value: value,
|
|
||||||
writable: true,
|
writable: true,
|
||||||
enumerable: true,
|
enumerable: true,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Keep only the leading run of stored views the popup is willing to render.
|
// Keep only the leading run of stored views the popup is willing to render.
|
||||||
//
|
//
|
||||||
@@ -288,15 +238,8 @@ function normalizePersisted(saved) {
|
|||||||
out.networkId = isKnownNetworkId(saved.networkId)
|
out.networkId = isKnownNetworkId(saved.networkId)
|
||||||
? saved.networkId
|
? saved.networkId
|
||||||
: DEFAULT_STATE.networkId;
|
: DEFAULT_STATE.networkId;
|
||||||
// Non-empty text or the default, never anything else. getProvider()
|
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
||||||
// (src/shared/balances.js) hands rpcUrl straight to `new
|
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
|
||||||
// JsonRpcProvider()`, which throws SYNCHRONOUSLY for a value that is not a
|
|
||||||
// string — out of src/popup/views/txStatus.js and src/popup/views/
|
|
||||||
// addWallet.js, neither of which is inside a try, and the first of which a
|
|
||||||
// stored `currentView: "wait-tx"` reaches through restoreView(). It is a
|
|
||||||
// scalar, so the type check is the whole fix.
|
|
||||||
out.rpcUrl = url(saved.rpcUrl, DEFAULT_STATE.rpcUrl);
|
|
||||||
out.blockscoutUrl = url(saved.blockscoutUrl, DEFAULT_STATE.blockscoutUrl);
|
|
||||||
// An actual object is required, not merely a truthy non-array: the code
|
// An actual object is required, not merely a truthy non-array: the code
|
||||||
// below and applyChainSwitchFields() index and ASSIGN INTO this value, and
|
// below and applyChainSwitchFields() index and ASSIGN INTO this value, and
|
||||||
// assigning a property to a string or a number is a silent no-op in
|
// assigning a property to a string or a number is a silent no-op in
|
||||||
@@ -310,16 +253,19 @@ function normalizePersisted(saved) {
|
|||||||
: {};
|
: {};
|
||||||
out.networkEndpoints = {};
|
out.networkEndpoints = {};
|
||||||
for (const netId of Object.keys(rawEndpoints)) {
|
for (const netId of Object.keys(rawEndpoints)) {
|
||||||
// Keys other than the known network ids are kept rather than dropped,
|
// defineProperty, not assignment: a stored map with an own
|
||||||
// so a profile that has been on a build with more networks does not
|
// "__proto__" key — which JSON can carry and assignment treats as the
|
||||||
// lose their endpoints by passing through this one. That is why an own
|
// prototype setter — would otherwise replace this object's prototype
|
||||||
// "__proto__" key survives here where siteMap() drops it, and why the
|
// and record no entry at all. Keys other than the known network ids
|
||||||
// write has to go through defineOwn().
|
// are kept rather than dropped, so a profile that has been on a build
|
||||||
defineOwn(
|
// with more networks does not lose their endpoints by passing through
|
||||||
out.networkEndpoints,
|
// this one.
|
||||||
netId,
|
Object.defineProperty(out.networkEndpoints, netId, {
|
||||||
endpointPair(rawEndpoints[netId]),
|
value: { ...rawEndpoints[netId] },
|
||||||
);
|
writable: true,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// A profile written before this map existed carries exactly one pair of
|
// A profile written before this map existed carries exactly one pair of
|
||||||
// endpoints, belonging to whatever network it was last on. Adopt it as
|
// endpoints, belonging to whatever network it was last on. Adopt it as
|
||||||
@@ -388,8 +334,10 @@ function normalizePersisted(saved) {
|
|||||||
out.theme = saved.theme || "system";
|
out.theme = saved.theme || "system";
|
||||||
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
|
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
|
||||||
out.currentView = saved.currentView || null;
|
out.currentView = saved.currentView || null;
|
||||||
out.selectedWallet = listIndex(saved.selectedWallet);
|
out.selectedWallet =
|
||||||
out.selectedAddress = listIndex(saved.selectedAddress);
|
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
|
||||||
|
out.selectedAddress =
|
||||||
|
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
|
||||||
// "ETH", or a contract address, or null — never anything else. The popup
|
// "ETH", or a contract address, or null — never anything else. The popup
|
||||||
// restores onto "address-token" behind a truthiness check on this field and
|
// restores onto "address-token" behind a truthiness check on this field and
|
||||||
// then dereferences it as text (`tokenId.toLowerCase()` in
|
// then dereferences it as text (`tokenId.toLowerCase()` in
|
||||||
|
|||||||
@@ -310,26 +310,12 @@ function mergeAddress(base, ours, theirs) {
|
|||||||
// a key another page edited. Unlike an array's identity function, an object
|
// a key another page edited. Unlike an array's identity function, an object
|
||||||
// key can't collide with a different logical entry (Object.keys() is
|
// key can't collide with a different logical entry (Object.keys() is
|
||||||
// already deduplicated), so this needs no collision floor of its own.
|
// already deduplicated), so this needs no collision floor of its own.
|
||||||
//
|
|
||||||
// Every write goes through defineProperty rather than assignment. The keys are
|
|
||||||
// whatever the stored record carries, and plain assignment of "__proto__" —
|
|
||||||
// which JSON can carry and normalizePersisted() keeps for networkEndpoints —
|
|
||||||
// replaces this object's prototype and records no entry. That would undo one
|
|
||||||
// layer downstream exactly what defineOwn() does in
|
|
||||||
// src/shared/persistedState.js.
|
|
||||||
function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
||||||
base = base || {};
|
base = base || {};
|
||||||
ours = ours || {};
|
ours = ours || {};
|
||||||
theirs = theirs || {};
|
theirs = theirs || {};
|
||||||
const result = {};
|
const result = {};
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const put = (key, value) =>
|
|
||||||
Object.defineProperty(result, key, {
|
|
||||||
value: value,
|
|
||||||
writable: true,
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const key of Object.keys(theirs)) {
|
for (const key of Object.keys(theirs)) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
@@ -337,16 +323,16 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
|||||||
const inOurs = Object.prototype.hasOwnProperty.call(ours, key);
|
const inOurs = Object.prototype.hasOwnProperty.call(ours, key);
|
||||||
if (inBase && !inOurs) continue; // this page deleted the whole entry
|
if (inBase && !inOurs) continue; // this page deleted the whole entry
|
||||||
if (inOurs) {
|
if (inOurs) {
|
||||||
put(key, mergeLeaf(base[key], ours[key], theirs[key]));
|
result[key] = mergeLeaf(base[key], ours[key], theirs[key]);
|
||||||
} else {
|
} else {
|
||||||
put(key, theirs[key]);
|
result[key] = theirs[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of Object.keys(ours)) {
|
for (const key of Object.keys(ours)) {
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
if (!Object.prototype.hasOwnProperty.call(base, key)) {
|
if (!Object.prototype.hasOwnProperty.call(base, key)) {
|
||||||
put(key, ours[key]);
|
result[key] = ours[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,31 +26,44 @@
|
|||||||
//
|
//
|
||||||
// What is checked HERE is what nothing downstream can floor: the wallet list,
|
// What is checked HERE is what nothing downstream can floor: the wallet list,
|
||||||
// the version, and the network id that keys an object. Every other field is
|
// the version, and the network id that keys an object. Every other field is
|
||||||
// normalizePersisted()'s to make safe, and what that function does is NOT
|
// normalizePersisted()'s to make safe, and what that function does today is
|
||||||
// uniform across the record.
|
// NOT uniform. The four kinds of floor it applies, listed so a reader can tell
|
||||||
|
// which one a given field has without reading it off:
|
||||||
//
|
//
|
||||||
// WHICH FLOOR A GIVEN FIELD HAS IS NOT WRITTEN HERE. It is
|
// Type-checked, container AND entries: trackedTokens, each address's
|
||||||
// tests/persistedFieldContract.test.js: one row per persisted field, naming
|
// tokenBalances, allowedSites, deniedSites, fraudContracts, viewStack,
|
||||||
// the property that field's floor is claimed to have, and PROVING it by
|
// networkEndpoints. These are the fields something dereferences
|
||||||
// driving the real code with hostile values — the gate for a field the gate
|
// structurally — iterated, indexed, assigned into, or .toLowerCase()'d —
|
||||||
// refuses, normalizePersisted() for a field it floors, and a boot of the real
|
// where a truthy value of the wrong type throws on the first read. The
|
||||||
// popup entry point for a field whose only defence is that nothing
|
// entries matter as much as the container: [1, 2] IS a list, {"0x…":
|
||||||
// dereferences it structurally. A field added to PERSISTED_FIELDS with no row
|
// "notalist"} IS an object, and the dereference is one level below the
|
||||||
// fails that suite; so does a row whose claim is false.
|
// container check. A malformed entry is dropped. networkEndpoints is the
|
||||||
|
// one whose entries are coerced rather than dropped: each value is spread
|
||||||
|
// into a fresh record, so a stored scalar becomes a record with no
|
||||||
|
// rpcUrl/blockscoutUrl and falls to the network defaults.
|
||||||
|
// Type-checked scalar: networkId, activeAddress, selectedToken. Each is
|
||||||
|
// dereferenced as text or used as an object key, so a truthy value of the
|
||||||
|
// wrong type throws or lands somewhere it should not; each falls back to
|
||||||
|
// its default or to null.
|
||||||
|
// `saved.x || default`, no type check: rpcUrl, blockscoutUrl,
|
||||||
|
// lastBalanceRefresh, tokenHolderCache, theme, currentView, viewData.
|
||||||
|
// None of these is dereferenced structurally, which is why the loose
|
||||||
|
// floor is still enough, and each reason is a fact about the readers, not
|
||||||
|
// a promise: the two URLs are only concatenated into a fetch URL and
|
||||||
|
// handed to ethers, where a bad value fails the request on a path that
|
||||||
|
// already catches; lastBalanceRefresh is only arithmetic; theme and
|
||||||
|
// currentView are only compared and concatenated (both fall through to a
|
||||||
|
// default branch, and currentView is additionally gated by
|
||||||
|
// RESTORABLE_VIEWS.has() before anything renders from it); viewData is
|
||||||
|
// only read field-by-field, and a field of a string or a number is
|
||||||
|
// undefined rather than a throw; nothing in src/ reads tokenHolderCache at
|
||||||
|
// all — it is only ever reset wholesale.
|
||||||
|
// Present-or-default, value taken verbatim: every boolean flag,
|
||||||
|
// dustThresholdGwei, selectedWallet, selectedAddress.
|
||||||
//
|
//
|
||||||
// That test exists because this comment did not work. It carried a
|
// A field added to the record needs a check here or a floor there, chosen by
|
||||||
// hand-written justification per field, and it shipped a false one in three
|
// what reads it: anything dereferenced structurally needs the type check, and
|
||||||
// consecutive changes — a different field each time, each caught only by a
|
// neither of the last two kinds is one.
|
||||||
// reviewer re-deriving thirty fields by hand. A claim nobody can execute is
|
|
||||||
// worse than no claim, because it is believed.
|
|
||||||
//
|
|
||||||
// The trap is worth stating here, since it is what all three got wrong: a
|
|
||||||
// check on a CONTAINER is not a check on its ENTRIES, and the dereference is
|
|
||||||
// one level below the container. `[1, 2]` is a list, `{"0x…": "notalist"}` is
|
|
||||||
// a record, and `{"currentView":"success-tx","viewData":{"hash":"0x1"}}`
|
|
||||||
// passes the restore gate and throws on the address the renderer below it
|
|
||||||
// reads. A field added to the record needs a decision about its entries as
|
|
||||||
// well as its shape — and then a row in that test.
|
|
||||||
|
|
||||||
const { isKnownNetworkId } = require("./networks");
|
const { isKnownNetworkId } = require("./networks");
|
||||||
|
|
||||||
|
|||||||
@@ -48,79 +48,13 @@ function formatAmount(raw, decimals) {
|
|||||||
return truncateAmountNeverZero(formatUnits(raw, decimals));
|
return truncateAmountNeverZero(formatUnits(raw, decimals));
|
||||||
}
|
}
|
||||||
|
|
||||||
// One wording for either side of the screen: a currency the calldata never
|
|
||||||
// named. It reads as a refusal, the same stance unknownDecimalsAmount() takes
|
|
||||||
// on a scale — not as a token name, and not as a quantity.
|
|
||||||
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
|
|
||||||
|
|
||||||
// Explicit presence, never truthiness. Every gate in this file that guards a
|
|
||||||
// decoded value goes through here: an address is never falsy once set, but an
|
|
||||||
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
|
|
||||||
// value is the trap this decoder has now been bitten by five times.
|
|
||||||
function present(value) {
|
|
||||||
return value !== null && value !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uniswap V4 spells "use the whole open delta" as an amount of zero:
|
|
||||||
// v4-periphery `src/libraries/ActionConstants.sol` declares
|
|
||||||
// `uint128 internal constant OPEN_DELTA = 0` ("used to signal that an action
|
|
||||||
// should use the input value of the open delta on the pool manager or of the
|
|
||||||
// balance that the contract holds"), and `src/V4Router.sol` substitutes the
|
|
||||||
// full open credit whenever an exact-in swap action's `amountIn` equals it:
|
|
||||||
//
|
|
||||||
// uint128 amountIn = params.amountIn;
|
|
||||||
// if (amountIn == ActionConstants.OPEN_DELTA) {
|
|
||||||
// amountIn = _getFullCredit(...).toUint128();
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// in both `_swapExactInputSingle` and `_swapExactInput`. Sentinel and literal
|
|
||||||
// zero are the same uint128 word, so the encoding CANNOT distinguish them —
|
|
||||||
// and the router does not try: it reads every zero as the sentinel, so in V4
|
|
||||||
// there is no such thing as an exact-in swap of literally zero. The amount is
|
|
||||||
// therefore not stated by the calldata at all; it is whatever credit is open
|
|
||||||
// at execution time. It is carried as this sentinel rather than as 0n because
|
|
||||||
// printing "0.0000" would state the exact inverse of what will happen —
|
|
||||||
// "nothing is being swapped" for a step that swaps the entire balance.
|
|
||||||
//
|
|
||||||
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
|
|
||||||
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
|
|
||||||
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
|
|
||||||
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
|
|
||||||
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
|
|
||||||
// so a zero `amountIn` there is a literal zero and is displayed as one.
|
|
||||||
const OPEN_DELTA = Symbol("v4-open-delta");
|
|
||||||
|
|
||||||
// The two amount lines that state a fact instead of a quantity. Same register
|
|
||||||
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
|
|
||||||
// as a number — and deliberately not a third phrasing of "not named": these
|
|
||||||
// say different things.
|
|
||||||
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
|
||||||
const NO_MINIMUM = "None (no minimum guaranteed)";
|
|
||||||
|
|
||||||
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
|
||||||
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
|
|
||||||
|
|
||||||
// `decimals` is null when nothing knows this token's scale. It is not
|
// `decimals` is null when nothing knows this token's scale. It is not
|
||||||
// defaulted to 18: the swap lines land on the same approval screen as the
|
// defaulted to 18: the swap lines land on the same approval screen as the
|
||||||
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
|
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
|
||||||
// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
|
// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
|
||||||
// on `state`; resolveTokenDecimals() reads the bundled list, then those.
|
// on `state`; resolveTokenDecimals() reads the bundled list, then those.
|
||||||
//
|
|
||||||
// A null `address` means UNDETERMINED — the calldata named no currency for
|
|
||||||
// that side — and is refused rather than named. It is not native ETH: Uniswap
|
|
||||||
// V4 spells native ETH as `Currency.wrap(address(0))` (v4-core
|
|
||||||
// `type Currency is address`), and a Currency is ABI-encoded as a plain
|
|
||||||
// address word, so every decode site here gets back the truthy string
|
|
||||||
// "0x0000000000000000000000000000000000000000" for it — never null. WRAP_ETH
|
|
||||||
// sets that same explicit zero address, and an UNWRAP_WETH output is caught by
|
|
||||||
// its caller before this is consulted, so nothing that genuinely is ETH
|
|
||||||
// arrives null. Naming a null ETH states the wrong asset and formats its
|
|
||||||
// amount at the wrong scale.
|
|
||||||
function tokenInfo(address, sources) {
|
function tokenInfo(address, sources) {
|
||||||
if (!address) {
|
if (!address || address === "0x0000000000000000000000000000000000000000") {
|
||||||
return { symbol: null, decimals: null, address: null };
|
|
||||||
}
|
|
||||||
if (address === "0x0000000000000000000000000000000000000000") {
|
|
||||||
return { symbol: "ETH", decimals: 18, address: null };
|
return { symbol: "ETH", decimals: 18, address: null };
|
||||||
}
|
}
|
||||||
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||||
@@ -271,14 +205,6 @@ const V4_SWAP_EXACT_OUT = 0x09;
|
|||||||
const V4_SETTLE = 0x0b;
|
const V4_SETTLE = 0x0b;
|
||||||
const V4_TAKE = 0x0e;
|
const V4_TAKE = 0x0e;
|
||||||
|
|
||||||
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
|
|
||||||
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
|
|
||||||
// comment above. The exact-OUT actions below decode no amounts at all, so
|
|
||||||
// their own OPEN_DELTA mapping on `amountOut` never reaches the screen.
|
|
||||||
function v4ExactInAmount(raw) {
|
|
||||||
return raw === 0n ? OPEN_DELTA : raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode V4_SWAP (command 0x10) input bytes.
|
// Decode V4_SWAP (command 0x10) input bytes.
|
||||||
// The input is ABI-encoded as (bytes actions, bytes[] params).
|
// The input is ABI-encoded as (bytes actions, bytes[] params).
|
||||||
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
|
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
|
||||||
@@ -327,17 +253,13 @@ function decodeV4Swap(input) {
|
|||||||
],
|
],
|
||||||
params[i],
|
params[i],
|
||||||
);
|
);
|
||||||
if (!present(settleToken)) settleToken = s[0][0];
|
if (!settleToken) settleToken = s[0][0];
|
||||||
const path = s[0][1];
|
const path = s[0][1];
|
||||||
if (path.length > 0 && !present(takeToken)) {
|
if (path.length > 0 && !takeToken) {
|
||||||
takeToken = path[path.length - 1][0];
|
takeToken = path[path.length - 1][0];
|
||||||
}
|
}
|
||||||
if (!present(amountIn)) {
|
if (!amountIn) amountIn = s[0][2];
|
||||||
amountIn = v4ExactInAmount(s[0][2]);
|
if (!amountOutMin) amountOutMin = s[0][3];
|
||||||
}
|
|
||||||
if (!present(amountOutMin)) {
|
|
||||||
amountOutMin = s[0][3];
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through — SETTLE/TAKE will provide tokens
|
// Fall through — SETTLE/TAKE will provide tokens
|
||||||
}
|
}
|
||||||
@@ -353,20 +275,16 @@ function decodeV4Swap(input) {
|
|||||||
);
|
);
|
||||||
const poolKey = s[0][0];
|
const poolKey = s[0][0];
|
||||||
const zeroForOne = s[0][1];
|
const zeroForOne = s[0][1];
|
||||||
if (!present(settleToken))
|
if (!settleToken)
|
||||||
settleToken = zeroForOne
|
settleToken = zeroForOne
|
||||||
? poolKey[0]
|
? poolKey[0]
|
||||||
: poolKey[1];
|
: poolKey[1];
|
||||||
if (!present(takeToken))
|
if (!takeToken)
|
||||||
takeToken = zeroForOne
|
takeToken = zeroForOne
|
||||||
? poolKey[1]
|
? poolKey[1]
|
||||||
: poolKey[0];
|
: poolKey[0];
|
||||||
if (!present(amountIn)) {
|
if (!amountIn) amountIn = s[0][2];
|
||||||
amountIn = v4ExactInAmount(s[0][2]);
|
if (!amountOutMin) amountOutMin = s[0][3];
|
||||||
}
|
|
||||||
if (!present(amountOutMin)) {
|
|
||||||
amountOutMin = s[0][3];
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through
|
// Fall through
|
||||||
}
|
}
|
||||||
@@ -383,9 +301,9 @@ function decodeV4Swap(input) {
|
|||||||
],
|
],
|
||||||
params[i],
|
params[i],
|
||||||
);
|
);
|
||||||
if (!present(takeToken)) takeToken = s[0][0];
|
if (!takeToken) takeToken = s[0][0];
|
||||||
const path = s[0][1];
|
const path = s[0][1];
|
||||||
if (path.length > 0 && !present(settleToken)) {
|
if (path.length > 0 && !settleToken) {
|
||||||
settleToken = path[path.length - 1][0];
|
settleToken = path[path.length - 1][0];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -401,11 +319,11 @@ function decodeV4Swap(input) {
|
|||||||
);
|
);
|
||||||
const poolKey = s[0][0];
|
const poolKey = s[0][0];
|
||||||
const zeroForOne = s[0][1];
|
const zeroForOne = s[0][1];
|
||||||
if (!present(settleToken))
|
if (!settleToken)
|
||||||
settleToken = zeroForOne
|
settleToken = zeroForOne
|
||||||
? poolKey[0]
|
? poolKey[0]
|
||||||
: poolKey[1];
|
: poolKey[1];
|
||||||
if (!present(takeToken))
|
if (!takeToken)
|
||||||
takeToken = zeroForOne
|
takeToken = zeroForOne
|
||||||
? poolKey[1]
|
? poolKey[1]
|
||||||
: poolKey[0];
|
: poolKey[0];
|
||||||
@@ -444,44 +362,11 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
let inputToken = null;
|
let inputToken = null;
|
||||||
let inputAmount = null;
|
let inputAmount = null;
|
||||||
let inputEstablished = false;
|
|
||||||
let outputToken = null;
|
let outputToken = null;
|
||||||
let minOutput = null;
|
let minOutput = null;
|
||||||
let hasUnwrapWeth = false;
|
let hasUnwrapWeth = false;
|
||||||
const commandNames = [];
|
const commandNames = [];
|
||||||
|
|
||||||
// THE INVARIANT: an amount and the token it is counted in always come
|
|
||||||
// from the same hop. A figure is never rendered against a token that
|
|
||||||
// did not supply it.
|
|
||||||
//
|
|
||||||
// Both sides are therefore set as a PAIR — never field by field, and
|
|
||||||
// never on truthiness. An address is never falsy once set but an
|
|
||||||
// amount of 0n is, so gating the two halves independently let a hop
|
|
||||||
// with a zero amount fix the token and leave the amount open; the next
|
|
||||||
// hop's figure was then displayed against the first hop's token, at the
|
|
||||||
// first hop's scale. A V3 USDT->WETH hop with amountIn 0 followed by a
|
|
||||||
// V2 WETH->USDC hop of 0.5e18 rendered "500000000000.0000 USDT".
|
|
||||||
//
|
|
||||||
// The input side is fixed by the first hop that states either half, the
|
|
||||||
// output side by the last, because the final leg is what the user
|
|
||||||
// receives. A half the establishing hop did not state stays null and
|
|
||||||
// the line says so, rather than being filled in from a different hop.
|
|
||||||
const setInput = (token, amount) => {
|
|
||||||
inputToken = present(token) ? token : null;
|
|
||||||
inputAmount = present(amount) ? amount : null;
|
|
||||||
inputEstablished = true;
|
|
||||||
};
|
|
||||||
const setInputOnce = (token, amount) => {
|
|
||||||
if (inputEstablished) return;
|
|
||||||
if (!present(token) && !present(amount)) return;
|
|
||||||
setInput(token, amount);
|
|
||||||
};
|
|
||||||
const setOutput = (token, amount) => {
|
|
||||||
if (!present(token) && !present(amount)) return;
|
|
||||||
outputToken = present(token) ? token : null;
|
|
||||||
minOutput = present(amount) ? amount : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < commandsBytes.length; i++) {
|
for (let i = 0; i < commandsBytes.length; i++) {
|
||||||
const cmdId = commandsBytes[i] & 0x1f;
|
const cmdId = commandsBytes[i] & 0x1f;
|
||||||
commandNames.push(
|
commandNames.push(
|
||||||
@@ -492,61 +377,69 @@ function decode(data, toAddress, sources) {
|
|||||||
try {
|
try {
|
||||||
if (cmdId === 0x0a) {
|
if (cmdId === 0x0a) {
|
||||||
const p = decodePermit2(inputs[i]);
|
const p = decodePermit2(inputs[i]);
|
||||||
// A permit states both halves itself, so it may replace an
|
if (p) {
|
||||||
// input side an earlier hop established without breaking
|
inputToken = p.token;
|
||||||
// the invariant.
|
inputAmount = p.amount;
|
||||||
if (p) setInput(p.token, p.amount);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x0e) {
|
if (cmdId === 0x0e) {
|
||||||
const b = decodeBalanceCheck(inputs[i]);
|
const b = decodeBalanceCheck(inputs[i]);
|
||||||
if (b) setOutput(b.token, b.minBalance);
|
if (b) {
|
||||||
|
outputToken = b.token;
|
||||||
|
minOutput = b.minBalance;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x00) {
|
if (cmdId === 0x00) {
|
||||||
const s = decodeV3SwapExactIn(inputs[i]);
|
const s = decodeV3SwapExactIn(inputs[i]);
|
||||||
if (s) {
|
if (s) {
|
||||||
setInputOnce(s.tokenIn, s.amountIn);
|
if (!inputToken) inputToken = s.tokenIn;
|
||||||
|
if (!inputAmount) inputAmount = s.amountIn;
|
||||||
// Always update output: in multi-step swaps (V3 → V4),
|
// Always update output: in multi-step swaps (V3 → V4),
|
||||||
// the last swap step determines the final output token
|
// the last swap step determines the final output token
|
||||||
// and minimum received amount.
|
// and minimum received amount.
|
||||||
setOutput(s.tokenOut, s.amountOutMin);
|
outputToken = s.tokenOut;
|
||||||
|
minOutput = s.amountOutMin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x08) {
|
if (cmdId === 0x08) {
|
||||||
const s = decodeV2SwapExactIn(inputs[i]);
|
const s = decodeV2SwapExactIn(inputs[i]);
|
||||||
if (s) {
|
if (s) {
|
||||||
setInputOnce(s.tokenIn, s.amountIn);
|
if (!inputToken) inputToken = s.tokenIn;
|
||||||
setOutput(s.tokenOut, s.amountOutMin);
|
if (!inputAmount) inputAmount = s.amountIn;
|
||||||
|
outputToken = s.tokenOut;
|
||||||
|
minOutput = s.amountOutMin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x0b) {
|
if (cmdId === 0x0b) {
|
||||||
const w = decodeWrapEth(inputs[i]);
|
const w = decodeWrapEth(inputs[i]);
|
||||||
if (w) {
|
if (w && !inputToken) {
|
||||||
setInputOnce(
|
inputToken =
|
||||||
"0x0000000000000000000000000000000000000000",
|
"0x0000000000000000000000000000000000000000";
|
||||||
w.amount,
|
inputAmount = w.amount;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x10) {
|
if (cmdId === 0x10) {
|
||||||
const v4 = decodeV4Swap(inputs[i]);
|
const v4 = decodeV4Swap(inputs[i]);
|
||||||
if (v4) {
|
if (v4) {
|
||||||
setInputOnce(v4.tokenIn, v4.amountIn);
|
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
|
||||||
|
if (!inputAmount && v4.amountIn)
|
||||||
|
inputAmount = v4.amountIn;
|
||||||
// Always update output: last swap step wins. A step
|
// Always update output: last swap step wins. A step
|
||||||
// that carries the Min. received figure but decoded no
|
// that carries the Min. received figure but decoded no
|
||||||
// output currency makes the output *undetermined* — it
|
// output currency makes the output *undetermined* — it
|
||||||
// is neither ETH nor whatever an earlier step named,
|
// is neither ETH nor whatever an earlier step named,
|
||||||
// and that figure is no longer counted in that token.
|
// and that figure is no longer counted in that token.
|
||||||
// Equally, a step that names an output currency but no
|
if (v4.tokenOut) {
|
||||||
// minimum leaves Min. received unstated rather than
|
outputToken = v4.tokenOut;
|
||||||
// keeping an earlier step's figure beside the new
|
} else if (v4.amountOutMin) {
|
||||||
// token. setOutput() is both rules; a step that states
|
outputToken = null;
|
||||||
// neither half leaves the output alone.
|
}
|
||||||
setOutput(v4.tokenOut, v4.amountOutMin);
|
if (v4.amountOutMin) minOutput = v4.amountOutMin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,14 +451,26 @@ function decode(data, toAddress, sources) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve token info. A null token on either side means the calldata
|
// Resolve token info.
|
||||||
// named no currency for it; tokenInfo() refuses rather than calling it
|
//
|
||||||
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
|
// A null `outputToken` means undetermined, not native ETH, so it is
|
||||||
// decode, and it is answered here rather than left to that rule.
|
// not handed to tokenInfo() — which maps null to ETH at 18 decimals
|
||||||
|
// for the input side's benefit. Uniswap V4 spells native ETH as
|
||||||
|
// `Currency.wrap(address(0))` (v4-core `type Currency is address`),
|
||||||
|
// and a Currency is ABI-encoded as a plain address word, so every
|
||||||
|
// decode site here gets back the truthy string
|
||||||
|
// "0x0000000000000000000000000000000000000000" for it — never null.
|
||||||
|
// tokenInfo() already names that ETH, and an UNWRAP_WETH output is
|
||||||
|
// caught above, so nothing that genuinely outputs ETH arrives null.
|
||||||
|
// Only a step whose output currency did not decode does, and naming
|
||||||
|
// that ETH states the wrong asset and formats Min. received at the
|
||||||
|
// wrong scale.
|
||||||
const inInfo = tokenInfo(inputToken, sources);
|
const inInfo = tokenInfo(inputToken, sources);
|
||||||
const outInfo = hasUnwrapWeth
|
const outInfo = hasUnwrapWeth
|
||||||
? { symbol: "ETH", decimals: 18, address: null }
|
? { symbol: "ETH", decimals: 18, address: null }
|
||||||
: tokenInfo(outputToken, sources);
|
: outputToken
|
||||||
|
? tokenInfo(outputToken, sources)
|
||||||
|
: { symbol: null, decimals: null, address: null };
|
||||||
|
|
||||||
const inSymbol = inInfo.symbol;
|
const inSymbol = inInfo.symbol;
|
||||||
const outSymbol = outInfo.symbol;
|
const outSymbol = outInfo.symbol;
|
||||||
@@ -583,7 +488,7 @@ function decode(data, toAddress, sources) {
|
|||||||
address: toAddress,
|
address: toAddress,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (present(inputToken) && present(inInfo.address)) {
|
if (inputToken && inInfo.address) {
|
||||||
const label = inSymbol
|
const label = inSymbol
|
||||||
? inSymbol + " (" + inputToken + ")"
|
? inSymbol + " (" + inputToken + ")"
|
||||||
: inputToken;
|
: inputToken;
|
||||||
@@ -595,28 +500,18 @@ function decode(data, toAddress, sources) {
|
|||||||
});
|
});
|
||||||
} else if (inSymbol === "ETH") {
|
} else if (inSymbol === "ETH") {
|
||||||
details.push({ label: "Token In", value: "ETH (native)" });
|
details.push({ label: "Token In", value: "ETH (native)" });
|
||||||
} else {
|
|
||||||
// Nothing established the input token, so the line says that
|
|
||||||
// rather than going missing or naming a token by default. Same
|
|
||||||
// wording as the Token Out refusal below: the two sides of this
|
|
||||||
// screen must not describe the same condition in two ways.
|
|
||||||
details.push({ label: "Token In", value: UNNAMED_CURRENCY });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (present(inputAmount)) {
|
if (inputAmount !== null && inputAmount !== undefined) {
|
||||||
// Two amounts need no scale to describe and are named rather than
|
const maxUint160 = BigInt(
|
||||||
// formatted: V4's open delta, which is not a quantity at all (see
|
"0xffffffffffffffffffffffffffffffffffffffff",
|
||||||
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
|
);
|
||||||
// first — the sentinel is not a bigint and cannot be compared with
|
const isUnlimited = inputAmount >= maxUint160;
|
||||||
// one.
|
// An unbounded permit needs no scale to describe, so it is still
|
||||||
let amount;
|
// named rather than refused.
|
||||||
if (inputAmount === OPEN_DELTA) {
|
const amount = isUnlimited
|
||||||
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
? { raw: "Unlimited", display: "Unlimited" }
|
||||||
} else if (inputAmount >= MAX_UINT160) {
|
: amountText(inputAmount, inInfo);
|
||||||
amount = { raw: "Unlimited", display: "Unlimited" };
|
|
||||||
} else {
|
|
||||||
amount = amountText(inputAmount, inInfo);
|
|
||||||
}
|
|
||||||
details.push({
|
details.push({
|
||||||
label: "Amount",
|
label: "Amount",
|
||||||
value: amount.display,
|
value: amount.display,
|
||||||
@@ -629,7 +524,7 @@ function decode(data, toAddress, sources) {
|
|||||||
// entirely, leaving a Min. received figure with nothing saying what is
|
// entirely, leaving a Min. received figure with nothing saying what is
|
||||||
// being received. The Token In line above already falls back to the
|
// being received. The Token In line above already falls back to the
|
||||||
// address; this does the same.
|
// address; this does the same.
|
||||||
if (present(outInfo.address)) {
|
if (outInfo.address) {
|
||||||
const label = outSymbol
|
const label = outSymbol
|
||||||
? outSymbol + " (" + outInfo.address + ")"
|
? outSymbol + " (" + outInfo.address + ")"
|
||||||
: outInfo.address;
|
: outInfo.address;
|
||||||
@@ -643,25 +538,20 @@ function decode(data, toAddress, sources) {
|
|||||||
details.push({ label: "Token Out", value: outSymbol });
|
details.push({ label: "Token Out", value: outSymbol });
|
||||||
} else {
|
} else {
|
||||||
// Nothing established the output token, so the line says that
|
// Nothing established the output token, so the line says that
|
||||||
// rather than going missing or naming a token by default, and a
|
// rather than going missing or naming a token by default. It reads
|
||||||
// Min. received figure below it is never attached to a token the
|
// as a refusal, the same stance unknownDecimalsAmount() takes on a
|
||||||
// calldata did not state.
|
// scale, so a Min. received figure below it is never attached to a
|
||||||
details.push({ label: "Token Out", value: UNNAMED_CURRENCY });
|
// token the calldata did not state.
|
||||||
|
details.push({
|
||||||
|
label: "Token Out",
|
||||||
|
value: "Unknown (not named in the calldata)",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (present(minOutput)) {
|
if (minOutput !== null && minOutput !== undefined) {
|
||||||
// A zero floor is the one case the user most needs stated: the
|
|
||||||
// swap guarantees nothing back. It is said in words, in the same
|
|
||||||
// register as UNNAMED_CURRENCY, because "0.0000 WETH" reads as an
|
|
||||||
// artifact of the four-decimal rule rather than as "this may
|
|
||||||
// return nothing" — and because it is true at every scale, so it
|
|
||||||
// holds even when the output token's decimals are unknown.
|
|
||||||
details.push({
|
details.push({
|
||||||
label: "Min. received",
|
label: "Min. received",
|
||||||
value:
|
value: amountText(minOutput, outInfo).display,
|
||||||
minOutput === 0n
|
|
||||||
? NO_MINIMUM
|
|
||||||
: amountText(minOutput, outInfo).display,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
|||||||
|
|
||||||
test("Back onto the transaction detail renders it", () => {
|
test("Back onto the transaction detail renders it", () => {
|
||||||
reopenedOn("settings", ["main", "transaction"], {
|
reopenedOn("settings", ["main", "transaction"], {
|
||||||
viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } },
|
viewData: { tx: { hash: "0xdead" } },
|
||||||
});
|
});
|
||||||
goBack();
|
goBack();
|
||||||
expect(calls).toEqual(["transactionDetail"]);
|
expect(calls).toEqual(["transactionDetail"]);
|
||||||
@@ -162,14 +162,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
|||||||
|
|
||||||
test("Back onto the transaction confirmation restores it", () => {
|
test("Back onto the transaction confirmation restores it", () => {
|
||||||
reopenedOn("settings", ["main", "confirm-tx"], {
|
reopenedOn("settings", ["main", "confirm-tx"], {
|
||||||
viewData: {
|
viewData: { pendingTx: { to: ADDRESS, amount: "1" } },
|
||||||
pendingTx: {
|
|
||||||
token: "ETH",
|
|
||||||
from: ADDRESS,
|
|
||||||
to: ADDRESS,
|
|
||||||
amount: "1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
goBack();
|
goBack();
|
||||||
expect(calls).toEqual(["confirmTx"]);
|
expect(calls).toEqual(["confirmTx"]);
|
||||||
@@ -178,7 +171,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
|||||||
|
|
||||||
test("Back onto the success screen renders it", () => {
|
test("Back onto the success screen renders it", () => {
|
||||||
reopenedOn("settings", ["main", "success-tx"], {
|
reopenedOn("settings", ["main", "success-tx"], {
|
||||||
viewData: { hash: "0xdead", to: ADDRESS },
|
viewData: { hash: "0xdead" },
|
||||||
});
|
});
|
||||||
goBack();
|
goBack();
|
||||||
expect(calls).toEqual(["successTx"]);
|
expect(calls).toEqual(["successTx"]);
|
||||||
@@ -187,7 +180,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
|||||||
|
|
||||||
test("Back onto the failure screen renders it", () => {
|
test("Back onto the failure screen renders it", () => {
|
||||||
reopenedOn("settings", ["main", "error-tx"], {
|
reopenedOn("settings", ["main", "error-tx"], {
|
||||||
viewData: { message: "execution reverted", to: ADDRESS },
|
viewData: { message: "execution reverted" },
|
||||||
});
|
});
|
||||||
goBack();
|
goBack();
|
||||||
expect(calls).toEqual(["errorTx"]);
|
expect(calls).toEqual(["errorTx"]);
|
||||||
|
|||||||
@@ -93,12 +93,11 @@ describe("the floor under allowedSites and deniedSites", () => {
|
|||||||
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
|
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`a stored own "__proto__" key in ${field} is dropped`, () => {
|
test(`a stored own "__proto__" key in ${field} does not become a prototype`, () => {
|
||||||
// JSON can carry the key. It can never be a wallet address, so it
|
// JSON can carry the key, and plain assignment would hand it to
|
||||||
// grants and denies nothing and goes the way of every other key
|
// the prototype setter — recording no entry and, worse, moving the
|
||||||
// whose value is unusable; keeping it would only keep a value that
|
// map's prototype. Same reason networkEndpoints uses
|
||||||
// saveState()'s merge hands to the prototype setter on the next
|
// defineProperty.
|
||||||
// write.
|
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
|
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
|
||||||
);
|
);
|
||||||
@@ -106,38 +105,12 @@ describe("the floor under allowedSites and deniedSites", () => {
|
|||||||
const out = normalizePersisted(saved);
|
const out = normalizePersisted(saved);
|
||||||
|
|
||||||
expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype);
|
expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype);
|
||||||
expect(Object.keys(out[field])).toEqual([]);
|
expect(Object.keys(out[field])).toEqual(["__proto__"]);
|
||||||
|
expect({}.length).toBeUndefined();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('a stored own "__proto__" key surviving a save', () => {
|
|
||||||
// The floor writes map keys with defineProperty; saveState()'s merge is one
|
|
||||||
// layer downstream of it and used to write them with plain assignment,
|
|
||||||
// which hands "__proto__" to the prototype setter and records no entry.
|
|
||||||
// networkEndpoints is where a key that is not a known id is deliberately
|
|
||||||
// KEPT, so it is where that undoing shows.
|
|
||||||
test("does not move a prototype or vanish from networkEndpoints", async () => {
|
|
||||||
const profile = unversionedValidProfile();
|
|
||||||
profile.networkEndpoints = JSON.parse(
|
|
||||||
'{"__proto__":{"rpcUrl":"https://kept.invalid"}}',
|
|
||||||
);
|
|
||||||
const storage = makeStorageStub({ autistmask: profile });
|
|
||||||
const { state, loadState, saveState } = loadStateModule(storage);
|
|
||||||
|
|
||||||
await loadState();
|
|
||||||
state.theme = "dark";
|
|
||||||
await saveState();
|
|
||||||
|
|
||||||
// Not compared against Object.prototype by identity: the storage stub
|
|
||||||
// clones through structuredClone, which builds the result in the host
|
|
||||||
// realm, so the two Object.prototypes are different objects.
|
|
||||||
const stored = storage.read("autistmask").networkEndpoints;
|
|
||||||
expect(Object.getPrototypeOf(stored).rpcUrl).toBeUndefined();
|
|
||||||
expect(Object.keys(stored)).toContain("__proto__");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the floor under fraudContracts", () => {
|
describe("the floor under fraudContracts", () => {
|
||||||
test("fraudContracts that is not a list becomes an empty list", () => {
|
test("fraudContracts that is not a list becomes an empty list", () => {
|
||||||
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
||||||
|
|||||||
@@ -1,570 +0,0 @@
|
|||||||
// What the floor under each persisted field actually guarantees — as a table
|
|
||||||
// that RUNS, one row per field.
|
|
||||||
//
|
|
||||||
// This file replaces a hand-written per-field justification in the header of
|
|
||||||
// src/shared/stateSchema.js. That comment shipped a false claim in three
|
|
||||||
// consecutive changes: every author wrote plausible prose about thirty fields,
|
|
||||||
// every reviewer re-derived it by hand, and it kept being wrong in a different
|
|
||||||
// place each time. The artifact was the problem. A claim nobody can execute is
|
|
||||||
// worse than no claim, because it is believed.
|
|
||||||
//
|
|
||||||
// So the claim is a row here instead:
|
|
||||||
//
|
|
||||||
// KIND.REFUSED assertStateUsable() refuses the record outright. Proven by
|
|
||||||
// stateProblem() naming a problem for every hostile value.
|
|
||||||
// KIND.ENTRIES normalizePersisted() floors the container AND its entries.
|
|
||||||
// Proven by holds() over the normalized value.
|
|
||||||
// KIND.SCALAR normalizePersisted() floors it to one scalar type, or to a
|
|
||||||
// fixed fallback. Proven the same way.
|
|
||||||
// KIND.LOOSE `saved.x || default`, no type check at all. The claim is
|
|
||||||
// that no structural dereference of it is reachable from a
|
|
||||||
// stored record — which cannot be argued, only driven, so the
|
|
||||||
// proof is a boot of the REAL popup entry point over a stored
|
|
||||||
// record carrying the hostile value.
|
|
||||||
//
|
|
||||||
// Every row is driven through the boot regardless of kind, and a LOOSE row
|
|
||||||
// must additionally prove it is loose: if someone floors the field and leaves
|
|
||||||
// the row saying LOOSE, the "survives verbatim" assertion fails. A field added
|
|
||||||
// to PERSISTED_FIELDS with no row fails the first test in the file.
|
|
||||||
//
|
|
||||||
// The three claims this replaced, all false, all caught here by construction:
|
|
||||||
// rpcUrl reaching `new JsonRpcProvider()` (a synchronous throw, not a caught
|
|
||||||
// request); viewData's ENTRIES being dereferenced by four restore branches
|
|
||||||
// that gate on one truthy field each; and selectedWallet, where a stale
|
|
||||||
// integer index is the SAFE case and `wallets["map"]` is the throwing one.
|
|
||||||
|
|
||||||
const {
|
|
||||||
PERSISTED_FIELDS,
|
|
||||||
normalizePersisted,
|
|
||||||
} = require("../src/shared/persistedState");
|
|
||||||
const { stateProblem } = require("../src/shared/stateSchema");
|
|
||||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
|
||||||
const {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
unversionedValidProfile,
|
|
||||||
ADDRESS,
|
|
||||||
TOKEN_ADDRESS,
|
|
||||||
} = require("./support/popupBoot");
|
|
||||||
|
|
||||||
const KIND = {
|
|
||||||
REFUSED: "refused by the gate",
|
|
||||||
ENTRIES: "container and entries type-checked",
|
|
||||||
SCALAR: "scalar type-checked",
|
|
||||||
LOOSE: "loosely floored; safety proven by driving the popup",
|
|
||||||
};
|
|
||||||
|
|
||||||
const isText = (v) => typeof v === "string";
|
|
||||||
const isRecord = (v) =>
|
|
||||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
||||||
const isIndexOrNull = (v) => v === null || (Number.isInteger(v) && v >= 0);
|
|
||||||
const isTextOrNull = (v) => v === null || (isText(v) && v !== "");
|
|
||||||
const everyEntry = (v, fn) => Array.isArray(v) && v.every(fn);
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ the table
|
|
||||||
//
|
|
||||||
// `hostile` is values a stored record can carry that nothing in src/ ever
|
|
||||||
// writes. Each one is driven through the floor AND through a real popup boot,
|
|
||||||
// so keep the list short and pointed. `floorOnly` is extra values checked
|
|
||||||
// against the floor alone, which is pure and free.
|
|
||||||
|
|
||||||
const CONTRACT = [
|
|
||||||
{
|
|
||||||
field: "wallets",
|
|
||||||
kind: KIND.REFUSED,
|
|
||||||
hostile: [42, "notastructure", { a: 1 }, [null], [{ addresses: 1 }]],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "networkId",
|
|
||||||
kind: KIND.REFUSED,
|
|
||||||
hostile: [42, "notanetwork", { a: 1 }, "__proto__"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "trackedTokens",
|
|
||||||
kind: KIND.ENTRIES,
|
|
||||||
hostile: [42, "notalist", { a: 1 }],
|
|
||||||
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
|
|
||||||
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "allowedSites",
|
|
||||||
kind: KIND.ENTRIES,
|
|
||||||
hostile: [42, "notarecord", { [ADDRESS]: "notalist" }],
|
|
||||||
floorOnly: [
|
|
||||||
[ADDRESS],
|
|
||||||
{ [ADDRESS]: 42 },
|
|
||||||
{ [ADDRESS]: [42, null, {}] },
|
|
||||||
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
|
||||||
],
|
|
||||||
holds: siteMapHolds,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "deniedSites",
|
|
||||||
kind: KIND.ENTRIES,
|
|
||||||
hostile: [42, "notarecord", { [ADDRESS]: "notalist" }],
|
|
||||||
floorOnly: [
|
|
||||||
[ADDRESS],
|
|
||||||
{ [ADDRESS]: 42 },
|
|
||||||
{ [ADDRESS]: [42, null, {}] },
|
|
||||||
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
|
||||||
],
|
|
||||||
holds: siteMapHolds,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "fraudContracts",
|
|
||||||
kind: KIND.ENTRIES,
|
|
||||||
hostile: [42, "notalist", { a: 1 }],
|
|
||||||
floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]],
|
|
||||||
holds: (v) => everyEntry(v, isText),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "viewStack",
|
|
||||||
kind: KIND.ENTRIES,
|
|
||||||
hostile: [42, "notalist", ["main", "show-phrase", "settings"]],
|
|
||||||
floorOnly: [[1, 2], [null], [{}], ["export-privkey"]],
|
|
||||||
// Truncated at the first entry the popup will not reopen onto, rather
|
|
||||||
// than filtered: every surviving entry's Back target has to stay the
|
|
||||||
// one it had. restorableStack() may also substitute ["main"] under a
|
|
||||||
// view restored below the root, so this is the one ENTRIES field whose
|
|
||||||
// result is not always a subset of what was stored.
|
|
||||||
holds: (v) => everyEntry(v, (e) => RESTORABLE_VIEWS.has(e)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "networkEndpoints",
|
|
||||||
kind: KIND.ENTRIES,
|
|
||||||
hostile: [42, "notarecord", { mainnet: "notapair" }],
|
|
||||||
floorOnly: [
|
|
||||||
[1, 2],
|
|
||||||
{ mainnet: { rpcUrl: 42, blockscoutUrl: {} } },
|
|
||||||
{ mainnet: { rpcUrl: "", blockscoutUrl: [] } },
|
|
||||||
{ sepolia: 42 },
|
|
||||||
],
|
|
||||||
// Entries are coerced rather than dropped: an unknown network id is
|
|
||||||
// KEPT, so a profile that has been on a build with more networks does
|
|
||||||
// not lose their endpoints here. What is floored is the two URL fields
|
|
||||||
// inside the pair, which applyChainSwitchFields() assigns straight onto
|
|
||||||
// s.rpcUrl / s.blockscoutUrl on the next switch.
|
|
||||||
holds: (v) =>
|
|
||||||
isRecord(v) &&
|
|
||||||
Object.keys(v).every((id) => {
|
|
||||||
const pair = v[id];
|
|
||||||
return (
|
|
||||||
isRecord(pair) &&
|
|
||||||
(pair.rpcUrl === undefined ||
|
|
||||||
(isText(pair.rpcUrl) && pair.rpcUrl !== "")) &&
|
|
||||||
(pair.blockscoutUrl === undefined ||
|
|
||||||
(isText(pair.blockscoutUrl) &&
|
|
||||||
pair.blockscoutUrl !== ""))
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "rpcUrl",
|
|
||||||
kind: KIND.SCALAR,
|
|
||||||
hostile: [42, true, { a: 1 }],
|
|
||||||
floorOnly: [[], "", null],
|
|
||||||
holds: (v) => isText(v) && v !== "",
|
|
||||||
// The claim this row replaced said a bad value "fails the request on a
|
|
||||||
// path that already catches". It does not: getProvider() hands rpcUrl
|
|
||||||
// to `new JsonRpcProvider()`, which throws SYNCHRONOUSLY, from two call
|
|
||||||
// sites outside any try — and a stored `currentView: "wait-tx"` reaches
|
|
||||||
// one of them through restoreView(). So the row proves the claim
|
|
||||||
// against the real constructor rather than describing it.
|
|
||||||
alsoProven: (normalized, hostile) => {
|
|
||||||
// requireActual: bootPopup() mocks this module out for the boots
|
|
||||||
// above, and a mocked getProvider() would prove nothing at all
|
|
||||||
// about the constructor this row is a claim about.
|
|
||||||
const { getProvider } = jest.requireActual(
|
|
||||||
"../src/shared/balances",
|
|
||||||
);
|
|
||||||
expect(() => getProvider(hostile, "mainnet")).toThrow();
|
|
||||||
const provider = getProvider(normalized, "mainnet");
|
|
||||||
expect(provider).toBeTruthy();
|
|
||||||
provider.destroy();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "blockscoutUrl",
|
|
||||||
kind: KIND.SCALAR,
|
|
||||||
hostile: [42, true, { a: 1 }],
|
|
||||||
floorOnly: [[], "", null],
|
|
||||||
holds: (v) => isText(v) && v !== "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "activeAddress",
|
|
||||||
kind: KIND.SCALAR,
|
|
||||||
hostile: [42, true, { a: 1 }],
|
|
||||||
floorOnly: [[ADDRESS], ""],
|
|
||||||
holds: isTextOrNull,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "selectedToken",
|
|
||||||
kind: KIND.SCALAR,
|
|
||||||
hostile: [42, true, { a: 1 }],
|
|
||||||
floorOnly: [[TOKEN_ADDRESS], ""],
|
|
||||||
holds: isTextOrNull,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "selectedWallet",
|
|
||||||
kind: KIND.SCALAR,
|
|
||||||
// The prototype members are the whole point: `wallets["map"]` is
|
|
||||||
// TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and
|
|
||||||
// `.addresses[…]` throws. A stale INTEGER is the safe case.
|
|
||||||
hostile: ["map", "__proto__", { a: 1 }],
|
|
||||||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
|
||||||
holds: isIndexOrNull,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "selectedAddress",
|
|
||||||
kind: KIND.SCALAR,
|
|
||||||
hostile: ["map", "__proto__", { a: 1 }],
|
|
||||||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
|
||||||
holds: isIndexOrNull,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "currentView",
|
|
||||||
kind: KIND.LOOSE,
|
|
||||||
// Compared, and concatenated into the debug banner's textContent
|
|
||||||
// (src/popup/views/helpers.js) with no gate in front of it, which
|
|
||||||
// coerces. Nothing renders FROM it without RESTORABLE_VIEWS.has()
|
|
||||||
// first, and Set.has() answers false for any value.
|
|
||||||
hostile: [42, "no-such-view", { a: 1 }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "viewData",
|
|
||||||
kind: KIND.LOOSE,
|
|
||||||
// The container is taken verbatim; what makes its ENTRIES safe is the
|
|
||||||
// per-branch guard in src/popup/viewRouter.js. Driven over every
|
|
||||||
// restorable view in "a malformed viewData" below, which is the proof
|
|
||||||
// this row rests on.
|
|
||||||
hostile: [42, "notarecord", { a: 1 }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "lastBalanceRefresh",
|
|
||||||
kind: KIND.LOOSE,
|
|
||||||
// Arithmetic only: `now - (s.lastBalanceRefresh || 0)` compares false
|
|
||||||
// for a non-number and forces a refresh.
|
|
||||||
hostile: [true, "notatime", { a: 1 }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "tokenHolderCache",
|
|
||||||
kind: KIND.LOOSE,
|
|
||||||
// Nothing DEREFERENCES it structurally. It is read by the
|
|
||||||
// field-agnostic snapshotPersisted()/deepEqual() in
|
|
||||||
// src/shared/state.js, which are safe for any value, and otherwise
|
|
||||||
// only reset wholesale in src/shared/chainSwitchFields.js.
|
|
||||||
hostile: [42, "notarecord", [1, 2]],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "theme",
|
|
||||||
kind: KIND.LOOSE,
|
|
||||||
// Compared against "dark"/"light" in applyTheme() and otherwise falls
|
|
||||||
// to the system branch; assigned into an input .value, which coerces.
|
|
||||||
hostile: [42, "chartreuse", { a: 1 }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "dustThresholdGwei",
|
|
||||||
kind: KIND.LOOSE,
|
|
||||||
hostile: ["notanumber", true, { a: 1 }],
|
|
||||||
},
|
|
||||||
...[
|
|
||||||
"rememberSiteChoice",
|
|
||||||
"showZeroBalanceTokens",
|
|
||||||
"hideSpoofedSymbols",
|
|
||||||
"hideLowHolderTokens",
|
|
||||||
"hideFraudContracts",
|
|
||||||
"hideDustTransactions",
|
|
||||||
"utcTimestamps",
|
|
||||||
"debugMode",
|
|
||||||
].map((field) => ({
|
|
||||||
field,
|
|
||||||
kind: KIND.LOOSE,
|
|
||||||
// A flag: only ever tested for truthiness, and written back verbatim.
|
|
||||||
hostile: [42, "notabool", { a: 1 }],
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
function siteMapHolds(v) {
|
|
||||||
return (
|
|
||||||
isRecord(v) &&
|
|
||||||
Object.getPrototypeOf(v) === Object.prototype &&
|
|
||||||
!Object.prototype.hasOwnProperty.call(v, "__proto__") &&
|
|
||||||
Object.keys(v).every((key) => everyEntry(v[key], isText))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupPopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------- exhaustive
|
|
||||||
|
|
||||||
describe("the contract covers the record", () => {
|
|
||||||
test("every persisted field has exactly one row, and no row invents one", () => {
|
|
||||||
const rows = CONTRACT.map((row) => row.field);
|
|
||||||
|
|
||||||
expect([...rows].sort()).toEqual([...PERSISTED_FIELDS].sort());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("every row declares a kind this file knows how to prove", () => {
|
|
||||||
const kinds = Object.values(KIND);
|
|
||||||
for (const row of CONTRACT) {
|
|
||||||
expect(kinds).toContain(row.kind);
|
|
||||||
expect(row.hostile.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------------------------------------------------------- the floors
|
|
||||||
|
|
||||||
function profileWith(field, value) {
|
|
||||||
return unversionedValidProfile({ [field]: value });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("the floor each row claims", () => {
|
|
||||||
for (const row of CONTRACT) {
|
|
||||||
const values = [...row.hostile, ...(row.floorOnly || [])];
|
|
||||||
|
|
||||||
if (row.kind === KIND.REFUSED) {
|
|
||||||
test(`${row.field}: the gate refuses it`, () => {
|
|
||||||
for (const value of values) {
|
|
||||||
expect(
|
|
||||||
typeof stateProblem(profileWith(row.field, value)),
|
|
||||||
).toBe("string");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
test(`${row.field}: ${row.kind}`, () => {
|
|
||||||
for (const value of values) {
|
|
||||||
const out = normalizePersisted(profileWith(row.field, value));
|
|
||||||
if (row.kind === KIND.LOOSE) {
|
|
||||||
// The claim IS that there is no floor. A field that grows
|
|
||||||
// one has to move to another kind rather than keep a row
|
|
||||||
// saying its readers are what make it safe.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
expect({
|
|
||||||
value: value,
|
|
||||||
holds: row.holds(out[row.field]),
|
|
||||||
}).toEqual({ value: value, holds: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (row.kind === KIND.LOOSE) {
|
|
||||||
test(`${row.field}: is genuinely unfloored`, () => {
|
|
||||||
const survived = values.some((value) => {
|
|
||||||
const out = normalizePersisted(
|
|
||||||
profileWith(row.field, value),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
JSON.stringify(out[row.field]) === JSON.stringify(value)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(survived).toBe(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------------------------------------------- driving the real popup boot
|
|
||||||
|
|
||||||
// A booted popup is healthy when nothing threw out of init() and something is
|
|
||||||
// on screen. A throw out of restoreView() is neither: init() does not guard it,
|
|
||||||
// so the rest of popup init never runs and the user gets a popup with no view,
|
|
||||||
// no message and no control on it.
|
|
||||||
async function bootHealth(profile) {
|
|
||||||
const env = await bootPopup(profile);
|
|
||||||
return {
|
|
||||||
errors: env.pageErrors,
|
|
||||||
blank: env.visibleViews().length === 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const HEALTHY = { errors: [], blank: false };
|
|
||||||
|
|
||||||
describe("a hostile value for one field, through the real popup", () => {
|
|
||||||
for (const row of CONTRACT) {
|
|
||||||
for (const value of row.hostile) {
|
|
||||||
test(`${row.field} = ${JSON.stringify(value)}`, async () => {
|
|
||||||
await expect(
|
|
||||||
bootHealth(profileWith(row.field, value)),
|
|
||||||
).resolves.toEqual(HEALTHY);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a row's extra proof against the real reader", () => {
|
|
||||||
for (const row of CONTRACT) {
|
|
||||||
if (!row.alsoProven) continue;
|
|
||||||
test(row.field, () => {
|
|
||||||
for (const value of row.hostile) {
|
|
||||||
const out = normalizePersisted(profileWith(row.field, value));
|
|
||||||
row.alsoProven(out[row.field], value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------------------------------------------- viewData, entry by entry
|
|
||||||
|
|
||||||
// The views that read viewData.
|
|
||||||
const DATA_VIEWS = [
|
|
||||||
"confirm-tx",
|
|
||||||
"transaction",
|
|
||||||
"wait-tx",
|
|
||||||
"success-tx",
|
|
||||||
"error-tx",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Each record below PASSES the gate of the branch it names, and then carries a
|
|
||||||
// value that branch's renderer dereferences. `views` is where it is driven from
|
|
||||||
// — the whole set for a value that is not a record at all, and otherwise the
|
|
||||||
// branch it targets, since the cross-view case is covered by EVERY_GATE below.
|
|
||||||
const HOSTILE_VIEW_DATA = [
|
|
||||||
{ data: 42, views: DATA_VIEWS },
|
|
||||||
{ data: "notarecord", views: DATA_VIEWS },
|
|
||||||
{ data: [1, 2], views: DATA_VIEWS },
|
|
||||||
// success-tx passes on `data.hash`, and renderSuccess() then calls
|
|
||||||
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
|
|
||||||
{ data: { hash: "0x1" }, views: ["success-tx"] },
|
|
||||||
{ data: { hash: "0x1", to: 42 }, views: ["success-tx"] },
|
|
||||||
{
|
|
||||||
data: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
|
|
||||||
views: ["success-tx"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
hash: "0x1",
|
|
||||||
to: ADDRESS,
|
|
||||||
decoded: { details: [{ address: 42 }] },
|
|
||||||
},
|
|
||||||
views: ["success-tx"],
|
|
||||||
},
|
|
||||||
// error-tx passes on `data.message`, same dereference.
|
|
||||||
{ data: { message: "boom" }, views: ["error-tx"] },
|
|
||||||
{ data: { message: "boom", to: 42 }, views: ["error-tx"] },
|
|
||||||
// transaction passes on `data.tx`.
|
|
||||||
{ data: { tx: { hash: "0x1" } }, views: ["transaction"] },
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
tx: {
|
|
||||||
hash: "0x1",
|
|
||||||
from: ADDRESS,
|
|
||||||
to: ADDRESS,
|
|
||||||
contractAddress: 42,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
views: ["transaction"],
|
|
||||||
},
|
|
||||||
// confirm-tx passes on `data.pendingTx`.
|
|
||||||
{ data: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] },
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
pendingTx: { token: 42, from: ADDRESS, to: ADDRESS, amount: "1" },
|
|
||||||
},
|
|
||||||
views: ["confirm-tx"],
|
|
||||||
},
|
|
||||||
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked the
|
|
||||||
// fields below it since it was written, and this is the regression guard.
|
|
||||||
{
|
|
||||||
data: { pendingWait: { hash: "0x1", txInfo: { to: 42, amount: "1" } } },
|
|
||||||
views: ["wait-tx"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function restoringOnto(view, extra) {
|
|
||||||
return unversionedValidProfile({
|
|
||||||
currentView: view,
|
|
||||||
selectedWallet: 0,
|
|
||||||
selectedAddress: 0,
|
|
||||||
selectedToken: TOKEN_ADDRESS,
|
|
||||||
viewStack: ["main"],
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("a malformed viewData restoring onto", () => {
|
|
||||||
for (const { data, views } of HOSTILE_VIEW_DATA) {
|
|
||||||
for (const view of views) {
|
|
||||||
test(`${view}: ${JSON.stringify(data)}`, async () => {
|
|
||||||
await expect(
|
|
||||||
bootHealth(restoringOnto(view, { viewData: data })),
|
|
||||||
).resolves.toEqual(HEALTHY);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every restorable view, against one record that passes every branch's
|
|
||||||
// gate at once: a branch a view does not read must stay one it does not
|
|
||||||
// read, and each renderer must survive the fields another branch left.
|
|
||||||
const EVERY_GATE = {
|
|
||||||
hash: "0x1",
|
|
||||||
message: "boom",
|
|
||||||
tx: { hash: "0x1" },
|
|
||||||
pendingTx: { amount: "1" },
|
|
||||||
pendingWait: { hash: "0x1" },
|
|
||||||
};
|
|
||||||
for (const view of RESTORABLE_VIEWS) {
|
|
||||||
test(`${view}: a record passing every branch's gate at once`, async () => {
|
|
||||||
await expect(
|
|
||||||
bootHealth(restoringOnto(view, { viewData: EVERY_GATE })),
|
|
||||||
).resolves.toEqual(HEALTHY);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------------------------------------- selectedWallet / selectedAddress
|
|
||||||
|
|
||||||
// `wallets` is a real Array, so a selectedWallet naming an Array.prototype or
|
|
||||||
// Object.prototype member is TRUTHY: hasValidAddress()'s `&&` does not
|
|
||||||
// short-circuit, `.addresses` is undefined, and the index access throws out of
|
|
||||||
// restoreView(). A stale INTEGER is falsy-or-in-range and safe — the opposite
|
|
||||||
// way round from how this pair was described.
|
|
||||||
const HOSTILE_INDEX = [
|
|
||||||
{ selectedWallet: "map", selectedAddress: 0 },
|
|
||||||
{ selectedWallet: "length", selectedAddress: 0 },
|
|
||||||
{ selectedWallet: "__proto__", selectedAddress: 0 },
|
|
||||||
{ selectedWallet: "constructor", selectedAddress: 0 },
|
|
||||||
{ selectedWallet: 0, selectedAddress: "map" },
|
|
||||||
{ selectedWallet: 5, selectedAddress: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const INDEX_VIEWS = [
|
|
||||||
"address",
|
|
||||||
"address-token",
|
|
||||||
"receive",
|
|
||||||
"transaction",
|
|
||||||
"confirm-tx",
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("a malformed wallet or address index restoring onto", () => {
|
|
||||||
const WELL_FORMED_DATA = {
|
|
||||||
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS },
|
|
||||||
pendingTx: {
|
|
||||||
token: "ETH",
|
|
||||||
from: ADDRESS,
|
|
||||||
to: ADDRESS,
|
|
||||||
amount: "1",
|
|
||||||
balance: "2",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const view of INDEX_VIEWS) {
|
|
||||||
for (const indices of HOSTILE_INDEX) {
|
|
||||||
test(`${view}: ${JSON.stringify(indices)}`, async () => {
|
|
||||||
await expect(
|
|
||||||
bootHealth(
|
|
||||||
restoringOnto(view, {
|
|
||||||
...indices,
|
|
||||||
viewData: WELL_FORMED_DATA,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toEqual(HEALTHY);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -101,21 +101,6 @@ function makeElement(id, className) {
|
|||||||
},
|
},
|
||||||
querySelector: () => null,
|
querySelector: () => null,
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
// The Receive view draws its QR onto #receive-qr through the qrcode
|
|
||||||
// package, which calls getContext("2d") and then createImageData/
|
|
||||||
// putImageData on the result. Without this the render throws from
|
|
||||||
// inside a promise the view does not await, which takes the whole node
|
|
||||||
// process down rather than failing a test — so a suite that boots onto
|
|
||||||
// Receive could not report anything.
|
|
||||||
getContext: () => ({
|
|
||||||
createImageData: (w, h) => ({
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
data: new Uint8ClampedArray(w * h * 4),
|
|
||||||
}),
|
|
||||||
putImageData: () => {},
|
|
||||||
clearRect: () => {},
|
|
||||||
}),
|
|
||||||
click: () => {
|
click: () => {
|
||||||
el.clicked += 1;
|
el.clicked += 1;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -94,69 +94,6 @@ function encodeV4Swap(actions, params) {
|
|||||||
return coder.encode(["bytes", "bytes[]"], [actions, params]);
|
return coder.encode(["bytes", "bytes[]"], [actions, params]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// V4 inner action IDs, as src/shared/uniswap.js names them.
|
|
||||||
const V4_SWAP_EXACT_IN = 0x07;
|
|
||||||
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
|
|
||||||
const V4_SETTLE_ID = 0x0b;
|
|
||||||
const V4_TAKE_ID = 0x0e;
|
|
||||||
const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
|
|
||||||
|
|
||||||
// Helper: V4 SETTLE params — (address currency, uint256 maxAmount, bool payerIsUser)
|
|
||||||
function encodeV4Settle(currency) {
|
|
||||||
return coder.encode(["address", "uint256", "bool"], [currency, 0n, true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: V4 TAKE params — (address currency, address recipient, uint256 amount)
|
|
||||||
function encodeV4Take(currency) {
|
|
||||||
return coder.encode(
|
|
||||||
["address", "address", "uint256"],
|
|
||||||
[currency, USER_ADDR, 0n],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: V4 ExactInputParams — (address currencyIn,
|
|
||||||
// tuple(address,uint24,int24,address,bytes)[] path,
|
|
||||||
// uint128 amountIn, uint128 amountOutMin)
|
|
||||||
function encodeV4ExactIn(currencyIn, pathTokens, amountIn, amountOutMin) {
|
|
||||||
return coder.encode(
|
|
||||||
[
|
|
||||||
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
currencyIn,
|
|
||||||
pathTokens.map((t) => [t, 3000, 60, ZERO_ADDR, "0x"]),
|
|
||||||
amountIn,
|
|
||||||
amountOutMin,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: V4 ExactInputSingleParams —
|
|
||||||
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
|
|
||||||
// uint128 amountIn, uint128 amountOutMin, bytes hookData)
|
|
||||||
function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
|
|
||||||
return coder.encode(
|
|
||||||
[
|
|
||||||
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
[currency0, currency1, 100, 1, ZERO_ADDR],
|
|
||||||
true, // zeroForOne: in = currency0, out = currency1
|
|
||||||
amountIn,
|
|
||||||
amountOutMin,
|
|
||||||
"0x",
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function detail(result, label) {
|
|
||||||
return result.details.find((d) => d.label === label);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("uniswap decoder", () => {
|
describe("uniswap decoder", () => {
|
||||||
test("returns null for non-execute calldata", () => {
|
test("returns null for non-execute calldata", () => {
|
||||||
expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull();
|
expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull();
|
||||||
@@ -181,18 +118,6 @@ describe("uniswap decoder", () => {
|
|||||||
expect(tokenIn.value).toContain("USDT");
|
expect(tokenIn.value).toContain("USDT");
|
||||||
expect(tokenIn.address.toLowerCase()).toBe(USDT_ADDR.toLowerCase());
|
expect(tokenIn.address.toLowerCase()).toBe(USDT_ADDR.toLowerCase());
|
||||||
|
|
||||||
// Genuine native ETH on the output side, on a real mainnet fixture:
|
|
||||||
// V4's TAKE names it as Currency.wrap(address(0)), which reaches the
|
|
||||||
// decoder as the explicit zero address and must still read as ETH.
|
|
||||||
const tokenOut = result.details.find((d) => d.label === "Token Out");
|
|
||||||
expect(tokenOut.value).toBe("ETH");
|
|
||||||
expect(result.details.find((d) => d.label === "Amount").value).toBe(
|
|
||||||
"Unlimited",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
result.details.find((d) => d.label === "Min. received").value,
|
|
||||||
).toBe("0.0002 ETH");
|
|
||||||
|
|
||||||
const steps = result.details.find((d) => d.label === "Steps");
|
const steps = result.details.find((d) => d.label === "Steps");
|
||||||
expect(steps.value).toContain("Permit2 Permit");
|
expect(steps.value).toContain("Permit2 Permit");
|
||||||
expect(steps.value).toContain("V4 Swap");
|
expect(steps.value).toContain("V4 Swap");
|
||||||
@@ -256,7 +181,8 @@ describe("uniswap decoder", () => {
|
|||||||
expect(tokenIn.value).toBe("ETH (native)");
|
expect(tokenIn.value).toBe("ETH (native)");
|
||||||
|
|
||||||
const amount = result.details.find((d) => d.label === "Amount");
|
const amount = result.details.find((d) => d.label === "Amount");
|
||||||
expect(amount.value).toBe("1.0000 ETH");
|
expect(amount.value).toContain("1.0000");
|
||||||
|
expect(amount.value).toContain("ETH");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("decodes UNWRAP_WETH as ETH output", () => {
|
test("decodes UNWRAP_WETH as ETH output", () => {
|
||||||
@@ -412,211 +338,6 @@ describe("uniswap decoder", () => {
|
|||||||
expect(steps.value).toContain("V4 Swap");
|
expect(steps.value).toContain("V4 Swap");
|
||||||
});
|
});
|
||||||
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/364 — the input half.
|
|
||||||
//
|
|
||||||
// Fails against c9ebac8: `if (!inputAmount) inputAmount = s.amountIn`
|
|
||||||
// cannot tell the V3 hop's genuine 0n from "not yet set", so the V2 hop's
|
|
||||||
// 0.5 WETH overwrote it while Token In stayed pinned to the V3 hop's USDT.
|
|
||||||
// Observed there: Amount = "500000000000.0000 USDT".
|
|
||||||
test("a hop's zero amountIn is a real amount, not an opening for the next hop's figure", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
|
|
||||||
[
|
|
||||||
encodeV3SwapExactIn(USER_ADDR, 0n, 0n, [USDT_ADDR, WETH_ADDR]),
|
|
||||||
encodeV2SwapExactIn(
|
|
||||||
USER_ADDR,
|
|
||||||
500000000000000000n, // 0.5 WETH
|
|
||||||
1000000n,
|
|
||||||
[WETH_ADDR, USDC_ADDR],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
|
|
||||||
// The amount and the token it is counted in come from the same hop.
|
|
||||||
expect(detail(result, "Token In").address.toLowerCase()).toBe(
|
|
||||||
USDT_ADDR.toLowerCase(),
|
|
||||||
);
|
|
||||||
expect(detail(result, "Amount").value).toBe("0.0000 USDT");
|
|
||||||
expect(detail(result, "Amount").value).not.toContain("500000000000");
|
|
||||||
});
|
|
||||||
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
|
|
||||||
// the shape the issue measured: a V4 step that states a minimum of zero
|
|
||||||
// and names no output currency.
|
|
||||||
//
|
|
||||||
// Fails against c9ebac8: `if (v4.amountOutMin) minOutput = ...` and the
|
|
||||||
// `else if` beside it both read 0n as absent, so neither the figure nor
|
|
||||||
// the token moved. Observed there: Token Out = "WETH (0xC02aaA39...)" and
|
|
||||||
// Min. received = "0.5000 WETH" — the V3 hop's guarantee shown for a
|
|
||||||
// transaction whose final leg guarantees nothing.
|
|
||||||
test("a V4 step with a zero amountOutMin states no minimum instead of keeping an earlier hop's", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
|
|
||||||
[
|
|
||||||
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
|
|
||||||
USDT_ADDR,
|
|
||||||
WETH_ADDR,
|
|
||||||
]),
|
|
||||||
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
|
|
||||||
encodeV4ExactIn(
|
|
||||||
WETH_ADDR,
|
|
||||||
[], // no path: this step names no output currency
|
|
||||||
1000000000000000000n,
|
|
||||||
0n, // no slippage floor at all
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toBe(
|
|
||||||
"Unknown (not named in the calldata)",
|
|
||||||
);
|
|
||||||
expect(detail(result, "Min. received").value).toBe(
|
|
||||||
"None (no minimum guaranteed)",
|
|
||||||
);
|
|
||||||
expect(detail(result, "Min. received").value).not.toContain("0.5000");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The same zero floor, but with the final leg's output currency named:
|
|
||||||
// the figure must belong to the token beside it. Against c9ebac8 this
|
|
||||||
// rendered Token Out = USDC with Min. received = "500000000000.0000 USDC",
|
|
||||||
// the V3 hop's 0.5e18 WETH figure re-scaled to USDC's six decimals.
|
|
||||||
test("a zero minimum is stated against the token that supplied it", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
|
|
||||||
[
|
|
||||||
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
|
|
||||||
USDT_ADDR,
|
|
||||||
WETH_ADDR,
|
|
||||||
]),
|
|
||||||
encodeV4Swap(
|
|
||||||
new Uint8Array([
|
|
||||||
V4_SETTLE_ID,
|
|
||||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
||||||
V4_TAKE_ID,
|
|
||||||
]),
|
|
||||||
[
|
|
||||||
encodeV4Settle(WETH_ADDR),
|
|
||||||
encodeV4ExactInSingle(
|
|
||||||
WETH_ADDR,
|
|
||||||
USDC_ADDR,
|
|
||||||
1000000000000000000n,
|
|
||||||
0n,
|
|
||||||
),
|
|
||||||
encodeV4Take(USDC_ADDR),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toContain("USDC");
|
|
||||||
expect(detail(result, "Min. received").value).toBe(
|
|
||||||
"None (no minimum guaranteed)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The other half of the same invariant: a final leg that names an output
|
|
||||||
// currency but no minimum leaves Min. received unstated. Against c9ebac8
|
|
||||||
// the V3 hop's figure stayed on screen beside the new token, rendering
|
|
||||||
// "500000000000.0000 USDC".
|
|
||||||
test("a final leg with no minimum drops the line rather than keeping an earlier hop's figure", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
|
|
||||||
[
|
|
||||||
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
|
|
||||||
USDT_ADDR,
|
|
||||||
WETH_ADDR,
|
|
||||||
]),
|
|
||||||
encodeV4Swap(new Uint8Array([V4_SETTLE_ID, V4_TAKE_ID]), [
|
|
||||||
encodeV4Settle(WETH_ADDR),
|
|
||||||
encodeV4Take(USDC_ADDR),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toContain("USDC");
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// V4 spells "swap the whole open delta" as an amountIn of zero
|
|
||||||
// (v4-periphery ActionConstants.OPEN_DELTA = 0, applied by V4Router's
|
|
||||||
// _swapExactInputSingle / _swapExactInput). It is not a quantity, and
|
|
||||||
// printing "0.0000 WETH" for it would state the exact inverse of what the
|
|
||||||
// step does. Against c9ebac8 the Amount line was omitted entirely.
|
|
||||||
test("a V4 open-delta amountIn is named, not printed as zero", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
"0x10",
|
|
||||||
[
|
|
||||||
encodeV4Swap(
|
|
||||||
new Uint8Array([
|
|
||||||
V4_SETTLE_ID,
|
|
||||||
V4_SWAP_EXACT_IN_SINGLE_ID,
|
|
||||||
V4_TAKE_ID,
|
|
||||||
]),
|
|
||||||
[
|
|
||||||
encodeV4Settle(WETH_ADDR),
|
|
||||||
encodeV4ExactInSingle(
|
|
||||||
WETH_ADDR,
|
|
||||||
USDC_ADDR,
|
|
||||||
0n, // ActionConstants.OPEN_DELTA
|
|
||||||
990000n,
|
|
||||||
),
|
|
||||||
encodeV4Take(USDC_ADDR),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
|
|
||||||
expect(detail(result, "Token In").value).toContain("WETH");
|
|
||||||
expect(detail(result, "Amount").value).toBe(
|
|
||||||
"All available (V4 open delta)",
|
|
||||||
);
|
|
||||||
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
|
|
||||||
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
|
|
||||||
// output currency, so it says so and titles itself "Uniswap Swap" rather
|
|
||||||
// than inventing "Token Out: ETH".
|
|
||||||
test("a PERMIT2_PERMIT-only execute() invents no output token", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
"0x0a",
|
|
||||||
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR)],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result.name).toBe("Uniswap Swap");
|
|
||||||
|
|
||||||
expect(detail(result, "Token In").value).toContain("USDT");
|
|
||||||
expect(detail(result, "Token Out").value).toBe(
|
|
||||||
"Unknown (not named in the calldata)",
|
|
||||||
);
|
|
||||||
expect(detail(result, "Token Out").address).toBeUndefined();
|
|
||||||
expect(detail(result, "Min. received")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles unknown tokens gracefully", () => {
|
test("handles unknown tokens gracefully", () => {
|
||||||
const fakeToken = "0x1111111111111111111111111111111111111111";
|
const fakeToken = "0x1111111111111111111111111111111111111111";
|
||||||
const data = buildExecute(
|
const data = buildExecute(
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
// What the dApp approval screen says the input token of a swap is when the
|
|
||||||
// calldata did not name one.
|
|
||||||
//
|
|
||||||
// Issue #357, the twin on the input side of
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/353: `tokenInfo(null)` answered
|
|
||||||
// `{symbol: "ETH", decimals: 18}`, so a null `inputToken` rendered as
|
|
||||||
// `Token In: ETH (native)` and titled the swap `Swap ETH -> X`. An
|
|
||||||
// undetermined input was therefore asserted to the user as native ETH — the
|
|
||||||
// same class as https://git.eeqj.de/sneak/AutistMask/issues/340 and
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/306, naming the wrong asset
|
|
||||||
// rather than merely mis-scaling it.
|
|
||||||
//
|
|
||||||
// The determination this file pins is the one #353 established, checked here
|
|
||||||
// on the input side: null is NOT how native ETH arrives. v4-core declares
|
|
||||||
// `type Currency is address` and wraps `address(0)` for native ETH, and a
|
|
||||||
// user-defined value type over `address` carries the plain `address` ABI
|
|
||||||
// encoding, so a native-ETH currency reaches the decoder as the truthy string
|
|
||||||
// "0x0000000000000000000000000000000000000000". WRAP_ETH sets that same
|
|
||||||
// explicit zero address. Both halves are asserted below: the zero address
|
|
||||||
// stays ETH, and null refuses.
|
|
||||||
|
|
||||||
const { AbiCoder, Interface, solidityPacked } = require("ethers");
|
|
||||||
const uniswap = require("../src/shared/uniswap");
|
|
||||||
|
|
||||||
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
|
||||||
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
const ZERO = "0x0000000000000000000000000000000000000000";
|
|
||||||
// Both in the bundled list: USDT at 6 decimals, USDC at 6.
|
|
||||||
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
|
||||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
||||||
|
|
||||||
// The same wording the output side refuses with — one screen, one vocabulary.
|
|
||||||
const REFUSAL = "Unknown (not named in the calldata)";
|
|
||||||
|
|
||||||
const ONE_ETH = 1000000000000000000n;
|
|
||||||
|
|
||||||
const V4_SWAP_EXACT_IN = 0x07;
|
|
||||||
const V4_SETTLE = 0x0b;
|
|
||||||
const V4_TAKE = 0x0e;
|
|
||||||
|
|
||||||
const coder = AbiCoder.defaultAbiCoder();
|
|
||||||
const routerIface = new Interface([
|
|
||||||
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function execute(commands, inputs) {
|
|
||||||
return routerIface.encodeFunctionData("execute", [
|
|
||||||
commands,
|
|
||||||
inputs,
|
|
||||||
9999999999n,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function v4Input(actions, params) {
|
|
||||||
return coder.encode(
|
|
||||||
["bytes", "bytes[]"],
|
|
||||||
[new Uint8Array(actions), params],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// IV4Router.ExactInputParams as the decoder reads it:
|
|
||||||
// (Currency currencyIn, PathKey[] path, uint128 amountIn, uint128 minOut).
|
|
||||||
function exactInParams(currencyIn, path, amountIn, amountOutMin) {
|
|
||||||
return coder.encode(
|
|
||||||
[
|
|
||||||
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
|
||||||
],
|
|
||||||
[[currencyIn, path, amountIn, amountOutMin]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
|
|
||||||
// uint256 minBalance). It names the output token and nothing about the input.
|
|
||||||
function balanceCheck(token, minBalance) {
|
|
||||||
return coder.encode(
|
|
||||||
["address", "address", "uint256"],
|
|
||||||
[USER, token, minBalance],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function detail(data, label) {
|
|
||||||
const decoded = uniswap.decode(data, ROUTER, {});
|
|
||||||
expect(decoded).not.toBeNull();
|
|
||||||
return decoded.details.find((d) => d.label === label);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("an execute() whose input currency never decoded", () => {
|
|
||||||
// A V4_SWAP whose sub-action params do not decode — an action encoding
|
|
||||||
// this decoder does not know — leaves every V4 token null. The
|
|
||||||
// BALANCE_CHECK still names the output, so the screen has a Min. received
|
|
||||||
// figure and a Token Out, and previously claimed the user was paying ETH
|
|
||||||
// for them.
|
|
||||||
const data = () =>
|
|
||||||
execute(solidityPacked(["uint8", "uint8"], [0x10, 0x0e]), [
|
|
||||||
coder.encode(["bytes", "bytes[]"], ["0x07", ["0x"]]),
|
|
||||||
balanceCheck(USDC, 2000000n),
|
|
||||||
]);
|
|
||||||
|
|
||||||
test("says the input token is unknown instead of naming ETH", () => {
|
|
||||||
expect(detail(data(), "Token In").value).toBe(REFUSAL);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps a Token In line, so the screen never omits what is paid", () => {
|
|
||||||
const tokenIn = detail(data(), "Token In");
|
|
||||||
expect(tokenIn).toBeDefined();
|
|
||||||
// A refusal is not a token: nothing to link to an explorer.
|
|
||||||
expect(tokenIn.address).toBeUndefined();
|
|
||||||
expect(tokenIn.isToken).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not name ETH in the swap title either", () => {
|
|
||||||
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("an execute() that names only an output token", () => {
|
|
||||||
// A lone BALANCE_CHECK_ERC20: nothing in it says what is being paid.
|
|
||||||
const data = () => execute("0x0e", [balanceCheck(USDT, 2000000n)]);
|
|
||||||
|
|
||||||
test("refuses the input rather than defaulting it to ETH", () => {
|
|
||||||
expect(detail(data(), "Token In").value).toBe(REFUSAL);
|
|
||||||
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("still states the output it did establish", () => {
|
|
||||||
expect(detail(data(), "Token Out").value).toContain("USDT");
|
|
||||||
expect(detail(data(), "Min. received").value).toBe("2.0000 USDT");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("native ETH in, which V4 spells as the zero address", () => {
|
|
||||||
test("a Currency of address(0) decodes to a truthy address string", () => {
|
|
||||||
// The fact the whole determination rests on: an absent input currency
|
|
||||||
// and a native-ETH one are distinguishable here, because the ABI
|
|
||||||
// decoder never yields null for an address word.
|
|
||||||
const [currency] = coder.decode(
|
|
||||||
["address", "uint256", "bool"],
|
|
||||||
coder.encode(["address", "uint256", "bool"], [ZERO, ONE_ETH, true]),
|
|
||||||
);
|
|
||||||
expect(currency).toBe(ZERO);
|
|
||||||
expect(Boolean(currency)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a V4 SETTLE of the zero address is still ETH at 18 decimals", () => {
|
|
||||||
const data = execute("0x10", [
|
|
||||||
v4Input(
|
|
||||||
[V4_SETTLE, V4_SWAP_EXACT_IN, V4_TAKE],
|
|
||||||
[
|
|
||||||
coder.encode(
|
|
||||||
["address", "uint256", "bool"],
|
|
||||||
[ZERO, ONE_ETH, true],
|
|
||||||
),
|
|
||||||
exactInParams(
|
|
||||||
ZERO,
|
|
||||||
[[USDT, 500, 10, ZERO, "0x"]],
|
|
||||||
ONE_ETH,
|
|
||||||
2000000n,
|
|
||||||
),
|
|
||||||
coder.encode(
|
|
||||||
["address", "address", "uint256"],
|
|
||||||
[USDT, USER, 0n],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(detail(data, "Token In").value).toBe("ETH (native)");
|
|
||||||
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
|
|
||||||
expect(uniswap.decode(data, ROUTER, {}).name).toBe("Swap ETH → USDT");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("WRAP_ETH is still ETH at 18 decimals", () => {
|
|
||||||
// WRAP_ETH names no currency of its own; the decoder supplies the
|
|
||||||
// explicit zero address for it, which is why it survives this change.
|
|
||||||
const data = execute("0x0b", [
|
|
||||||
coder.encode(["address", "uint256"], [ROUTER, ONE_ETH]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(detail(data, "Token In").value).toBe("ETH (native)");
|
|
||||||
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user