Compare commits
1 Commits
next
...
041f5dc39f
| Author | SHA1 | Date | |
|---|---|---|---|
| 041f5dc39f |
91
README.md
91
README.md
@@ -800,12 +800,7 @@ discoverable.
|
|||||||
addresses visually, as a security feature.
|
addresses visually, as a security feature.
|
||||||
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
||||||
styling. Tailwind is configured with a minimal monochrome palette. This keeps
|
styling. Tailwind is configured with a minimal monochrome palette. This keeps
|
||||||
the styling co-located with the markup and eliminates CSS file management. The
|
the styling co-located with the markup and eliminates CSS file management.
|
||||||
handful of classes in `styles/main.css` are not styling: `.copy-flash-*`
|
|
||||||
carries the copy feedback animation, and `.am-address` carries the rule that
|
|
||||||
an address never wraps. Both are invariants that hold in every place they
|
|
||||||
appear, and spelling either out as repeated utilities is how one of those
|
|
||||||
places drifts away from the rest.
|
|
||||||
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
|
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
|
||||||
enough that vanilla JS with simple view switching is sufficient. A framework
|
enough that vanilla JS with simple view switching is sufficient. A framework
|
||||||
would add bundle size, build complexity, and attack surface for no benefit at
|
would add bundle size, build complexity, and attack surface for no benefit at
|
||||||
@@ -854,12 +849,6 @@ that the portions still displayed will be more than adequate for the user to
|
|||||||
verify addresses even in the case of address spoofing attacks. Clicking an
|
verify addresses even in the case of address spoofing attacks. Clicking an
|
||||||
address will always copy the full, untruncated value.
|
address will always copy the full, untruncated value.
|
||||||
|
|
||||||
As of the address-row layout change, no view invokes that exception: every
|
|
||||||
address in the popup is rendered on a row of its own, wide enough for all 42
|
|
||||||
characters, and no screen truncates one to fit. The cap is still enforced in
|
|
||||||
`truncateMiddle()` and the 32-character floor in `renderAddressHtml()`, so the
|
|
||||||
guarantee holds for any future caller; there simply are none today.
|
|
||||||
|
|
||||||
**Specific Exception — Transaction Detail view:** The transaction detail screen
|
**Specific Exception — Transaction Detail view:** The transaction detail screen
|
||||||
is the authoritative record of a specific transaction and shows the exact,
|
is the authoritative record of a specific transaction and shows the exact,
|
||||||
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
|
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
|
||||||
@@ -1047,49 +1036,17 @@ 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`, `networkId`,
|
||||||
is an object, and the dereference is one level below the container check; a
|
`networkEndpoints`, `activeAddress`, `viewStack` — and it checks the ENTRIES as
|
||||||
malformed entry is dropped, except in `networkEndpoints`, where the entry is
|
well as the container, because `[1, 2]` is a list and `t.address` is one level
|
||||||
coerced so an unknown network's endpoints are not lost, and in `viewStack`,
|
below an `Array.isArray()`. The remaining fields get a `saved.x || default` or a
|
||||||
where the stack is truncated at the first entry the popup will not reopen onto.
|
present-or-default passthrough that takes the stored value verbatim, with no
|
||||||
Some are type-checked as a scalar. The rest take the stored value verbatim,
|
type check at all; which field is in which category is listed in the header of
|
||||||
because nothing dereferences them structurally.
|
`src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS
|
||||||
|
dereferenced walks through truthiness and throws on the first read, which is the
|
||||||
Which field is which is not written in prose anywhere, deliberately.
|
blank popup again by a longer route — so adding a field means choosing between
|
||||||
`tests/persistedFieldContract.test.js` is the list: one row per persisted field,
|
the two by what reads it.
|
||||||
naming the property that field's floor is claimed to have and proving it by
|
|
||||||
driving the real code with hostile values — and, for every field whose only
|
|
||||||
defence is that nothing dereferences it, by booting the real popup entry point
|
|
||||||
over that value onto every view the popup can reopen onto. That last part is
|
|
||||||
what makes the claim falsifiable, because this defect class lives on the restore
|
|
||||||
path rather than on the home screen. Read the claim narrowly, as that file
|
|
||||||
states it: what those boots prove is no structural dereference on the code paths
|
|
||||||
a WHOLLY-CORRUPTED PROFILE takes, which is not every path a stored record takes.
|
|
||||||
Not driven: any pairing of values the four slots do not produce, a view only
|
|
||||||
forward navigation opens, anything behind a click, and everything a healthy
|
|
||||||
profile reaches. Within that boundary the verdict is unconditional — if one of
|
|
||||||
those boots leaves the popup unhealthy or off the view it stored, `make check`
|
|
||||||
fails, including when it takes two corrupted fields at once, because the verdict
|
|
||||||
is the combined boot and the per-field re-boot that names a culprit can only
|
|
||||||
decorate the message. So does a field that gains a floor while its row still
|
|
||||||
claims it has none, and so does a field added to `PERSISTED_FIELDS` with no row
|
|
||||||
at all. The per-field justification that used to live in the header of
|
|
||||||
`src/shared/stateSchema.js` shipped a false claim in three consecutive changes,
|
|
||||||
each caught only by a reviewer re-deriving thirty fields by hand.
|
|
||||||
|
|
||||||
The `allowedSites` case is why the entry check is not optional. A stored
|
|
||||||
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
|
||||||
passed the gate, rendered a completely healthy popup, and then threw inside
|
|
||||||
`saveState()`'s per-hostname merge, so every save from that moment on failed and
|
|
||||||
the user went on operating a wallet that was persisting nothing
|
|
||||||
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
|
|
||||||
now also reported rather than swallowed: `onSaveFailure()` in
|
|
||||||
`src/shared/state.js` is called for every failed save, awaited or not, and the
|
|
||||||
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
|
|
||||||
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
|
|
||||||
covers — a quota, a revoked permission, a record a newer build wrote — and the
|
|
||||||
wallet must never look healthy while that is true.
|
|
||||||
|
|
||||||
The `networkId` check is not cosmetic: that value is an object KEY into
|
The `networkId` check is not cosmetic: that value is an object KEY into
|
||||||
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
||||||
@@ -1144,14 +1101,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
|
||||||
@@ -1193,17 +1142,13 @@ view would leave a wallet one click from deletion.
|
|||||||
- Send / Receive quick-action buttons, both acting on the active address
|
- Send / Receive quick-action buttons, both acting on the active address
|
||||||
- ETH/USD price display
|
- ETH/USD price display
|
||||||
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
|
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
|
||||||
button for HD and xprv wallets, then one block per address. The block
|
button for HD and xprv wallets, then one block per address with "Address
|
||||||
opens with a row carrying the colour dot, "Address N" (bold when active),
|
N" (bold when active), the ENS name if resolved, the full address, an
|
||||||
an `[info]` button and an `[x]` button (only on HD and xprv wallets
|
`[info]` button, an `[x]` button (only on HD and xprv wallets holding more
|
||||||
holding more than one address); the ENS name, if resolved, is below it;
|
than one address), the address USD total, and a balance line for ETH and
|
||||||
then the full address on a row of its own, followed by the address USD
|
for each token shown for that address
|
||||||
total and a balance line for ETH and for each token shown for that address
|
|
||||||
- "Recent Transactions": up to 25 transactions merged across every address
|
- "Recent Transactions": up to 25 transactions merged across every address
|
||||||
of every wallet, deduplicated by hash and filtered. Each row is three
|
of every wallet, deduplicated by hash and filtered
|
||||||
lines: age and direction, then the counterparty's colour dot (with our own
|
|
||||||
name for it, where it is one of our addresses) and the amount, then the
|
|
||||||
counterparty's full address on a row of its own
|
|
||||||
- "Add additional wallet..." link at bottom
|
- "Add additional wallet..." link at bottom
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Tap address row → sets the active address and broadcasts
|
- Tap address row → sets the active address and broadcasts
|
||||||
|
|||||||
89
TODO.md
89
TODO.md
@@ -45,71 +45,6 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
|
|
||||||
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
|
|
||||||
wallet list was the reported case: the address shared one row with the
|
|
||||||
`[info]` and `[x]` controls and folded onto a second line, which turns one
|
|
||||||
42-character string the user is meant to compare into two shorter ones — the
|
|
||||||
shape an address-poisoning attack wants. The fix is layout, not CSS: every
|
|
||||||
address in the popup now sits alone on a full-width row, with the colour dot,
|
|
||||||
the wallet title, the ENS name and the explorer link moved onto a strip above
|
|
||||||
it, and the transaction rows carry the counterparty's whole address instead of
|
|
||||||
a `truncateMiddle()`d one squeezed in beside the amount. `truncateMiddle()`
|
|
||||||
keeps its 10-character cap and its 32-character floor moved into
|
|
||||||
`renderAddressHtml()`, so the guarantee outlives having no callers. The e2e
|
|
||||||
suite measures every rendered address in a real Chromium — whole, one line
|
|
||||||
box, inside its row and inside the popup — across Home, the address, token,
|
|
||||||
receive, send and transaction detail screens, the confirmation screen and the
|
|
||||||
dApp transaction prompt.
|
|
||||||
- 2026-08-23: Both manifests declare toolbar icons, and real PNGs at
|
|
||||||
16/32/48/128 ship inside both archives
|
|
||||||
([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest
|
|
||||||
had an `icons` block, so both browsers drew a generic puzzle piece — the first
|
|
||||||
thing the owner sees on every launch, and how a user tells a real extension
|
|
||||||
from a look-alike. The sizes `build.js` copies into each browser directory are
|
|
||||||
read out of the manifest that ships next to them rather than from a second
|
|
||||||
list, so a declared size `icons/` does not hold fails `make build`;
|
|
||||||
`script/lib/package.js` already resolves `.png` references, so an icon that
|
|
||||||
reached a manifest but not the archive fails packaging. The artwork is
|
|
||||||
original: a flat dark-navy rounded field with a teal triangular "A", drawn
|
|
||||||
from geometry and rasterised into PNG, nothing traced or downloaded.
|
|
||||||
- 2026-08-23: A persisted container whose ENTRIES were dereferenced unchecked no
|
|
||||||
longer reaches a `.map()` or a `.toLowerCase()`
|
|
||||||
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). `allowedSites` was
|
|
||||||
the worst shape available: a stored `{"0x…": "notalist"}` passed the gate,
|
|
||||||
rendered a completely healthy popup, and then threw inside `saveState()`'s
|
|
||||||
per-hostname merge, so every save from that moment on failed silently and the
|
|
||||||
user went on operating a wallet that was persisting nothing — measured as
|
|
||||||
`chrome.storage.local.set` never being called at all. `deniedSites` has the
|
|
||||||
same shape, `fraudContracts` the same class with a milder consequence, and the
|
|
||||||
sweep for the class turned up `selectedToken`, `rpcUrl` (handed whole to
|
|
||||||
`new JsonRpcProvider()`, which throws synchronously outside any `try`), the
|
|
||||||
entries of `viewData` (four restore branches gate on one truthy field and then
|
|
||||||
dereference an address), and `selectedWallet`/`selectedAddress` (a stored
|
|
||||||
`"map"` is TRUTHY against a real Array, so the restore guard does not
|
|
||||||
short-circuit). All of them are now floored in `src/shared/persistedState.js`
|
|
||||||
or refused by the per-branch guards in `src/popup/viewRouter.js`. Separately,
|
|
||||||
a save that fails is no longer swallowed: `onSaveFailure()` in
|
|
||||||
`src/shared/state.js` reports every failed save, awaited or not, and the popup
|
|
||||||
raises a persistent "NOT SAVED" banner. The hand-written per-field
|
|
||||||
justification in the header of `src/shared/stateSchema.js` — which had shipped
|
|
||||||
a false claim in three consecutive changes — is replaced by
|
|
||||||
`tests/persistedFieldContract.test.js`, one row per persisted field, each
|
|
||||||
proven by driving the real code with hostile values — and, for a field whose
|
|
||||||
only defence is that nothing dereferences it, by booting the real popup entry
|
|
||||||
point over that value onto every view the popup can reopen onto, since that is
|
|
||||||
the path this whole class of defect lives on. Each such field is driven at
|
|
||||||
both polarities — a value nothing writes is wrong-typed and so truthy, so a
|
|
||||||
falsy slot is driven too, or the field is proven unable to be falsy after the
|
|
||||||
floor. The claim is narrow and stated as such: no structural dereference on
|
|
||||||
the code paths a wholly-corrupted profile takes, which is not every path a
|
|
||||||
stored record takes — a pairing of values the four slots do not produce, a
|
|
||||||
view only forward navigation opens, anything behind a click, and everything a
|
|
||||||
healthy profile reaches are all undriven. Within that boundary the verdict is
|
|
||||||
unconditional, including a dereference that takes two corrupted fields at
|
|
||||||
once, since the assertion is on the combined boot and the per-field re-boot
|
|
||||||
can only decorate the message. A field with no row and a field that gains a
|
|
||||||
floor while its row still claims it has none also fail `make check`.
|
|
||||||
- 2026-08-23: A swap amount and the token it is counted in now always come from
|
- 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
|
the same hop, on both sides of the approval screen
|
||||||
([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and
|
([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and
|
||||||
@@ -210,22 +145,14 @@ but the review is broader than any of them.
|
|||||||
[#246](https://git.eeqj.de/sneak/AutistMask/issues/246). Existing installs
|
[#246](https://git.eeqj.de/sneak/AutistMask/issues/246). Existing installs
|
||||||
hold `18`s that cannot be told apart retroactively; they display exactly as
|
hold `18`s that cannot be told apart retroactively; they display exactly as
|
||||||
they do today until the next balance refresh, which rewrites `tokenBalances`
|
they do today until the next balance refresh, which rewrites `tokenBalances`
|
||||||
wholesale and needs no user action. No `|| 18` or `?? 18` fallback remains
|
wholesale and needs no user action. The only `18`s left in `src/` are native
|
||||||
anywhere in `src/`; the literal `18`s that do remain are real data, not
|
ETH's real scale in `src/shared/uniswap.js` and the fixed-point comparison
|
||||||
defaults — 432 per-token `decimals: 18` entries in the bundled
|
scale in `src/shared/txValidation.js`. `tokenBalances[].decimals` is the
|
||||||
`src/shared/tokenList.js`, and, outside that file, only native ETH's
|
explorer's answer alone and not the scale a screen renders at, so the Send
|
||||||
protocol-defined scale in `src/shared/uniswap.js` and the fixed-point
|
screen resolves through `resolveTokenDecimals()` like every other consumer:
|
||||||
comparison scale in `src/shared/txValidation.js`. `tokenBalances[].decimals`
|
reading the stored field raw carried a `null` into `estimateGas()` for a
|
||||||
is the explorer's answer alone and not the scale a screen renders at, so the
|
bundled token such as WETH, which reported an unestimable network fee and left
|
||||||
Send screen resolves through `resolveTokenDecimals()` like every other
|
Send disabled behind a message no retry could clear.
|
||||||
consumer: reading the stored field raw carried a `null` into `estimateGas()`
|
|
||||||
for a bundled token such as WETH, which reported an unestimable network fee
|
|
||||||
and left Send disabled behind a message no retry could clear. Send resolves
|
|
||||||
with `wallets`, which adds the cross-address disagreement check the balance
|
|
||||||
list does not make, so the two can differ; where they do, the stored quantity
|
|
||||||
was computed at a scale Send has refused, and it is withdrawn with it. An
|
|
||||||
unknown scale is an unknown balance, and the user is told that rather than
|
|
||||||
that the fee could not be estimated.
|
|
||||||
|
|
||||||
- 2026-08-23: The background no longer reads or writes the shared `state`
|
- 2026-08-23: The background no longer reads or writes the shared `state`
|
||||||
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
||||||
|
|||||||
49
build.js
49
build.js
@@ -51,17 +51,6 @@ const RECEIPT_ENV = "AUTISTMASK_BUILD_RECEIPT";
|
|||||||
// rather than writing a receipt that cannot be checked.
|
// rather than writing a receipt that cannot be checked.
|
||||||
const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
|
const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
|
||||||
|
|
||||||
// Where each browser directory's manifest comes from, and — through its
|
|
||||||
// "icons" — which image files ship inside that directory.
|
|
||||||
const MANIFEST_SOURCES = new Map([
|
|
||||||
[DIST_CHROME, path.join(__dirname, "manifest", "chrome.json")],
|
|
||||||
[DIST_FIREFOX, path.join(__dirname, "manifest", "firefox.json")],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// What an "icons" entry may name: a plain file under icons/, so a manifest
|
|
||||||
// value is never joined into a path that leaves the repo.
|
|
||||||
const ICON_REF_RE = /^icons\/[A-Za-z0-9._-]+\.png$/;
|
|
||||||
|
|
||||||
function ensureDir(dir) {
|
function ensureDir(dir) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -268,42 +257,6 @@ function copyEmitted(src, dest) {
|
|||||||
recordEmitted(dest);
|
recordEmitted(dest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the icons one browser directory ships. The sizes come from the manifest
|
|
||||||
// that will sit next to them, not from a second list here: a size the manifest
|
|
||||||
// declares and icons/ does not hold fails the build, rather than shipping a
|
|
||||||
// manifest whose reference resolves to nothing. Relative to the browser
|
|
||||||
// directory, so nothing points up and out of it the way dist/styles.css does.
|
|
||||||
function copyIcons(distDir) {
|
|
||||||
const manifestPath = MANIFEST_SOURCES.get(distDir);
|
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
||||||
const refs = Object.values(manifest.icons || {});
|
|
||||||
if (refs.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`${repoRelative(manifestPath)} declares no icons, so the browser ` +
|
|
||||||
`renders a generic placeholder for this extension`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const ref of refs) {
|
|
||||||
if (!ICON_REF_RE.test(ref)) {
|
|
||||||
throw new Error(
|
|
||||||
`${repoRelative(manifestPath)} declares icon ` +
|
|
||||||
`${JSON.stringify(ref)}, which is not a plain file under ` +
|
|
||||||
`icons/`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const src = path.join(__dirname, ref);
|
|
||||||
if (!fs.existsSync(src)) {
|
|
||||||
throw new Error(
|
|
||||||
`${repoRelative(manifestPath)} declares ${ref}, which is not ` +
|
|
||||||
`in this tree`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const dest = path.join(distDir, ref);
|
|
||||||
ensureDir(path.dirname(dest));
|
|
||||||
copyEmitted(src, dest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sha256File(absPath) {
|
function sha256File(absPath) {
|
||||||
return crypto
|
return crypto
|
||||||
.createHash("sha256")
|
.createHash("sha256")
|
||||||
@@ -571,8 +524,6 @@ async function build() {
|
|||||||
tailwindOutput,
|
tailwindOutput,
|
||||||
path.join(distDir, "src", "popup", "styles.css"),
|
path.join(distDir, "src", "popup", "styles.css"),
|
||||||
);
|
);
|
||||||
|
|
||||||
copyIcons(distDir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy manifests
|
// copy manifests
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
BIN
icons/icon16.png
BIN
icons/icon16.png
Binary file not shown.
|
Before Width: | Height: | Size: 292 B |
BIN
icons/icon32.png
BIN
icons/icon32.png
Binary file not shown.
|
Before Width: | Height: | Size: 534 B |
BIN
icons/icon48.png
BIN
icons/icon48.png
Binary file not shown.
|
Before Width: | Height: | Size: 725 B |
@@ -9,12 +9,6 @@
|
|||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
|
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
|
||||||
},
|
},
|
||||||
"icons": {
|
|
||||||
"16": "icons/icon16.png",
|
|
||||||
"32": "icons/icon32.png",
|
|
||||||
"48": "icons/icon48.png",
|
|
||||||
"128": "icons/icon128.png"
|
|
||||||
},
|
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "src/popup/index.html"
|
"default_popup": "src/popup/index.html"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,12 +5,6 @@
|
|||||||
"description": "Minimal Ethereum wallet for Firefox",
|
"description": "Minimal Ethereum wallet for Firefox",
|
||||||
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
||||||
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
||||||
"icons": {
|
|
||||||
"16": "icons/icon16.png",
|
|
||||||
"32": "icons/icon32.png",
|
|
||||||
"48": "icons/icon48.png",
|
|
||||||
"128": "icons/icon128.png"
|
|
||||||
},
|
|
||||||
"browser_action": {
|
"browser_action": {
|
||||||
"default_popup": "src/popup/index.html"
|
"default_popup": "src/popup/index.html"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -213,7 +213,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- active address display -->
|
<!-- active address display -->
|
||||||
<div id="active-address-display" class="text-xs mb-3"></div>
|
<div
|
||||||
|
id="active-address-display"
|
||||||
|
class="text-xs break-all mb-3"
|
||||||
|
></div>
|
||||||
|
|
||||||
<!-- quick actions for active address -->
|
<!-- quick actions for active address -->
|
||||||
<div class="flex gap-2 mb-2">
|
<div class="flex gap-2 mb-2">
|
||||||
@@ -289,7 +292,7 @@
|
|||||||
class="font-bold mb-1 hidden flex items-center"
|
class="font-bold mb-1 hidden flex items-center"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="text-xs mb-1 cursor-pointer"
|
class="text-xs mb-1 cursor-pointer break-all"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
id="address-line"
|
id="address-line"
|
||||||
>
|
>
|
||||||
@@ -377,14 +380,14 @@
|
|||||||
></div>
|
></div>
|
||||||
<h2 class="font-bold mb-1">Export Private Key</h2>
|
<h2 class="font-bold mb-1">Export Private Key</h2>
|
||||||
<p class="text-xs mb-1" id="export-privkey-title"></p>
|
<p class="text-xs mb-1" id="export-privkey-title"></p>
|
||||||
<div class="text-xs mb-3">
|
<p class="text-xs mb-3">
|
||||||
<span id="export-privkey-dot"></span>
|
<span id="export-privkey-dot"></span>
|
||||||
<span
|
<span
|
||||||
id="export-privkey-address"
|
id="export-privkey-address"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</p>
|
||||||
<p class="text-xs mb-3 text-muted">
|
<p class="text-xs mb-3 text-muted">
|
||||||
Warning: anyone with this private key can access and
|
Warning: anyone with this private key can access and
|
||||||
transfer all funds from this address. Never share it.
|
transfer all funds from this address. Never share it.
|
||||||
@@ -437,7 +440,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="text-xs mb-1 cursor-pointer"
|
class="text-xs mb-1 cursor-pointer break-all"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
id="address-token-line"
|
id="address-token-line"
|
||||||
>
|
>
|
||||||
@@ -570,16 +573,19 @@
|
|||||||
<!-- ERC-20 token contract (hidden for ETH) -->
|
<!-- ERC-20 token contract (hidden for ETH) -->
|
||||||
<div id="confirm-token-section" class="mb-3 hidden">
|
<div id="confirm-token-section" class="mb-3 hidden">
|
||||||
<div class="text-xs text-muted mb-1">Token contract</div>
|
<div class="text-xs text-muted mb-1">Token contract</div>
|
||||||
<div id="confirm-token-contract" class="text-xs"></div>
|
<div
|
||||||
|
id="confirm-token-contract"
|
||||||
|
class="text-xs break-all"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">From</div>
|
<div class="text-xs text-muted mb-1">From</div>
|
||||||
<div id="confirm-from" class="text-xs"></div>
|
<div id="confirm-from" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
<div id="confirm-to" class="text-xs"></div>
|
<div id="confirm-to" class="text-xs break-all"></div>
|
||||||
<div
|
<div
|
||||||
id="confirm-to-ens"
|
id="confirm-to-ens"
|
||||||
class="text-xs text-muted hidden"
|
class="text-xs text-muted hidden"
|
||||||
@@ -722,7 +728,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
<div id="wait-tx-to" class="text-xs"></div>
|
<div id="wait-tx-to" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Transaction hash</div>
|
<div class="text-xs text-muted mb-1">Transaction hash</div>
|
||||||
@@ -741,7 +747,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
<div id="success-tx-to" class="text-xs"></div>
|
<div id="success-tx-to" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Block</div>
|
<div class="text-xs text-muted mb-1">Block</div>
|
||||||
@@ -768,7 +774,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
<div id="error-tx-to" class="text-xs"></div>
|
<div id="error-tx-to" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div
|
<div
|
||||||
@@ -805,9 +811,9 @@
|
|||||||
<canvas id="receive-qr"></canvas>
|
<canvas id="receive-qr"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="border border-border p-2 mb-3 text-xs cursor-pointer"
|
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
|
||||||
>
|
>
|
||||||
<div id="receive-address-block" class="select-all"></div>
|
<span id="receive-address-block" class="select-all"></span>
|
||||||
<span id="receive-etherscan-link"></span>
|
<span id="receive-etherscan-link"></span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -1233,7 +1239,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="delete-address-value"
|
id="delete-address-value"
|
||||||
class="text-xs mb-2 min-h-[1rem]"
|
class="text-xs mb-2 break-all min-h-[1rem]"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="text-xs mb-2 border border-border border-dashed p-2"
|
class="text-xs mb-2 border border-border border-dashed p-2"
|
||||||
@@ -1423,11 +1429,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<div class="text-xs text-muted mb-1">From</div>
|
<div class="text-xs text-muted mb-1">From</div>
|
||||||
<div id="tx-detail-from" class="text-xs"></div>
|
<div
|
||||||
|
id="tx-detail-from"
|
||||||
|
class="text-xs break-all"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<div class="text-xs text-muted mb-1">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
<div id="tx-detail-to" class="text-xs"></div>
|
<div id="tx-detail-to" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1464,7 +1473,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="tx-detail-token-contract"
|
id="tx-detail-token-contract"
|
||||||
class="text-xs"
|
class="text-xs break-all"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1558,11 +1567,11 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">From</div>
|
<div class="text-xs text-muted mb-1">From</div>
|
||||||
<div id="approve-tx-from" class="text-xs"></div>
|
<div id="approve-tx-from" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Contract</div>
|
<div class="text-xs text-muted mb-1">Contract</div>
|
||||||
<div id="approve-tx-to" class="text-xs"></div>
|
<div id="approve-tx-to" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Value</div>
|
<div class="text-xs text-muted mb-1">Value</div>
|
||||||
@@ -1664,7 +1673,7 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">From</div>
|
<div class="text-xs text-muted mb-1">From</div>
|
||||||
<div id="approve-sign-from" class="text-xs"></div>
|
<div id="approve-sign-from" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
// AutistMask popup entry point.
|
// AutistMask popup entry point.
|
||||||
// Loads state, initializes views, triggers first render.
|
// Loads state, initializes views, triggers first render.
|
||||||
|
|
||||||
const {
|
const { state, saveState, loadState } = require("../shared/state");
|
||||||
state,
|
|
||||||
saveState,
|
|
||||||
onSaveFailure,
|
|
||||||
loadState,
|
|
||||||
} = require("../shared/state");
|
|
||||||
const { StateUnusableError } = require("../shared/stateSchema");
|
const { StateUnusableError } = require("../shared/stateSchema");
|
||||||
const { log, setRuntimeDebug } = require("../shared/log");
|
const { setRuntimeDebug } = require("../shared/log");
|
||||||
const { refreshPrices } = require("../shared/prices");
|
const { refreshPrices } = require("../shared/prices");
|
||||||
const { refreshBalances } = require("../shared/balances");
|
const { refreshBalances } = require("../shared/balances");
|
||||||
const {
|
const {
|
||||||
@@ -16,7 +11,6 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
updateDebugBanner,
|
updateDebugBanner,
|
||||||
setBackRenderer,
|
setBackRenderer,
|
||||||
showSaveFailureBanner,
|
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
goBack,
|
goBack,
|
||||||
} = require("./views/helpers");
|
} = require("./views/helpers");
|
||||||
@@ -67,14 +61,6 @@ async function doRefreshAndRender() {
|
|||||||
state.lastBalanceRefresh = Date.now();
|
state.lastBalanceRefresh = Date.now();
|
||||||
await saveState();
|
await saveState();
|
||||||
renderWalletList();
|
renderWalletList();
|
||||||
} catch (e) {
|
|
||||||
// Every call site fires this and walks away — the boot below, the ten
|
|
||||||
// second interval, and eight views through ctx — so it must never
|
|
||||||
// reject: an unhandled rejection is not a report of anything. The save
|
|
||||||
// inside it reports its own failure through onSaveFailure() (see
|
|
||||||
// src/shared/state.js); what is left here is a failed network round
|
|
||||||
// trip, which the next tick retries.
|
|
||||||
log.errorf("popup: background refresh failed:", e);
|
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false;
|
refreshInFlight = false;
|
||||||
}
|
}
|
||||||
@@ -150,12 +136,6 @@ function fallbackView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
// First, before anything can save: showView() saves on every navigation
|
|
||||||
// without awaiting, so a save that fails from here on has somewhere to be
|
|
||||||
// reported rather than being swallowed by the save queue
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
|
||||||
// the approval-window branch below too, since that window saves as well.
|
|
||||||
onSaveFailure(showSaveFailureBanner);
|
|
||||||
try {
|
try {
|
||||||
await loadState();
|
await loadState();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -44,23 +44,3 @@ body {
|
|||||||
background-color 225ms ease-out,
|
background-color 225ms ease-out,
|
||||||
color 225ms ease-out;
|
color 225ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An address is one atomic string, so it gets a row of its own and never
|
|
||||||
* breaks across lines. A wrapped address reads as two shorter strings, and
|
|
||||||
* two shorter strings are exactly what an address-poisoning attack needs
|
|
||||||
* the user to compare instead of the whole thing. Every view that shows an
|
|
||||||
* address puts it in one of these, alone: the colour dot, the wallet title,
|
|
||||||
* the ENS name and the explorer link all live on their own line above, so
|
|
||||||
* nothing competes with the 42 characters for width.
|
|
||||||
*
|
|
||||||
* overflow-x is the escape hatch, not the mechanism. The row is wide enough
|
|
||||||
* for a full address at every nesting depth the popup uses; if that ever
|
|
||||||
* stops being true — a font with wider glyphs, a browser zoom — the row
|
|
||||||
* scrolls and the user can still reach the last character, rather than the
|
|
||||||
* tail being clipped away by #app's overflow-x-hidden with nothing to say
|
|
||||||
* it happened. tests/e2e asserts the scroll is never actually needed. */
|
|
||||||
.am-address {
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
|
truncateMiddle,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
@@ -228,12 +229,10 @@ function renderTransactions(txs) {
|
|||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + sym)
|
? escapeHtml(tx.value + " " + sym)
|
||||||
: escapeHtml(sym);
|
: escapeHtml(sym);
|
||||||
// The counterparty used to be squeezed in beside the amount and
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||||
// truncated to whatever was left over. It gets its own row now and
|
const displayAddr =
|
||||||
// is shown whole; the title or ENS name, where there is one, names
|
title || ensName || truncateMiddle(counterparty, maxAddr);
|
||||||
// it on the line above rather than replacing it.
|
const addrStr = escapeHtml(displayAddr);
|
||||||
const nameStr = escapeHtml(title || ensName || "");
|
|
||||||
const addrStr = escapeHtml(counterparty);
|
|
||||||
const dot = addressDotHtml(counterparty);
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||||
@@ -241,8 +240,7 @@ function renderTransactions(txs) {
|
|||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||||
html += `<div class="am-address">${addrStr}</div>`;
|
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
|
truncateMiddle,
|
||||||
balanceLine,
|
balanceLine,
|
||||||
unknownableAmount,
|
unknownableAmount,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
@@ -304,12 +305,10 @@ function renderTransactions(txs) {
|
|||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + sym)
|
? escapeHtml(tx.value + " " + sym)
|
||||||
: escapeHtml(sym);
|
: escapeHtml(sym);
|
||||||
// The counterparty used to be squeezed in beside the amount and
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||||
// truncated to whatever was left over. It gets its own row now and
|
const displayAddr =
|
||||||
// is shown whole; the title or ENS name, where there is one, names
|
title || ensName || truncateMiddle(counterparty, maxAddr);
|
||||||
// it on the line above rather than replacing it.
|
const addrStr = escapeHtml(displayAddr);
|
||||||
const nameStr = escapeHtml(title || ensName || "");
|
|
||||||
const addrStr = escapeHtml(counterparty);
|
|
||||||
const dot = addressDotHtml(counterparty);
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||||
@@ -317,8 +316,7 @@ function renderTransactions(txs) {
|
|||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||||
html += `<div class="am-address">${addrStr}</div>`;
|
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,38 +132,6 @@ function updateDebugBanner(viewName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The banner shown when a save has failed, registered as the save-failure
|
|
||||||
// reporter by src/popup/index.js.
|
|
||||||
//
|
|
||||||
// Persistent and not dismissable, unlike showFlash(): what it says is true
|
|
||||||
// until the popup is closed, and a message that clears itself after two seconds
|
|
||||||
// is how the user goes on operating a wallet that is persisting nothing
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362). It survives navigation
|
|
||||||
// because it hangs off document.body rather than off a view.
|
|
||||||
//
|
|
||||||
// Created on demand rather than authored in index.html, the same way
|
|
||||||
// updateDebugBanner() creates its own: it is absent from a popup where nothing
|
|
||||||
// has failed, which is the state that must not need markup to be in.
|
|
||||||
//
|
|
||||||
// textContent, never innerHTML: `detail` carries an error message, which may
|
|
||||||
// come from the browser's storage layer.
|
|
||||||
function showSaveFailureBanner(detail) {
|
|
||||||
let banner = document.getElementById("save-failure-banner");
|
|
||||||
if (!banner) {
|
|
||||||
banner = document.createElement("div");
|
|
||||||
banner.id = "save-failure-banner";
|
|
||||||
banner.style.cssText =
|
|
||||||
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:2px 4px;font-family:monospace;position:sticky;top:0;z-index:10000;";
|
|
||||||
document.body.prepend(banner);
|
|
||||||
}
|
|
||||||
const message = (detail && (detail.message || detail.problem)) || detail;
|
|
||||||
banner.textContent =
|
|
||||||
"NOT SAVED — AutistMask could not write to storage, so recent" +
|
|
||||||
" changes are not stored. Close and reopen the popup; if this keeps" +
|
|
||||||
" happening, do not rely on anything you change now." +
|
|
||||||
(message ? " (" + String(message) + ")" : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Callback that renders a view being navigated BACK onto. Set once by
|
// Callback that renders a view being navigated BACK onto. Set once by
|
||||||
// index.js via setBackRenderer(), which routes the view through the same
|
// index.js via setBackRenderer(), which routes the view through the same
|
||||||
// per-view render and data guards restoreView() uses.
|
// per-view render and data guards restoreView() uses.
|
||||||
@@ -229,15 +197,6 @@ function showFlash(msg, duration = 2000) {
|
|||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stored token balance as a number, or null when there is no number in it.
|
|
||||||
// balances.js writes null for a holding whose scale nothing knows, and this
|
|
||||||
// keeps that null from becoming a zero one dereference later.
|
|
||||||
function unknownableAmount(balance) {
|
|
||||||
if (balance == null) return null;
|
|
||||||
const n = parseFloat(balance);
|
|
||||||
return Number.isFinite(n) ? n : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One row of the balance list: symbol, quantity, fiat value.
|
// One row of the balance list: symbol, quantity, fiat value.
|
||||||
//
|
//
|
||||||
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
||||||
@@ -251,6 +210,15 @@ function unknownableAmount(balance) {
|
|||||||
// print for it and no fiat value to derive from one, and printing 0.0000 for
|
// print for it and no fiat value to derive from one, and printing 0.0000 for
|
||||||
// a real holding is the failure this whole rule exists to prevent, so the row
|
// a real holding is the failure this whole rule exists to prevent, so the row
|
||||||
// says so instead.
|
// says so instead.
|
||||||
|
// A stored token balance as a number, or null when there is no number in it.
|
||||||
|
// balances.js writes null for a holding whose scale nothing knows, and this
|
||||||
|
// keeps that null from becoming a zero one dereference later.
|
||||||
|
function unknownableAmount(balance) {
|
||||||
|
if (balance == null) return null;
|
||||||
|
const n = parseFloat(balance);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
function balanceLine(symbol, amount, price, tokenId) {
|
function balanceLine(symbol, amount, price, tokenId) {
|
||||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||||
const usd =
|
const usd =
|
||||||
@@ -331,12 +299,6 @@ function addressHoldsFunds(addr) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The fewest characters of an address any caller may ask to display. The
|
|
||||||
// 10-character cap inside truncateMiddle() is the other half of the same
|
|
||||||
// guarantee; this is the half that used to be spelled out at each call
|
|
||||||
// site, and is now enforced once in renderAddressHtml().
|
|
||||||
const ADDRESS_MIN_DISPLAY_LEN = 32;
|
|
||||||
|
|
||||||
// Truncate the middle of a string, replacing removed characters with "…".
|
// Truncate the middle of a string, replacing removed characters with "…".
|
||||||
// Safety: refuses to truncate more than 10 characters, which is the maximum
|
// Safety: refuses to truncate more than 10 characters, which is the maximum
|
||||||
// that still prevents address spoofing attacks (see Display Consistency in
|
// that still prevents address spoofing attacks (see Display Consistency in
|
||||||
@@ -524,29 +486,17 @@ function attachCopyHandlers(container) {
|
|||||||
|
|
||||||
// Unified address rendering.
|
// Unified address rendering.
|
||||||
//
|
//
|
||||||
// Two stacked rows, in this order:
|
// Produces consistent HTML for any Ethereum address:
|
||||||
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 —
|
// • Color dot
|
||||||
// Address 2") and the explorer link icon. Optional ENS name below it.
|
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
|
||||||
// 2. The address itself, alone on a full-width row that never wraps
|
// • Optional ENS name shown bold above address
|
||||||
// (see .am-address in styles/main.css).
|
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
|
||||||
//
|
// • Etherscan external link icon
|
||||||
// The split is the point. Everything used to sit on one line: dot, address
|
|
||||||
// and link together, with `break-all` to let the address fold when the line
|
|
||||||
// ran out. In the wallet list, where the row also carried [info] and [x],
|
|
||||||
// it ran out every time — the bug in #380 — and a folded address is a
|
|
||||||
// spoofing hazard, not a cosmetic one. Nothing shares the address's row
|
|
||||||
// now, so all 42 characters fit at every nesting depth the popup uses and
|
|
||||||
// nothing has to be dropped or folded to make room.
|
|
||||||
//
|
//
|
||||||
// Options object:
|
// Options object:
|
||||||
// title — wallet title string (from addressTitle)
|
// title — wallet title string (from addressTitle)
|
||||||
// ensName — ENS name string
|
// ensName — ENS name string
|
||||||
// maxLen — if set, truncate address display. Floored at 32 characters
|
// maxLen — if set, truncate address display (min 32 chars enforced)
|
||||||
// here rather than by the caller: no view passes it any more
|
|
||||||
// (every address row is wide enough for all 42 characters),
|
|
||||||
// so a floor that lived in the callers would have gone away
|
|
||||||
// with them, and the "at least 32 characters" guarantee has
|
|
||||||
// to survive having no current callers to be a guarantee.
|
|
||||||
// noLink — if true, omit etherscan link
|
// noLink — if true, omit etherscan link
|
||||||
//
|
//
|
||||||
// After inserting the returned HTML into the DOM, call
|
// After inserting the returned HTML into the DOM, call
|
||||||
@@ -554,22 +504,22 @@ function attachCopyHandlers(container) {
|
|||||||
function renderAddressHtml(address, opts) {
|
function renderAddressHtml(address, opts) {
|
||||||
const { title, ensName, maxLen, noLink } = opts || {};
|
const { title, ensName, maxLen, noLink } = opts || {};
|
||||||
const dot = addressDotHtml(address);
|
const dot = addressDotHtml(address);
|
||||||
const displayAddr = maxLen
|
const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
|
||||||
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
|
|
||||||
: address;
|
|
||||||
const link = etherscanAddressUrl(address);
|
const link = etherscanAddressUrl(address);
|
||||||
const extLink = noLink ? "" : etherscanLinkHtml(link);
|
const extLink = noLink ? "" : etherscanLinkHtml(link);
|
||||||
|
|
||||||
let html = "";
|
let html = "";
|
||||||
html += `<div class="flex items-center">${dot}`;
|
|
||||||
if (title) {
|
if (title) {
|
||||||
html += `<span class="font-bold">${escapeHtml(title)}</span>`;
|
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
|
||||||
}
|
}
|
||||||
html += `${extLink}</div>`;
|
|
||||||
if (ensName) {
|
if (ensName) {
|
||||||
html += `<div class="font-bold">${escapeHtml(ensName)}</div>`;
|
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
|
||||||
|
}
|
||||||
|
if (title || ensName) {
|
||||||
|
html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
|
||||||
}
|
}
|
||||||
html += `<div class="am-address">${copyableHtml(displayAddr)}</div>`;
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +544,6 @@ module.exports = {
|
|||||||
showView,
|
showView,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
updateDebugBanner,
|
updateDebugBanner,
|
||||||
showSaveFailureBanner,
|
|
||||||
setBackRenderer,
|
setBackRenderer,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
goBack,
|
goBack,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
|
truncateMiddle,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
@@ -116,13 +117,10 @@ function renderHomeTxList(ctx) {
|
|||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + sym)
|
? escapeHtml(tx.value + " " + sym)
|
||||||
: escapeHtml(sym);
|
: escapeHtml(sym);
|
||||||
// The counterparty used to be squeezed in beside the amount and
|
|
||||||
// truncated to whatever was left over. It gets its own row now and
|
|
||||||
// is shown whole; the title, when it is one of our own addresses,
|
|
||||||
// names it on the line above rather than replacing it.
|
|
||||||
const title = addressTitle(counterparty, state.wallets);
|
const title = addressTitle(counterparty, state.wallets);
|
||||||
const titleStr = title ? escapeHtml(title) : "";
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||||
const addrStr = escapeHtml(counterparty);
|
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
|
||||||
|
const addrStr = escapeHtml(displayAddr);
|
||||||
const dot = addressDotHtml(counterparty);
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||||
@@ -130,8 +128,7 @@ function renderHomeTxList(ctx) {
|
|||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${titleStr}</span><span>${amountStr}</span></div>`;
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||||
html += `<div class="am-address">${addrStr}</div>`;
|
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
@@ -255,22 +252,17 @@ function walletListHtml() {
|
|||||||
: "";
|
: "";
|
||||||
const dot = addressDotHtml(addr.address);
|
const dot = addressDotHtml(addr.address);
|
||||||
const titleBold = isActive ? "font-bold" : "";
|
const titleBold = isActive ? "font-bold" : "";
|
||||||
// [info] and [x] ride on the "Address N" line, which was empty
|
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
|
||||||
// to its right, so the address below gets the row to itself.
|
|
||||||
// They used to sit beside the address and take about a third of
|
|
||||||
// the width off it, which is what made a 42-character address
|
|
||||||
// fold onto a second line here and nowhere else (#380).
|
|
||||||
html += `<div class="flex text-xs items-center justify-between">`;
|
|
||||||
html += `<span class="flex items-center ${titleBold}">${dot}Address ${ai + 1}</span>`;
|
|
||||||
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
|
|
||||||
html += `</div>`;
|
|
||||||
if (addr.ensName) {
|
if (addr.ensName) {
|
||||||
// An ENS reverse record is whatever the name owner set it
|
// An ENS reverse record is whatever the name owner set it
|
||||||
// to; renderAddressHtml() escapes its own copy of this and
|
// to; renderAddressHtml() escapes its own copy of this and
|
||||||
// this list was the one that did not.
|
// this list was the one that did not.
|
||||||
html += `<div class="text-xs font-bold">${escapeHtml(addr.ensName)}</div>`;
|
html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
|
||||||
}
|
}
|
||||||
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
html += `<div class="flex text-xs items-center justify-between">`;
|
||||||
|
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${escapeHtml(addr.address)}</span>`;
|
||||||
|
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
|
||||||
|
html += `</div>`;
|
||||||
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
||||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
||||||
html += balanceLinesForAddress(
|
html += balanceLinesForAddress(
|
||||||
|
|||||||
@@ -255,31 +255,12 @@ function init(_ctx) {
|
|||||||
// inside estimateGas(), which the confirmation screen reports as
|
// inside estimateGas(), which the confirmation screen reports as
|
||||||
// an unestimable fee. Unsendable, over a scale that was never in
|
// an unestimable fee. Unsendable, over a scale that was never in
|
||||||
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||||
// Still null when nothing knows: no fallback.
|
// Still null when nothing knows: no fallback, and the unknown
|
||||||
//
|
// path below is then the real one.
|
||||||
// Resolved WITH `wallets`, which balances.js does not pass: that
|
|
||||||
// adds explorerDecimals()'s cross-address check, so a contract two
|
|
||||||
// addresses report different scales for answers null rather than
|
|
||||||
// picking one. That check has to apply here, because this value
|
|
||||||
// encodes a transfer; balances.js is formatting one explorer row
|
|
||||||
// at fetch time and cannot consult a state it is in the middle of
|
|
||||||
// replacing.
|
|
||||||
tokenDecimals = resolveTokenDecimals(token, {
|
tokenDecimals = resolveTokenDecimals(token, {
|
||||||
trackedTokens: state.trackedTokens,
|
trackedTokens: state.trackedTokens,
|
||||||
wallets: state.wallets,
|
wallets: state.wallets,
|
||||||
});
|
});
|
||||||
// The two resolutions can therefore differ, and where they do, the
|
|
||||||
// stored `balance` is a quantity computed at a scale this screen
|
|
||||||
// has just declined to stand behind. Stating it would leave
|
|
||||||
// validateTransfer() checking the amount against a number the
|
|
||||||
// wallet does not vouch for, and — since the unknown-balance path
|
|
||||||
// is gated on the balance, not on the scale — would leave the
|
|
||||||
// fee-estimate failure as the only thing on the confirmation
|
|
||||||
// screen, which says nothing about decimals. Unknown scale means
|
|
||||||
// unknown balance. Only a stored quantity is withdrawn: the "0"
|
|
||||||
// for a token that has no row at all is an absence of holdings,
|
|
||||||
// which is true at every scale.
|
|
||||||
if (tb && tokenDecimals === null) tokenBalance = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.showConfirmTx({
|
ctx.showConfirmTx({
|
||||||
|
|||||||
@@ -137,16 +137,10 @@ function render() {
|
|||||||
if (tx.contractAddress) {
|
if (tx.contractAddress) {
|
||||||
const dot = addressDotHtml(tx.contractAddress);
|
const dot = addressDotHtml(tx.contractAddress);
|
||||||
const link = explorerUrl("token", tx.contractAddress);
|
const link = explorerUrl("token", tx.contractAddress);
|
||||||
// Hand-rolled rather than renderAddressHtml() because the
|
|
||||||
// link goes to the explorer's /token/ page, not /address/.
|
|
||||||
// Same two-row shape though: dot and link on the strip, the
|
|
||||||
// contract address alone on the row below it.
|
|
||||||
tokenContractEl.innerHTML =
|
tokenContractEl.innerHTML =
|
||||||
`<div class="flex items-center">${dot}` +
|
`<div class="flex items-center">${dot}` +
|
||||||
|
copyableHtml(tx.contractAddress, "break-all") +
|
||||||
etherscanLinkHtml(link) +
|
etherscanLinkHtml(link) +
|
||||||
`</div>` +
|
|
||||||
`<div class="am-address">` +
|
|
||||||
copyableHtml(tx.contractAddress) +
|
|
||||||
`</div>`;
|
`</div>`;
|
||||||
tokenContractSection.classList.remove("hidden");
|
tokenContractSection.classList.remove("hidden");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -100,107 +100,6 @@ function tokenRefs(value) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A list of strings, for the fields whose entries are dereferenced as text:
|
|
||||||
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
|
|
||||||
// src/shared/transactions.js) and each address's hostname list in the site maps
|
|
||||||
// below (`h !== host` filters, `list.includes(hostname)` in the background).
|
|
||||||
//
|
|
||||||
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
|
|
||||||
// with a malformed entry DROPPED rather than repaired. A number in a hostname
|
|
||||||
// list names no site and a number in fraudContracts names no contract, so there
|
|
||||||
// is nothing to repair either to, and the empty list is a legitimate value that
|
|
||||||
// survives. The result is a fresh array of primitives, so it shares no
|
|
||||||
// structure with `saved`.
|
|
||||||
function textList(value) {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value.filter((entry) => typeof entry === "string");
|
|
||||||
}
|
|
||||||
|
|
||||||
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
|
|
||||||
//
|
|
||||||
// The container check these had (truthy and not an array) is not the floor:
|
|
||||||
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
|
|
||||||
// level below it. saveState() merges these maps per key and then per hostname
|
|
||||||
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
|
|
||||||
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
|
|
||||||
// rendered, which is why every save from then on failed while the UI looked
|
|
||||||
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
|
|
||||||
// revoke button (`list.filter()`), and the background's
|
|
||||||
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
|
|
||||||
// one a stored string would also answer a SUBSTRING match, so a corrupt map
|
|
||||||
// could widen a site permission rather than merely throw.
|
|
||||||
//
|
|
||||||
// An address key whose value is not a list of hostnames is dropped entirely: it
|
|
||||||
// grants and denies nothing, and dropping it fails closed. A stored own
|
|
||||||
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
|
|
||||||
// can never be a wallet address, so it grants nothing either, and keeping it
|
|
||||||
// only keeps a value that saveState()'s merge would hand to the prototype
|
|
||||||
// setter on the next write. Keys are written with defineProperty so that no key
|
|
||||||
// reaching this function can consult a setter at all, whatever the rule above
|
|
||||||
// it becomes; mergeMapByKey() in src/shared/state.js writes the same way.
|
|
||||||
function siteMap(value) {
|
|
||||||
const out = {};
|
|
||||||
if (!isRecord(value)) return out;
|
|
||||||
for (const address of Object.keys(value)) {
|
|
||||||
if (address === "__proto__") continue;
|
|
||||||
const hostnames = textList(value[address]);
|
|
||||||
if (hostnames.length === 0) continue;
|
|
||||||
defineOwn(out, address, hostnames);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// An endpoint URL: non-empty text, or the fallback.
|
|
||||||
function url(value, fallback) {
|
|
||||||
return typeof value === "string" && value !== "" ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One remembered endpoint pair out of networkEndpoints, floored on the two
|
|
||||||
// fields applyChainSwitchFields() (src/shared/chainSwitchFields.js) assigns
|
|
||||||
// STRAIGHT ONTO s.rpcUrl / s.blockscoutUrl on the next chain switch: flooring
|
|
||||||
// the live fields alone would leave a non-string sitting one switch away from
|
|
||||||
// them. A field that is not text is deleted rather than replaced, so the
|
|
||||||
// switch falls through its own `|| net.defaultRpcUrl`. Anything else the pair
|
|
||||||
// carries is kept: a profile that has been on a build storing more per-network
|
|
||||||
// fields must not lose them by passing through this one.
|
|
||||||
function endpointPair(value) {
|
|
||||||
const pair = { ...(isRecord(value) ? value : {}) };
|
|
||||||
for (const field of ["rpcUrl", "blockscoutUrl"]) {
|
|
||||||
if (typeof pair[field] !== "string" || pair[field] === "") {
|
|
||||||
delete pair[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pair;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A list index into wallets / a wallet's addresses: a non-negative integer, or
|
|
||||||
// null for "nothing selected".
|
|
||||||
//
|
|
||||||
// hasValidAddress() (src/popup/viewRouter.js) guards the restore path with
|
|
||||||
// `state.wallets[state.selectedWallet] && …addresses[state.selectedAddress]`,
|
|
||||||
// which is safe for a stale INTEGER — out of range is undefined, and the `&&`
|
|
||||||
// short-circuits — and NOT safe for a string naming an Array.prototype member.
|
|
||||||
// `wallets["map"]` is truthy, so the guard does not short-circuit and
|
|
||||||
// `.addresses[…]` throws out of restoreView(): the dead popup. "length",
|
|
||||||
// "constructor" and "__proto__" answer the same way, and
|
|
||||||
// src/popup/views/confirmTx.js dereferences selectedWallet behind no guard at
|
|
||||||
// all.
|
|
||||||
function listIndex(value) {
|
|
||||||
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write `key` as an own data property, never through a setter. Plain
|
|
||||||
// assignment of "__proto__" replaces the object's prototype and records no
|
|
||||||
// entry; every map built from stored keys goes through this.
|
|
||||||
function defineOwn(obj, key, value) {
|
|
||||||
Object.defineProperty(obj, key, {
|
|
||||||
value: value,
|
|
||||||
writable: true,
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep only the leading run of stored views the popup is willing to render.
|
// Keep only the leading run of stored views the popup is willing to render.
|
||||||
//
|
//
|
||||||
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
||||||
@@ -296,15 +195,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
|
||||||
@@ -318,16 +210,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
|
||||||
@@ -352,8 +247,14 @@ function normalizePersisted(saved) {
|
|||||||
typeof saved.activeAddress === "string" && saved.activeAddress !== ""
|
typeof saved.activeAddress === "string" && saved.activeAddress !== ""
|
||||||
? saved.activeAddress
|
? saved.activeAddress
|
||||||
: null;
|
: null;
|
||||||
out.allowedSites = siteMap(saved.allowedSites);
|
out.allowedSites =
|
||||||
out.deniedSites = siteMap(saved.deniedSites);
|
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
||||||
|
? structuredClone(saved.allowedSites)
|
||||||
|
: {};
|
||||||
|
out.deniedSites =
|
||||||
|
saved.deniedSites && !Array.isArray(saved.deniedSites)
|
||||||
|
? structuredClone(saved.deniedSites)
|
||||||
|
: {};
|
||||||
out.rememberSiteChoice =
|
out.rememberSiteChoice =
|
||||||
saved.rememberSiteChoice !== undefined
|
saved.rememberSiteChoice !== undefined
|
||||||
? saved.rememberSiteChoice
|
? saved.rememberSiteChoice
|
||||||
@@ -386,32 +287,16 @@ function normalizePersisted(saved) {
|
|||||||
: 100000;
|
: 100000;
|
||||||
out.utcTimestamps =
|
out.utcTimestamps =
|
||||||
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
|
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
|
||||||
// A list of contract addresses, floored the same way: send.js builds its
|
out.fraudContracts = structuredClone(saved.fraudContracts || []);
|
||||||
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
|
|
||||||
// and filterTransactions() maps the same list through normalizeAddress(),
|
|
||||||
// so a stored string walks through the `|| []` and a stored number walks
|
|
||||||
// through an Array.isArray().
|
|
||||||
out.fraudContracts = textList(saved.fraudContracts);
|
|
||||||
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
|
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
|
||||||
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;
|
||||||
// "ETH", or a contract address, or null — never anything else. The popup
|
out.selectedAddress =
|
||||||
// restores onto "address-token" behind a truthiness check on this field and
|
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
|
||||||
// then dereferences it as text (`tokenId.toLowerCase()` in
|
out.selectedToken = saved.selectedToken || null;
|
||||||
// src/popup/views/addressToken.js, `state.selectedToken.toLowerCase()` in
|
|
||||||
// src/popup/views/receive.js), so a stored number is truthy, passes the
|
|
||||||
// restore gate, and throws on the screen it restores onto. Found by the
|
|
||||||
// sweep for this same defect class in
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/362; floored to null, which
|
|
||||||
// is what the restore gate already treats as "nothing selected". The empty
|
|
||||||
// string was already falsy here and stays null.
|
|
||||||
out.selectedToken =
|
|
||||||
typeof saved.selectedToken === "string" && saved.selectedToken !== ""
|
|
||||||
? saved.selectedToken
|
|
||||||
: null;
|
|
||||||
out.viewData = structuredClone(saved.viewData || {});
|
out.viewData = structuredClone(saved.viewData || {});
|
||||||
out.viewStack = restorableStack(saved.viewStack, out.currentView);
|
out.viewStack = restorableStack(saved.viewStack, out.currentView);
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,51 +522,11 @@ async function saveStateOnce() {
|
|||||||
// begins, so each one only ever sees the true live state at its turn.
|
// begins, so each one only ever sees the true live state at its turn.
|
||||||
let saveQueue = Promise.resolve();
|
let saveQueue = Promise.resolve();
|
||||||
|
|
||||||
// Where a failed save is REPORTED, set once by the context that has a screen
|
|
||||||
// to say it on (src/popup/index.js).
|
|
||||||
//
|
|
||||||
// A save that fails must not fail silently. showView() fires saveState() on
|
|
||||||
// every navigation without awaiting it, and the queue below has to attach a
|
|
||||||
// rejection handler to keep advancing — so a failing save was swallowed
|
|
||||||
// entirely: no throw, no message, nothing on screen. The wallet kept running
|
|
||||||
// against storage that was rejecting every write, which is the data-loss half
|
|
||||||
// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
|
|
||||||
// no better off: `await saveState()` inside an unguarded event handler surfaces
|
|
||||||
// in the console and nowhere the user looks.
|
|
||||||
//
|
|
||||||
// This is the "tell the user" half; the other half is the floor in
|
|
||||||
// normalizePersisted(), which stops the malformed-record cause from arising in
|
|
||||||
// the first place. Both, because a floor only covers the causes it knows about
|
|
||||||
// and storage can still fail for reasons of its own (quota, a revoked
|
|
||||||
// permission, a record a newer build wrote).
|
|
||||||
let saveFailureHandler = null;
|
|
||||||
|
|
||||||
function onSaveFailure(fn) {
|
|
||||||
saveFailureHandler = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportSaveFailure(err) {
|
|
||||||
log.errorf("state: saving failed, changes were NOT persisted:", err);
|
|
||||||
if (!saveFailureHandler) return;
|
|
||||||
try {
|
|
||||||
saveFailureHandler(err);
|
|
||||||
} catch (e) {
|
|
||||||
// The reporter is the last thing standing between a failed save and
|
|
||||||
// silence; a reporter that throws must not become an unhandled
|
|
||||||
// rejection of its own on top of it.
|
|
||||||
log.errorf("state: the save-failure reporter itself failed:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveState() {
|
function saveState() {
|
||||||
const turn = saveQueue.then(saveStateOnce);
|
const turn = saveQueue.then(saveStateOnce);
|
||||||
// The queue must advance even when a save rejects, or every save after
|
// The queue must advance even when a save rejects, or every save after
|
||||||
// it queues behind a promise that never settles.
|
// it queues behind a promise that never settles.
|
||||||
saveQueue = turn.catch(() => {});
|
saveQueue = turn.catch(() => {});
|
||||||
// Every failed save is reported, whether or not the caller awaited this
|
|
||||||
// one. The returned promise still rejects, so a caller that DOES await
|
|
||||||
// keeps its own error handling.
|
|
||||||
turn.catch(reportSaveFailure);
|
|
||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,7 +574,6 @@ function currentAddress() {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
state,
|
state,
|
||||||
saveState,
|
saveState,
|
||||||
onSaveFailure,
|
|
||||||
loadState,
|
loadState,
|
||||||
currentAddress,
|
currentAddress,
|
||||||
currentNetwork,
|
currentNetwork,
|
||||||
|
|||||||
@@ -26,42 +26,35 @@
|
|||||||
//
|
//
|
||||||
// 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, networkId, networkEndpoints, activeAddress, viewStack.
|
||||||
// the property that field's floor is claimed to have, and PROVING it by
|
// These are the fields something dereferences structurally — iterated,
|
||||||
// driving the real code with hostile values — the gate for a field the gate
|
// indexed, assigned into, or .toLowerCase()'d — where a truthy value of
|
||||||
// refuses, normalizePersisted() for a field it floors, and, for a field whose
|
// the wrong type throws on the first read. The entries matter as much as
|
||||||
// only defence is that nothing dereferences it structurally, a boot of the
|
// the container: [1, 2] IS a list, and `t.address` is one level below the
|
||||||
// real popup entry point onto EVERY view the popup can reopen onto.
|
// Array.isArray(). What is checked on an ENTRY is the field the check
|
||||||
|
// exists for and no more — for trackedTokens and tokenBalances that is
|
||||||
|
// `address` alone; the rest of an entry is taken verbatim. So an entry's
|
||||||
|
// `decimals` and `balance` may be null, which is how balances.js records
|
||||||
|
// that nothing knows the token's scale
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349), and every reader
|
||||||
|
// handles that null rather than being defended from it here.
|
||||||
|
// Container shape only: allowedSites, deniedSites. A falsy value or a list
|
||||||
|
// becomes {}; anything else is taken as stored and the entries are not
|
||||||
|
// checked.
|
||||||
|
// `saved.x || default`, no type check: rpcUrl, blockscoutUrl,
|
||||||
|
// lastBalanceRefresh, fraudContracts, tokenHolderCache, theme,
|
||||||
|
// currentView, selectedToken, viewData.
|
||||||
|
// Present-or-default, value taken verbatim: every boolean flag,
|
||||||
|
// dustThresholdGwei, selectedWallet, selectedAddress.
|
||||||
//
|
//
|
||||||
// That last part is the whole point, because this defect class lives on the
|
// A field added to the record needs a check here or a floor there, chosen by
|
||||||
// RESTORE path and not on Home. Take the claim NARROWLY, exactly as that file
|
// what reads it: anything dereferenced structurally needs the type check, and
|
||||||
// states it: what those boots prove is no structural dereference on the code
|
// neither of the last two kinds is one.
|
||||||
// paths a WHOLLY-CORRUPTED PROFILE takes — which is not every path a stored
|
|
||||||
// record takes. Not driven: any pairing of values the four slots do not
|
|
||||||
// produce, a view only forward navigation opens, anything behind a click, and
|
|
||||||
// everything a healthy profile reaches. Within that boundary the verdict is
|
|
||||||
// unconditional, including a dereference that takes two corrupted fields at
|
|
||||||
// once. That suite also goes red on a field that gains a floor while its row
|
|
||||||
// still claims it has none, and on a field added to PERSISTED_FIELDS with no
|
|
||||||
// row at all.
|
|
||||||
//
|
|
||||||
// That test exists because this comment did not work. It carried a
|
|
||||||
// hand-written justification per field, and it shipped a false one in three
|
|
||||||
// consecutive changes — a different field each time, each caught only by a
|
|
||||||
// reviewer re-deriving thirty fields by hand. A claim nobody can execute is
|
|
||||||
// worse than no claim, because it is believed.
|
|
||||||
//
|
|
||||||
// The trap is worth stating here, since it is what all three got wrong: a
|
|
||||||
// check on a CONTAINER is not a check on its ENTRIES, and the dereference is
|
|
||||||
// one level below the container. `[1, 2]` is a list, `{"0x…": "notalist"}` is
|
|
||||||
// a record, and `{"currentView":"success-tx","viewData":{"hash":"0x1"}}`
|
|
||||||
// passes the restore gate and throws on the address the renderer below it
|
|
||||||
// reads. A field added to the record needs a decision about its entries as
|
|
||||||
// well as its shape — and then a row in that test.
|
|
||||||
|
|
||||||
const { isKnownNetworkId } = require("./networks");
|
const { isKnownNetworkId } = require("./networks");
|
||||||
|
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
203
tests/e2e/run.js
203
tests/e2e/run.js
@@ -1525,7 +1525,6 @@ async function goToConfirm(page, { token, balance, amount }) {
|
|||||||
await page.fill("#send-amount", amount);
|
await page.fill("#send-amount", amount);
|
||||||
await page.click("#btn-send-review");
|
await page.click("#btn-send-review");
|
||||||
await visible(page, "#view-confirm-tx");
|
await visible(page, "#view-confirm-tx");
|
||||||
await assertAddressesFit(page, "the confirmation screen");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A balance as the main view renders it: balanceLinesForAddress() writes
|
// A balance as the main view renders it: balanceLinesForAddress() writes
|
||||||
@@ -3263,8 +3262,6 @@ test("eth_sendTransaction signs the approved transaction and broadcasts it (#183
|
|||||||
JSON.stringify(screen.data),
|
JSON.stringify(screen.data),
|
||||||
);
|
);
|
||||||
|
|
||||||
await assertAddressesFit(popup, "the dApp transaction prompt");
|
|
||||||
|
|
||||||
const broadcastBefore = env.routeOpts.broadcastTransactions.length;
|
const broadcastBefore = env.routeOpts.broadcastTransactions.length;
|
||||||
await popup.fill("#approve-tx-password", PASSWORD);
|
await popup.fill("#approve-tx-password", PASSWORD);
|
||||||
await popup.click("#btn-approve-tx");
|
await popup.click("#btn-approve-tx");
|
||||||
@@ -3428,206 +3425,6 @@ test("the password never crossed either boundary in this section (#183)", async
|
|||||||
await env.dapp.close();
|
await env.dapp.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------- address layout (#380)
|
|
||||||
//
|
|
||||||
// "addresses should never wrap in the common views. this doesn't mean to
|
|
||||||
// just change the css, but update the layout itself so the untruncated
|
|
||||||
// addresses are shown in full and don't mess up the layout."
|
|
||||||
//
|
|
||||||
// Every one of these questions is about glyph advances and the width of
|
|
||||||
// the box an address landed in, and nothing in the markup answers any of
|
|
||||||
// them: a row can hold `white-space: nowrap` and still be too narrow, and
|
|
||||||
// the popup's own `overflow-x-hidden` would then hide the evidence by
|
|
||||||
// clipping the tail. So they are measured in a real Chromium, on the real
|
|
||||||
// rendered views, one assertion per property #380 names:
|
|
||||||
//
|
|
||||||
// - the whole address is there (42 characters, no ellipsis)
|
|
||||||
// - it occupies exactly one line box
|
|
||||||
// - it fits its row, so the overflow-x escape hatch never engages
|
|
||||||
// - its row ends inside the popup's content box
|
|
||||||
// - and the document itself does not scroll sideways
|
|
||||||
//
|
|
||||||
// The narrowest containers the popup has are covered here — the
|
|
||||||
// transaction detail wells (`bg-well p-3 mx-1`) and the token contract
|
|
||||||
// well — so the wider ones cannot fail while these pass.
|
|
||||||
|
|
||||||
// Everything on screen that carries an address, measured in one pass.
|
|
||||||
// Views other than the current one are display:none and measure zero, so
|
|
||||||
// filtering on width leaves exactly what a user can see right now.
|
|
||||||
function addressRowReport(page) {
|
|
||||||
return page.evaluate(() => {
|
|
||||||
const app = document.getElementById("app");
|
|
||||||
const appRight = app.getBoundingClientRect().right;
|
|
||||||
const rows = [];
|
|
||||||
for (const el of document.querySelectorAll(".am-address")) {
|
|
||||||
const box = el.getBoundingClientRect();
|
|
||||||
if (box.width === 0) continue;
|
|
||||||
// Line boxes are counted off the inline content, because the
|
|
||||||
// element's own rect is one box whether the text inside it
|
|
||||||
// wrapped or not. A Range yields a rect per contained node as
|
|
||||||
// well as per line, so it is the distinct tops that count:
|
|
||||||
// a copyable span and the text inside it share one.
|
|
||||||
const range = document.createRange();
|
|
||||||
range.selectNodeContents(el);
|
|
||||||
const tops = new Set(
|
|
||||||
Array.from(range.getClientRects()).map((r) =>
|
|
||||||
Math.round(r.top),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
rows.push({
|
|
||||||
text: el.innerText.trim(),
|
|
||||||
lineBoxes: tops.size,
|
|
||||||
overflow: el.scrollWidth - el.clientWidth,
|
|
||||||
overhang: Math.round(box.right - appRight),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
rows,
|
|
||||||
pageOverflow:
|
|
||||||
document.documentElement.scrollWidth -
|
|
||||||
document.documentElement.clientWidth,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertAddressesFit(page, where) {
|
|
||||||
const report = await addressRowReport(page);
|
|
||||||
assert(
|
|
||||||
report.rows.length > 0,
|
|
||||||
where + ": no address rows were rendered, so nothing was measured",
|
|
||||||
);
|
|
||||||
for (const row of report.rows) {
|
|
||||||
assert(
|
|
||||||
/^0x[0-9a-fA-F]{40}$/.test(row.text),
|
|
||||||
where +
|
|
||||||
": the address is not shown whole: " +
|
|
||||||
JSON.stringify(row.text),
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
row.lineBoxes === 1,
|
|
||||||
where +
|
|
||||||
": " +
|
|
||||||
row.text +
|
|
||||||
" wrapped onto " +
|
|
||||||
row.lineBoxes +
|
|
||||||
" lines",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
row.overflow <= 1,
|
|
||||||
where +
|
|
||||||
": " +
|
|
||||||
row.text +
|
|
||||||
" is " +
|
|
||||||
row.overflow +
|
|
||||||
"px wider than the row holding it",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
row.overhang <= 1,
|
|
||||||
where +
|
|
||||||
": " +
|
|
||||||
row.text +
|
|
||||||
" reaches " +
|
|
||||||
row.overhang +
|
|
||||||
"px past the popup's content box",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert(
|
|
||||||
report.pageOverflow <= 0,
|
|
||||||
where + ": the popup scrolls sideways by " + report.pageOverflow + "px",
|
|
||||||
);
|
|
||||||
return report.rows.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back to Home from wherever the suite above finished, without assuming
|
|
||||||
// which screen that was. Every screen the popup can rest on has a Back
|
|
||||||
// button, and Home has none, so unwinding until Home shows is the one
|
|
||||||
// route that does not depend on the order of the tests before this point.
|
|
||||||
async function unwindToHome(page) {
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
if (await page.isVisible("#view-main")) return;
|
|
||||||
const back = page
|
|
||||||
.locator(".view:not(.hidden) button", { hasText: "Back" })
|
|
||||||
.first();
|
|
||||||
if ((await back.count()) === 0) break;
|
|
||||||
await back.click();
|
|
||||||
await page.waitForTimeout(150);
|
|
||||||
}
|
|
||||||
await visible(page, "#view-main");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The reproduction from the issue: a wallet holding more than one address.
|
|
||||||
// Every address in the list is a full 42 characters competing with the
|
|
||||||
// [info] and [x] controls for one row's width, which is the state the
|
|
||||||
// wallet view was reported wrapping in.
|
|
||||||
test("a wallet with two addresses lists both in full, unwrapped (#380)", async (env) => {
|
|
||||||
await unwindToHome(env.page);
|
|
||||||
|
|
||||||
const before = await env.page
|
|
||||||
.locator("#wallet-list .btn-addr-info")
|
|
||||||
.count();
|
|
||||||
await env.page.locator("#wallet-list .btn-add-address").first().click();
|
|
||||||
await env.page.waitForFunction(
|
|
||||||
(n) =>
|
|
||||||
document.querySelectorAll("#wallet-list .btn-addr-info").length > n,
|
|
||||||
before,
|
|
||||||
{ timeout: 60000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const shown = await assertAddressesFit(env.page, "the wallet list");
|
|
||||||
assert(
|
|
||||||
shown >= before + 1,
|
|
||||||
"the wallet list measured " +
|
|
||||||
shown +
|
|
||||||
" addresses, fewer than the " +
|
|
||||||
(before + 1) +
|
|
||||||
" it now holds",
|
|
||||||
);
|
|
||||||
|
|
||||||
// The [x] control only exists on a wallet holding more than one
|
|
||||||
// address, so its presence is also the proof the second one landed.
|
|
||||||
const removable = await env.page
|
|
||||||
.locator("#wallet-list .btn-remove-address")
|
|
||||||
.count();
|
|
||||||
assert(removable > 0, "the second address did not reach the wallet list");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("every common view shows its addresses in full on one line (#380)", async (env) => {
|
|
||||||
await unwindToHome(env.page);
|
|
||||||
await assertAddressesFit(env.page, "Home");
|
|
||||||
|
|
||||||
await env.page.locator("#wallet-list .btn-addr-info").first().click();
|
|
||||||
await visible(env.page, "#view-address");
|
|
||||||
await visible(env.page, "#tx-list .tx-row");
|
|
||||||
await assertAddressesFit(env.page, "the address screen");
|
|
||||||
|
|
||||||
await env.page.click("#btn-receive");
|
|
||||||
await visible(env.page, "#view-receive");
|
|
||||||
await assertAddressesFit(env.page, "the receive screen");
|
|
||||||
await env.page.click("#btn-receive-back");
|
|
||||||
await visible(env.page, "#view-address");
|
|
||||||
|
|
||||||
await env.page.click("#btn-send");
|
|
||||||
await visible(env.page, "#view-send");
|
|
||||||
await assertAddressesFit(env.page, "the send screen");
|
|
||||||
await env.page.click("#btn-send-back");
|
|
||||||
await visible(env.page, "#view-address");
|
|
||||||
|
|
||||||
// The transaction detail screen carries the narrowest address rows in
|
|
||||||
// the popup: its fields sit inside a well that takes another 24px of
|
|
||||||
// padding and 8px of margin off the content width, and the token
|
|
||||||
// contract row there is narrower still.
|
|
||||||
await env.page.locator("#address-balances .balance-row").first().click();
|
|
||||||
await visible(env.page, "#view-address-token");
|
|
||||||
await assertAddressesFit(env.page, "the token screen");
|
|
||||||
await env.page.click("#btn-address-token-back");
|
|
||||||
await visible(env.page, "#view-address");
|
|
||||||
|
|
||||||
await env.page.locator("#tx-list .tx-row").first().click();
|
|
||||||
await visible(env.page, "#view-transaction");
|
|
||||||
await visible(env.page, "#tx-detail-token-contract-section");
|
|
||||||
await assertAddressesFit(env.page, "the transaction detail screen");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- runner
|
// ---------------------------------------------------------------- runner
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
@@ -47,12 +47,6 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
|
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
|
||||||
const ROOT = path.join(__dirname, "..");
|
|
||||||
|
|
||||||
// The sizes both stores and both toolbars ask for.
|
|
||||||
const EXPECTED_ICON_SIZES = ["16", "32", "48", "128"];
|
|
||||||
|
|
||||||
const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex");
|
|
||||||
|
|
||||||
const EXPECTED_DIRECTIVES = {
|
const EXPECTED_DIRECTIVES = {
|
||||||
"default-src": ["'self'"],
|
"default-src": ["'self'"],
|
||||||
@@ -121,51 +115,6 @@ function assertPolicy(policy) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The declared icons, in both manifests.
|
|
||||||
//
|
|
||||||
// Without an "icons" block a browser draws a generic puzzle piece in the
|
|
||||||
// toolbar for this extension, which is both the first thing the user sees and
|
|
||||||
// how a real extension is told apart from a look-alike. Declaring one is not
|
|
||||||
// enough on its own: an entry naming a file that is not in the tree ships a
|
|
||||||
// reference to nothing, so the referenced bytes are read here and required to
|
|
||||||
// be a PNG of the size the entry claims. build.js copies these into each
|
|
||||||
// browser directory, relative to it, and script/lib/package.js then refuses to
|
|
||||||
// build an archive that does not contain everything the manifest names.
|
|
||||||
function assertIcons(target) {
|
|
||||||
const icons = readManifest(target).icons;
|
|
||||||
expect(Object.keys(icons).sort()).toEqual(EXPECTED_ICON_SIZES.sort());
|
|
||||||
for (const size of EXPECTED_ICON_SIZES) {
|
|
||||||
const ref = icons[size];
|
|
||||||
expect([size, ref]).toEqual([size, `icons/icon${size}.png`]);
|
|
||||||
|
|
||||||
const bytes = fs.readFileSync(path.join(ROOT, ref));
|
|
||||||
expect(bytes.subarray(0, 8)).toEqual(PNG_SIGNATURE);
|
|
||||||
// IHDR width and height, at fixed offsets right after the signature
|
|
||||||
// and the chunk header.
|
|
||||||
expect([ref, bytes.readUInt32BE(16), bytes.readUInt32BE(20)]).toEqual([
|
|
||||||
ref,
|
|
||||||
Number(size),
|
|
||||||
Number(size),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("declared icons", () => {
|
|
||||||
test("chrome declares real icons at every size", () => {
|
|
||||||
assertIcons("chrome");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("firefox declares real icons at every size", () => {
|
|
||||||
assertIcons("firefox");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("both targets declare the same icons", () => {
|
|
||||||
expect(readManifest("firefox").icons).toEqual(
|
|
||||||
readManifest("chrome").icons,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("shipped Content Security Policy", () => {
|
describe("shipped Content Security Policy", () => {
|
||||||
// MV3 takes an object and applies extension_pages to the popup and the
|
// MV3 takes an object and applies extension_pages to the popup and the
|
||||||
// background service worker, which is where libsodium runs.
|
// background service worker, which is where libsodium runs.
|
||||||
|
|||||||
@@ -90,26 +90,6 @@ describe("archive self-containment", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toolbar icons are the other file the manifest names and no bundler
|
|
||||||
// emits, so an archive built without them would carry a manifest whose
|
|
||||||
// "icons" resolve to nothing and a browser would fall back to a generic
|
|
||||||
// placeholder without saying so.
|
|
||||||
test("a manifest naming an icon that is not in the archive fails", () => {
|
|
||||||
const { members, read } = archiveOf({
|
|
||||||
"manifest.json": JSON.stringify({
|
|
||||||
...MINIMAL_MANIFEST,
|
|
||||||
icons: { 16: "icons/icon16.png", 128: "icons/icon128.png" },
|
|
||||||
}),
|
|
||||||
"src/popup/index.html": "<html></html>",
|
|
||||||
"src/popup/index.js": "//",
|
|
||||||
"src/background/index.js": "//",
|
|
||||||
"icons/icon16.png": "PNG",
|
|
||||||
});
|
|
||||||
expect(() => checkSelfContained("chrome", members, read)).toThrow(
|
|
||||||
/would not be self-contained.*icons\/icon128\.png/s,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an archive with no manifest.json at its root fails", () => {
|
test("an archive with no manifest.json at its root fails", () => {
|
||||||
const { members, read } = archiveOf({ "src/popup/index.js": "//" });
|
const { members, read } = archiveOf({ "src/popup/index.js": "//" });
|
||||||
expect(() => checkSelfContained("chrome", members, read)).toThrow(
|
expect(() => checkSelfContained("chrome", members, read)).toThrow(
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
// A persisted container that is checked while its ENTRIES are dereferenced
|
|
||||||
// unchecked (https://git.eeqj.de/sneak/AutistMask/issues/362).
|
|
||||||
//
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/311 settled the idiom — floor the
|
|
||||||
// container AND its entries, dropping anything that cannot be safely
|
|
||||||
// dereferenced — and applied it to trackedTokens and tokenBalances. These are
|
|
||||||
// the fields it did not reach.
|
|
||||||
//
|
|
||||||
// allowedSites is the worst shape in the codebase, and it is what the boot
|
|
||||||
// tests below measure: a stored `{"0x…": "notalist"}` passes the gate, renders
|
|
||||||
// a WORKING popup, and then throws `base.map is not a function` inside
|
|
||||||
// saveState()'s merge — so every save from then on fails while the UI looks
|
|
||||||
// entirely healthy and the user goes on operating a wallet that is persisting
|
|
||||||
// nothing. A blank popup is at least visibly broken; this is not. So the
|
|
||||||
// assertion here is never merely "the popup rendered": it is "the popup
|
|
||||||
// rendered AND the write actually landed in storage".
|
|
||||||
//
|
|
||||||
// Observed at ad6aa7b, with the floors below removed:
|
|
||||||
// allowedSites: {"0x…": "notalist"} -> views=["main"], errors=[], and
|
|
||||||
// storage.set NEVER called: the stored record kept no schemaVersion, so
|
|
||||||
// nothing the user did was persisted.
|
|
||||||
// fraudContracts: "0x…" -> renderSendTokenSelect() threw
|
|
||||||
// "(state.fraudContracts || []).map is not a function"
|
|
||||||
// fraudContracts: [42] -> threw "a.toLowerCase is not a
|
|
||||||
// function"
|
|
||||||
// selectedToken: 42 (restoring onto address-token) -> views=[], errors=
|
|
||||||
// ["tokenId.toLowerCase is not a function"] — a blank popup.
|
|
||||||
|
|
||||||
const { normalizePersisted } = require("../src/shared/persistedState");
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
|
||||||
const {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
unversionedValidProfile,
|
|
||||||
ADDRESS,
|
|
||||||
TOKEN_ADDRESS,
|
|
||||||
} = require("./support/popupBoot");
|
|
||||||
|
|
||||||
// One extension page: a fresh module registry over the given storage. state.js
|
|
||||||
// resolves the storage API at require time, so the stub has to be installed
|
|
||||||
// before the module is loaded.
|
|
||||||
function loadStateModule(storage) {
|
|
||||||
jest.resetModules();
|
|
||||||
globalThis.chrome = { storage: { local: storage.local } };
|
|
||||||
return require("../src/shared/state");
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupPopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------------------------------------------------- the floor itself
|
|
||||||
|
|
||||||
describe("the floor under allowedSites and deniedSites", () => {
|
|
||||||
for (const field of ["allowedSites", "deniedSites"]) {
|
|
||||||
test(`${field} that is not a record becomes an empty record`, () => {
|
|
||||||
for (const bad of ["nope", 42, true, [ADDRESS], null]) {
|
|
||||||
expect(normalizePersisted({ [field]: bad })[field]).toEqual({});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test(`an ${field} entry whose value is not a hostname list is dropped`, () => {
|
|
||||||
for (const bad of ["dapp.example", 42, null, { a: 1 }, true]) {
|
|
||||||
expect(
|
|
||||||
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
|
|
||||||
).toEqual({});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test(`a hostname that is not text is dropped from an ${field} entry`, () => {
|
|
||||||
expect(
|
|
||||||
normalizePersisted({
|
|
||||||
[field]: { [ADDRESS]: [42, null, "dapp.example", {}] },
|
|
||||||
})[field],
|
|
||||||
).toEqual({ [ADDRESS]: ["dapp.example"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test(`a real ${field} map survives, copied not shared`, () => {
|
|
||||||
const saved = { [field]: { [ADDRESS]: ["dapp.example"] } };
|
|
||||||
|
|
||||||
const out = normalizePersisted(saved);
|
|
||||||
|
|
||||||
expect(out[field]).toEqual(saved[field]);
|
|
||||||
expect(out[field]).not.toBe(saved[field]);
|
|
||||||
expect(out[field][ADDRESS]).not.toBe(saved[field][ADDRESS]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test(`a good ${field} entry beside a malformed one survives`, () => {
|
|
||||||
const out = normalizePersisted({
|
|
||||||
[field]: { [ADDRESS]: ["dapp.example"], [TOKEN_ADDRESS]: 42 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test(`a stored own "__proto__" key in ${field} is dropped`, () => {
|
|
||||||
// JSON can carry the key. It can never be a wallet address, so it
|
|
||||||
// grants and denies nothing and goes the way of every other key
|
|
||||||
// whose value is unusable; keeping it would only keep a value that
|
|
||||||
// saveState()'s merge hands to the prototype setter on the next
|
|
||||||
// write.
|
|
||||||
const saved = JSON.parse(
|
|
||||||
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
|
|
||||||
);
|
|
||||||
|
|
||||||
const out = normalizePersisted(saved);
|
|
||||||
|
|
||||||
expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype);
|
|
||||||
expect(Object.keys(out[field])).toEqual([]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('a stored own "__proto__" key surviving a save', () => {
|
|
||||||
// The floor writes map keys with defineProperty; saveState()'s merge is one
|
|
||||||
// layer downstream of it and used to write them with plain assignment,
|
|
||||||
// which hands "__proto__" to the prototype setter and records no entry.
|
|
||||||
// networkEndpoints is where a key that is not a known id is deliberately
|
|
||||||
// KEPT, so it is where that undoing shows.
|
|
||||||
test("does not move a prototype or vanish from networkEndpoints", async () => {
|
|
||||||
const profile = unversionedValidProfile();
|
|
||||||
profile.networkEndpoints = JSON.parse(
|
|
||||||
'{"__proto__":{"rpcUrl":"https://kept.invalid"}}',
|
|
||||||
);
|
|
||||||
const storage = makeStorageStub({ autistmask: profile });
|
|
||||||
const { state, loadState, saveState } = loadStateModule(storage);
|
|
||||||
|
|
||||||
await loadState();
|
|
||||||
state.theme = "dark";
|
|
||||||
await saveState();
|
|
||||||
|
|
||||||
// Not compared against Object.prototype by identity: the storage stub
|
|
||||||
// clones through structuredClone, which builds the result in the host
|
|
||||||
// realm, so the two Object.prototypes are different objects.
|
|
||||||
const stored = storage.read("autistmask").networkEndpoints;
|
|
||||||
expect(Object.getPrototypeOf(stored).rpcUrl).toBeUndefined();
|
|
||||||
expect(Object.keys(stored)).toContain("__proto__");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the floor under fraudContracts", () => {
|
|
||||||
test("fraudContracts that is not a list becomes an empty list", () => {
|
|
||||||
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
|
||||||
expect(
|
|
||||||
normalizePersisted({ fraudContracts: bad }).fraudContracts,
|
|
||||||
).toEqual([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a fraudContracts entry that is not text is dropped", () => {
|
|
||||||
expect(
|
|
||||||
normalizePersisted({
|
|
||||||
fraudContracts: [42, null, TOKEN_ADDRESS, {}, []],
|
|
||||||
}).fraudContracts,
|
|
||||||
).toEqual([TOKEN_ADDRESS]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a real fraudContracts list survives, copied not shared", () => {
|
|
||||||
const saved = { fraudContracts: [TOKEN_ADDRESS] };
|
|
||||||
|
|
||||||
const out = normalizePersisted(saved);
|
|
||||||
|
|
||||||
expect(out.fraudContracts).toEqual(saved.fraudContracts);
|
|
||||||
expect(out.fraudContracts).not.toBe(saved.fraudContracts);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the floor under selectedToken", () => {
|
|
||||||
// Found by the sweep for this defect class, not named in the issue: the
|
|
||||||
// restore gate in src/popup/viewRouter.js checks truthiness only, and both
|
|
||||||
// src/popup/views/addressToken.js and src/popup/views/receive.js then
|
|
||||||
// dereference it as text.
|
|
||||||
test("a selectedToken that is not text becomes null", () => {
|
|
||||||
for (const bad of [42, true, { a: 1 }, [TOKEN_ADDRESS]]) {
|
|
||||||
expect(
|
|
||||||
normalizePersisted({ selectedToken: bad }).selectedToken,
|
|
||||||
).toBeNull();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a real selectedToken survives; the empty string becomes null", () => {
|
|
||||||
expect(
|
|
||||||
normalizePersisted({ selectedToken: TOKEN_ADDRESS }).selectedToken,
|
|
||||||
).toBe(TOKEN_ADDRESS);
|
|
||||||
expect(normalizePersisted({ selectedToken: "ETH" }).selectedToken).toBe(
|
|
||||||
"ETH",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
normalizePersisted({ selectedToken: "" }).selectedToken,
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ----------------------------------------- what the user actually gets
|
|
||||||
|
|
||||||
describe("a malformed allowedSites entry", () => {
|
|
||||||
const MALFORMED = [
|
|
||||||
{ name: "a string", value: "notalist" },
|
|
||||||
{ name: "a number", value: 42 },
|
|
||||||
{ name: "a record", value: { hostnames: ["dapp.example"] } },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { name, value } of MALFORMED) {
|
|
||||||
test(`whose value is ${name}: a working popup whose writes persist`, async () => {
|
|
||||||
const env = await bootPopup(
|
|
||||||
unversionedValidProfile({
|
|
||||||
allowedSites: { [ADDRESS]: value },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect({
|
|
||||||
visibleViews: env.visibleViews(),
|
|
||||||
errors: env.pageErrors,
|
|
||||||
}).toEqual({ visibleViews: ["main"], errors: [] });
|
|
||||||
|
|
||||||
// The half that matters. A popup that renders and never persists
|
|
||||||
// again is worse than one that renders nothing, because nothing
|
|
||||||
// tells the user. The version stamp is proof a write landed: it
|
|
||||||
// is absent from the stored record until saveState() writes one.
|
|
||||||
expect(env.storage.set).toHaveBeenCalled();
|
|
||||||
const stored = env.storage.read("autistmask");
|
|
||||||
expect(stored.schemaVersion).toBe(1);
|
|
||||||
expect(stored.wallets[0].encryptedSecret).toBe(
|
|
||||||
"encrypted-secret-1",
|
|
||||||
);
|
|
||||||
expect(stored.allowedSites).toEqual({});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("the well-formed entries beside it keep working", async () => {
|
|
||||||
const env = await bootPopup(
|
|
||||||
unversionedValidProfile({
|
|
||||||
allowedSites: {
|
|
||||||
[ADDRESS]: ["dapp.example"],
|
|
||||||
[TOKEN_ADDRESS]: "notalist",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(env.pageErrors).toEqual([]);
|
|
||||||
expect(env.storage.read("autistmask").allowedSites).toEqual({
|
|
||||||
[ADDRESS]: ["dapp.example"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a later save still lands, not just the first", async () => {
|
|
||||||
// The failure this closes was in the MERGE, which runs on every save
|
|
||||||
// against whatever is in storage at the time. One write landing is not
|
|
||||||
// enough: the field has to stay mergeable.
|
|
||||||
const storage = makeStorageStub({
|
|
||||||
autistmask: unversionedValidProfile({
|
|
||||||
allowedSites: { [ADDRESS]: "notalist" },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const { state, loadState, saveState } = loadStateModule(storage);
|
|
||||||
|
|
||||||
await loadState();
|
|
||||||
state.theme = "dark";
|
|
||||||
await saveState();
|
|
||||||
state.utcTimestamps = true;
|
|
||||||
await saveState();
|
|
||||||
|
|
||||||
const stored = storage.read("autistmask");
|
|
||||||
expect(stored.theme).toBe("dark");
|
|
||||||
expect(stored.utcTimestamps).toBe(true);
|
|
||||||
expect(stored.allowedSites).toEqual({});
|
|
||||||
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a malformed fraudContracts", () => {
|
|
||||||
// The send screen, which is where this one lands: the boot path only
|
|
||||||
// reaches fraudContracts through loadHomeTxs(), which catches, so the
|
|
||||||
// consequence is an unusable send screen rather than silent data loss.
|
|
||||||
function stubSendDocument() {
|
|
||||||
const select = { innerHTML: "", children: [] };
|
|
||||||
select.appendChild = (child) => select.children.push(child);
|
|
||||||
globalThis.document = {
|
|
||||||
getElementById: (id) => (id === "send-token" ? select : null),
|
|
||||||
createElement: () => ({ value: "", textContent: "" }),
|
|
||||||
};
|
|
||||||
return select;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HELD = {
|
|
||||||
address: TOKEN_ADDRESS,
|
|
||||||
symbol: "AAA",
|
|
||||||
decimals: 18,
|
|
||||||
balance: "12.5",
|
|
||||||
holders: 50000,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function sendScreenTokens(fraudContracts) {
|
|
||||||
const storage = makeStorageStub({
|
|
||||||
autistmask: unversionedValidProfile({ fraudContracts }),
|
|
||||||
});
|
|
||||||
const { loadState } = loadStateModule(storage);
|
|
||||||
await loadState();
|
|
||||||
const select = stubSendDocument();
|
|
||||||
const { renderSendTokenSelect } = require("../src/popup/views/send");
|
|
||||||
|
|
||||||
renderSendTokenSelect({ address: ADDRESS, tokenBalances: [HELD] });
|
|
||||||
|
|
||||||
return select.children.map((opt) => opt.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const bad of ["notalist", 42, { a: 1 }, [42], [null], [{}]]) {
|
|
||||||
test(`${JSON.stringify(bad)}: a usable send screen`, async () => {
|
|
||||||
await expect(sendScreenTokens(bad)).resolves.toEqual([
|
|
||||||
TOKEN_ADDRESS,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a real fraud entry beside a malformed one still hides its token", async () => {
|
|
||||||
await expect(
|
|
||||||
sendScreenTokens([42, TOKEN_ADDRESS.toLowerCase()]),
|
|
||||||
).resolves.toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a malformed selectedToken", () => {
|
|
||||||
test("does not blank the popup on restore", async () => {
|
|
||||||
const env = await bootPopup(
|
|
||||||
unversionedValidProfile({
|
|
||||||
currentView: "address-token",
|
|
||||||
selectedWallet: 0,
|
|
||||||
selectedAddress: 0,
|
|
||||||
selectedToken: 42,
|
|
||||||
viewStack: ["main", "address"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect({
|
|
||||||
visibleViews: env.visibleViews(),
|
|
||||||
errors: env.pageErrors,
|
|
||||||
}).toEqual({ visibleViews: ["main"], errors: [] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------------------------------------------- a save that fails is told
|
|
||||||
|
|
||||||
describe("a save that fails", () => {
|
|
||||||
function failingStorage(profile) {
|
|
||||||
const storage = makeStorageStub({ autistmask: profile });
|
|
||||||
const realSet = storage.local.set;
|
|
||||||
storage.local.set = jest.fn(async () => {
|
|
||||||
throw new Error("QUOTA_BYTES quota exceeded");
|
|
||||||
});
|
|
||||||
storage.restoreWrites = () => {
|
|
||||||
storage.local.set = realSet;
|
|
||||||
};
|
|
||||||
return storage;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("is reported, not swallowed by the save queue", async () => {
|
|
||||||
const storage = failingStorage(unversionedValidProfile());
|
|
||||||
const { state, loadState, saveState, onSaveFailure } =
|
|
||||||
loadStateModule(storage);
|
|
||||||
const failures = [];
|
|
||||||
onSaveFailure((e) => failures.push(String(e && e.message)));
|
|
||||||
|
|
||||||
await loadState();
|
|
||||||
state.theme = "dark";
|
|
||||||
// Not awaited, which is how showView() saves on every navigation and
|
|
||||||
// how the failure used to disappear entirely.
|
|
||||||
saveState();
|
|
||||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
||||||
|
|
||||||
expect(failures).toEqual(["QUOTA_BYTES quota exceeded"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("still rejects for a caller that awaits it", async () => {
|
|
||||||
const storage = failingStorage(unversionedValidProfile());
|
|
||||||
const { state, loadState, saveState, onSaveFailure } =
|
|
||||||
loadStateModule(storage);
|
|
||||||
onSaveFailure(() => {});
|
|
||||||
|
|
||||||
await loadState();
|
|
||||||
state.theme = "dark";
|
|
||||||
|
|
||||||
await expect(saveState()).rejects.toThrow("QUOTA_BYTES");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("puts a banner on the popup saying nothing is being saved", async () => {
|
|
||||||
const env = await bootPopup(undefined, {
|
|
||||||
storage: failingStorage(unversionedValidProfile()),
|
|
||||||
});
|
|
||||||
|
|
||||||
// The popup is still usable — the point is that it no longer looks
|
|
||||||
// healthy while silently persisting nothing.
|
|
||||||
expect(env.visibleViews()).toEqual(["main"]);
|
|
||||||
const banner = env.node("save-failure-banner");
|
|
||||||
expect(banner).not.toBeNull();
|
|
||||||
expect(banner.textContent).toContain("NOT SAVED");
|
|
||||||
expect(banner.textContent).toContain("QUOTA_BYTES quota exceeded");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("no banner appears on a popup whose saves work", async () => {
|
|
||||||
const env = await bootPopup(unversionedValidProfile());
|
|
||||||
|
|
||||||
expect(env.node("save-failure-banner")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,864 +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, ONTO EVERY RESTORABLE
|
|
||||||
// VIEW. Home is not where this class of defect lives.
|
|
||||||
//
|
|
||||||
// Every row is driven through a boot regardless of kind, but only a LOOSE row
|
|
||||||
// (or a row that sets `alsoSweep`) is swept across the restore path: that is
|
|
||||||
// what declaring LOOSE costs. ENTRIES and SCALAR rows are proven by their
|
|
||||||
// holds() instead, because a floored value is not hostile by the time a
|
|
||||||
// renderer sees it. A LOOSE row must additionally prove it is loose: if
|
|
||||||
// someone floors the field — even partially — 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 sweep is what makes a LOOSE row falsifiable, so read how it is driven
|
|
||||||
// before trusting it. A row the ROUTER reads (`routes`) gets its own boot per
|
|
||||||
// view, because a hostile value in it legitimately changes which view renders.
|
|
||||||
// Every other swept field is corrupted on the SAME boot, one boot per view per
|
|
||||||
// slot, and that boot has to land on the view it stored — so a field that does
|
|
||||||
// move the routing cannot hide in the crowd. Every swept field is driven at
|
|
||||||
// BOTH POLARITIES: a value nothing in src/ writes is a wrong-typed one and so
|
|
||||||
// always truthy, which leaves `if (!state.x) { state.y.deref() }` unentered on
|
|
||||||
// the very boot that corrupts x. The last slot is the falsy one for that
|
|
||||||
// reason, and a field that cannot be falsy after the floor says so in its row
|
|
||||||
// and is proven so.
|
|
||||||
//
|
|
||||||
// READ THE CLAIM NARROWLY. What this file proves is: NO STRUCTURAL
|
|
||||||
// DEREFERENCE ON THE CODE PATHS A WHOLLY-CORRUPTED PROFILE TAKES. That is not
|
|
||||||
// every path a stored record takes, and the difference is the whole of what
|
|
||||||
// this file does not cover:
|
|
||||||
//
|
|
||||||
// - Only the values in the table, in the SLOT arrangement below: four value
|
|
||||||
// combinations per view, not the product of twelve fields. A dereference
|
|
||||||
// reached only under a pairing no slot produces is not driven at all.
|
|
||||||
// - Only what a stored record reaches by ITSELF. A view only forward
|
|
||||||
// navigation opens, and anything behind a click, is not driven.
|
|
||||||
// - Nothing about the paths a HEALTHY profile takes, which is most of the
|
|
||||||
// popup. This file is a floor under one defect class, not a proof about
|
|
||||||
// the renderers.
|
|
||||||
//
|
|
||||||
// Within that boundary it is unconditional: if one of these boots leaves the
|
|
||||||
// popup unhealthy or off the view it stored, this file goes red — including
|
|
||||||
// when it takes two corrupted fields at once, because the verdict is the
|
|
||||||
// combined boot itself and the per-field re-boot below can only decorate the
|
|
||||||
// message. That last part is the one thing an earlier version got wrong: it
|
|
||||||
// asserted on the per-field list, so an observed dead popup that no single
|
|
||||||
// field reproduced was reported green.
|
|
||||||
//
|
|
||||||
// Booting every field separately at every value would be several hundred boots
|
|
||||||
// and most of the suite's budget; this is forty-four. Widening it further is
|
|
||||||
// out of scope — proving no field is dereferenced on any reachable render path
|
|
||||||
// is exhaustive verification of the popup, not a floor under a stored record.
|
|
||||||
//
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// A row is SWEPT — driven onto every restorable view rather than only onto
|
|
||||||
// Home — when its claim is that no restore path dereferences the field. That
|
|
||||||
// is what LOOSE means. The two index rows opt in with `alsoSweep` although
|
|
||||||
// they are floored, because the restore path is precisely why they gained a
|
|
||||||
// floor and the sweep is the regression guard on it.
|
|
||||||
const swept = (row) => row.kind === KIND.LOOSE || Boolean(row.alsoSweep);
|
|
||||||
|
|
||||||
// Every value a swept row drives through a boot: the hostile set, plus the
|
|
||||||
// falsy slot that gives the field its other polarity. `hostile` values are all
|
|
||||||
// TRUTHY by nature — a value nothing in src/ writes is a wrong-typed one, and
|
|
||||||
// wrong-typed values are objects, non-empty strings and non-zero numbers. A
|
|
||||||
// field that is only ever truthy on the boot that corrupts it cannot falsify
|
|
||||||
// `if (!state.x) { state.y.deref() }`, so the falsy slot is not optional.
|
|
||||||
const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 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 —
|
|
||||||
// and, for a swept row, through one boot per restorable view — so keep the
|
|
||||||
// list short and pointed. `floorOnly` is extra values checked against the
|
|
||||||
// floor alone, which is pure and free. `hostileRestore` is extra values driven
|
|
||||||
// through the restore path only, for a value that means nothing until a
|
|
||||||
// particular branch's gate has let it past.
|
|
||||||
//
|
|
||||||
// `falsy` is the other POLARITY of a swept field, driven for the same reason.
|
|
||||||
// It is not a value src/ never writes — for three of these fields it is the
|
|
||||||
// DEFAULT_STATE default, which is the branch every ordinary install takes —
|
|
||||||
// and that is the point: without it, a dereference behind `if (!state.x)` is
|
|
||||||
// unreachable on the one boot that corrupts x. A swept row that cannot supply
|
|
||||||
// one says `neverFalsy` instead, which is proven rather than asserted: every
|
|
||||||
// falsy value stored under that field comes back TRUTHY from the floor, so no
|
|
||||||
// `!state.x` branch is reachable from a stored record at all.
|
|
||||||
|
|
||||||
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,
|
|
||||||
// SCALAR, and swept anyway: the restore path is precisely why this
|
|
||||||
// field gained a floor, so the sweep is the regression guard on it.
|
|
||||||
alsoSweep: true,
|
|
||||||
routes: true,
|
|
||||||
// A stale INTEGER index, which reaches the restore path by a different
|
|
||||||
// route from the prototype members above — falsy or out of range
|
|
||||||
// rather than truthy — and has to keep being the safe case.
|
|
||||||
hostileRestore: [{ value: "length" }, { value: 5 }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "selectedAddress",
|
|
||||||
kind: KIND.SCALAR,
|
|
||||||
hostile: ["map", "__proto__", { a: 1 }],
|
|
||||||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
|
||||||
holds: isIndexOrNull,
|
|
||||||
alsoSweep: true,
|
|
||||||
routes: true,
|
|
||||||
hostileRestore: [{ value: 5 }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "currentView",
|
|
||||||
kind: KIND.LOOSE,
|
|
||||||
routes: true,
|
|
||||||
// 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 }],
|
|
||||||
// `saved.currentView || null`: the falsy polarity is the popup landing
|
|
||||||
// on Home, which every boot in "booting onto Home" below also drives.
|
|
||||||
falsy: [""],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "viewData",
|
|
||||||
kind: KIND.LOOSE,
|
|
||||||
routes: true,
|
|
||||||
// The container is taken verbatim; what makes its ENTRIES safe is the
|
|
||||||
// per-branch guard in src/popup/viewRouter.js. The sweep drives the
|
|
||||||
// container shapes below onto every restorable view; hostileRestore
|
|
||||||
// adds the records that PASS a branch's gate and then hand its
|
|
||||||
// renderer something it dereferences, which is where the entries are
|
|
||||||
// actually decided.
|
|
||||||
hostile: [42, "notarecord", { a: 1 }, [1, 2]],
|
|
||||||
// `structuredClone(saved.viewData || {})`: the container is never falsy
|
|
||||||
// in state whatever was stored, so no `!state.viewData` branch exists to
|
|
||||||
// drive.
|
|
||||||
neverFalsy: true,
|
|
||||||
hostileRestore: [
|
|
||||||
// success-tx passes on `data.hash`, and renderSuccess() then calls
|
|
||||||
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
|
|
||||||
{ value: { hash: "0x1" }, views: ["success-tx"] },
|
|
||||||
{ value: { hash: "0x1", to: 42 }, views: ["success-tx"] },
|
|
||||||
{
|
|
||||||
value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
|
|
||||||
views: ["success-tx"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: {
|
|
||||||
hash: "0x1",
|
|
||||||
to: ADDRESS,
|
|
||||||
decoded: { details: [{ address: 42 }] },
|
|
||||||
},
|
|
||||||
views: ["success-tx"],
|
|
||||||
},
|
|
||||||
// error-tx passes on `data.message`, same dereference.
|
|
||||||
{ value: { message: "boom" }, views: ["error-tx"] },
|
|
||||||
{ value: { message: "boom", to: 42 }, views: ["error-tx"] },
|
|
||||||
// transaction passes on `data.tx`.
|
|
||||||
{ value: { tx: { hash: "0x1" } }, views: ["transaction"] },
|
|
||||||
{
|
|
||||||
value: {
|
|
||||||
tx: {
|
|
||||||
hash: "0x1",
|
|
||||||
from: ADDRESS,
|
|
||||||
to: ADDRESS,
|
|
||||||
contractAddress: 42,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
views: ["transaction"],
|
|
||||||
},
|
|
||||||
// confirm-tx passes on `data.pendingTx`.
|
|
||||||
{ value: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] },
|
|
||||||
{
|
|
||||||
value: {
|
|
||||||
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.
|
|
||||||
{
|
|
||||||
value: {
|
|
||||||
pendingWait: {
|
|
||||||
hash: "0x1",
|
|
||||||
txInfo: { to: 42, amount: "1" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
views: ["wait-tx"],
|
|
||||||
},
|
|
||||||
// A record that passes EVERY branch's gate at once, driven onto
|
|
||||||
// every restorable view: a branch a view does not read must stay
|
|
||||||
// one it does not read, and each renderer must survive the fields
|
|
||||||
// another branch left behind.
|
|
||||||
{
|
|
||||||
value: {
|
|
||||||
hash: "0x1",
|
|
||||||
message: "boom",
|
|
||||||
tx: { hash: "0x1" },
|
|
||||||
pendingTx: { amount: "1" },
|
|
||||||
pendingWait: { hash: "0x1" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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 }],
|
|
||||||
// `|| 0` collapses every falsy stored value to 0, so 0 IS the whole
|
|
||||||
// falsy polarity of this field — and it is the DEFAULT_STATE default,
|
|
||||||
// the value a profile carries until its first refresh lands.
|
|
||||||
falsy: [0],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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]],
|
|
||||||
// `structuredClone(saved.tokenHolderCache || {})`.
|
|
||||||
neverFalsy: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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 }],
|
|
||||||
// `saved.theme || "system"`.
|
|
||||||
neverFalsy: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "dustThresholdGwei",
|
|
||||||
kind: KIND.LOOSE,
|
|
||||||
hostile: ["notanumber", true, { a: 1 }],
|
|
||||||
// Survives verbatim, so the falsy slot is also wrong-typed: "" reaches
|
|
||||||
// filterTransactions() as a comparand and a settings input .value.
|
|
||||||
falsy: [""],
|
|
||||||
},
|
|
||||||
...[
|
|
||||||
"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 }],
|
|
||||||
// Both answers to that truthiness test have to be driven, and 0 is a
|
|
||||||
// value src/ never writes for a flag. For utcTimestamps and debugMode
|
|
||||||
// the falsy answer is also the DEFAULT_STATE default.
|
|
||||||
falsy: [0],
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
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", () => {
|
|
||||||
// The falsy slot is deliberately NOT in here. `saved.x || default` is a
|
|
||||||
// floor on falsy values and on nothing else, so a falsy value is the one
|
|
||||||
// thing a LOOSE field need not carry through verbatim; what it has to carry
|
|
||||||
// through is being falsy, which "both polarities" below asserts.
|
|
||||||
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`, () => {
|
|
||||||
// EVERY value, not some: a PARTIAL floor is still a floor, and
|
|
||||||
// a row that keeps saying LOOSE because one hostile value out
|
|
||||||
// of three still survives is exactly the stale claim this file
|
|
||||||
// exists to stop.
|
|
||||||
for (const value of values) {
|
|
||||||
const out = normalizePersisted(
|
|
||||||
profileWith(row.field, value),
|
|
||||||
);
|
|
||||||
expect({
|
|
||||||
value: value,
|
|
||||||
survived: JSON.stringify(out[row.field]),
|
|
||||||
}).toEqual({
|
|
||||||
value: value,
|
|
||||||
survived: JSON.stringify(value),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------------------------------------------- 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 };
|
|
||||||
|
|
||||||
// unversionedValidProfile() stores no currentView, so every boot in here lands
|
|
||||||
// on Home. That is the cheap half of the proof; the restore path below is the
|
|
||||||
// half that matters.
|
|
||||||
// Both polarities of every swept field are driven, or the field is proven
|
|
||||||
// unable to take one of them. This is the guard on the sweep itself: a hostile
|
|
||||||
// set is all-truthy by construction, so without a falsy slot a dereference
|
|
||||||
// behind `if (!state.x)` is never reached on the boot that corrupts x — the
|
|
||||||
// same falsy-collapse blind spot the fields below were floored for.
|
|
||||||
describe("both polarities of every swept field are driven", () => {
|
|
||||||
const FALSY_STORED = [0, "", false, null];
|
|
||||||
const floored = (field, value) =>
|
|
||||||
normalizePersisted(profileWith(field, value))[field];
|
|
||||||
|
|
||||||
for (const row of CONTRACT) {
|
|
||||||
if (!swept(row)) continue;
|
|
||||||
|
|
||||||
if (row.neverFalsy) {
|
|
||||||
test(`${row.field}: cannot be falsy in state at all`, () => {
|
|
||||||
for (const value of FALSY_STORED) {
|
|
||||||
expect({
|
|
||||||
stored: value,
|
|
||||||
truthy: Boolean(floored(row.field, value)),
|
|
||||||
}).toEqual({ stored: value, truthy: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
test(`${row.field}: truthy and falsy`, () => {
|
|
||||||
// What the boots below actually drive, floored the way a renderer
|
|
||||||
// sees it — not what the row says it drives.
|
|
||||||
const driven = [
|
|
||||||
...sweptValues(row),
|
|
||||||
...(row.hostileRestore || []).map((entry) => entry.value),
|
|
||||||
].map((value) => floored(row.field, value));
|
|
||||||
|
|
||||||
expect({
|
|
||||||
truthy: driven.some((value) => Boolean(value)),
|
|
||||||
falsy: driven.some((value) => !value),
|
|
||||||
}).toEqual({ truthy: true, falsy: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a hostile value for one field, booting onto Home", () => {
|
|
||||||
for (const row of CONTRACT) {
|
|
||||||
for (const value of sweptValues(row)) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------------------------------------------ driving the restore path
|
|
||||||
|
|
||||||
// Everything above lands on Home. Home is not where this class of defect
|
|
||||||
// lives: all three of the false claims this file replaced were falsified by a
|
|
||||||
// RESTORE, through the unguarded restoreView() in src/popup/index.js. So a
|
|
||||||
// swept row's hostile values are driven onto EVERY restorable view, one boot
|
|
||||||
// each.
|
|
||||||
//
|
|
||||||
// This is what makes a LOOSE row falsifiable. A field that gains a structural
|
|
||||||
// dereference on any restorable view — `state.theme.toLowerCase()` in a view's
|
|
||||||
// show(), say — turns the row red here, instead of waiting for a reviewer to
|
|
||||||
// re-derive the claim by hand.
|
|
||||||
|
|
||||||
// restoreWait() resumes from this, so it has to be a finite number and recent
|
|
||||||
// enough that the resumed deadline has not already passed — a wait that has
|
|
||||||
// outlived its deadline resolves on the first poll instead of staying on
|
|
||||||
// screen. Read once at module load, so every boot in one run shares it.
|
|
||||||
const BROADCAST_TIME = Date.now();
|
|
||||||
|
|
||||||
// A viewData well formed for every restorable branch at once, so the only
|
|
||||||
// thing a swept boot can fail on is the field the row corrupts. "the base
|
|
||||||
// profile the sweep corrupts" below proves this really does render each view
|
|
||||||
// rather than falling back — without that, a sweep could pass by never
|
|
||||||
// reaching a renderer at all.
|
|
||||||
const WELL_FORMED_DATA = {
|
|
||||||
hash: "0x1",
|
|
||||||
message: "boom",
|
|
||||||
to: ADDRESS,
|
|
||||||
decoded: { details: [{ address: TOKEN_ADDRESS }] },
|
|
||||||
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: null },
|
|
||||||
pendingTx: {
|
|
||||||
token: "ETH",
|
|
||||||
from: ADDRESS,
|
|
||||||
to: ADDRESS,
|
|
||||||
amount: "1",
|
|
||||||
balance: "2",
|
|
||||||
},
|
|
||||||
pendingWait: {
|
|
||||||
hash: "0x1",
|
|
||||||
txInfo: { to: ADDRESS, amount: "1" },
|
|
||||||
broadcastTime: BROADCAST_TIME,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function restoringOnto(view, extra) {
|
|
||||||
return unversionedValidProfile({
|
|
||||||
currentView: view,
|
|
||||||
selectedWallet: 0,
|
|
||||||
selectedAddress: 0,
|
|
||||||
selectedToken: TOKEN_ADDRESS,
|
|
||||||
viewStack: ["main"],
|
|
||||||
viewData: WELL_FORMED_DATA,
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// A boot that RESTORED is healthy and landed on the view it stored, rather
|
|
||||||
// than falling back to Home — which a healthy boot also does, and which would
|
|
||||||
// let a sweep pass by never running the renderer it is aimed at.
|
|
||||||
async function restoredHealth(profile, view) {
|
|
||||||
const env = await bootPopup(profile);
|
|
||||||
return {
|
|
||||||
errors: env.pageErrors,
|
|
||||||
restored: env.visibleViews().includes(view),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const RESTORED = { errors: [], restored: true };
|
|
||||||
|
|
||||||
describe("the base profile the sweep corrupts", () => {
|
|
||||||
for (const view of RESTORABLE_VIEWS) {
|
|
||||||
test(`renders ${view} rather than falling back`, async () => {
|
|
||||||
await expect(
|
|
||||||
restoredHealth(restoringOnto(view), view),
|
|
||||||
).resolves.toEqual(RESTORED);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// A field the ROUTER itself reads — the two it gates on and the two
|
|
||||||
// hasValidAddress() indexes with. A hostile value in one of these legitimately
|
|
||||||
// changes which view renders, so each gets its own boot per view and is held
|
|
||||||
// only to "healthy", not to "restored onto the view it stored".
|
|
||||||
const routes = (row) => Boolean(row.routes);
|
|
||||||
|
|
||||||
// Every routing row × every hostile value × every restorable view. Profiles
|
|
||||||
// are deduplicated because a hostile `currentView` REPLACES the view being
|
|
||||||
// restored onto, which would otherwise be the same boot eleven times.
|
|
||||||
describe("a hostile routing value restoring onto", () => {
|
|
||||||
for (const row of CONTRACT) {
|
|
||||||
if (!swept(row) || !routes(row)) continue;
|
|
||||||
const seen = new Set();
|
|
||||||
for (const value of sweptValues(row)) {
|
|
||||||
for (const view of RESTORABLE_VIEWS) {
|
|
||||||
const profile = restoringOnto(view, { [row.field]: value });
|
|
||||||
const key = JSON.stringify(profile);
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
test(`${view}: ${row.field} = ${JSON.stringify(
|
|
||||||
value,
|
|
||||||
)}`, async () => {
|
|
||||||
await expect(bootHealth(profile)).resolves.toEqual(HEALTHY);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Every OTHER swept field, corrupted at once, one boot per view per hostile
|
|
||||||
// slot: twelve fields on one boot rather than twelve boots. A field is only in
|
|
||||||
// here because it is not one the router reads — and that is ASSERTED, not
|
|
||||||
// argued, because the boot has to land on `view`. A field that does move the
|
|
||||||
// routing turns this red and has to declare `routes` and take the individual
|
|
||||||
// sweep above.
|
|
||||||
//
|
|
||||||
// Combining hides one thing, and the last slot is what stops it. A hostile
|
|
||||||
// value is wrong-typed and therefore TRUTHY, so on a boot where every swept
|
|
||||||
// field is hostile, no `if (!state.x)` branch is entered — and a dereference
|
|
||||||
// inside such a branch would go unseen however loudly it throws. The last slot
|
|
||||||
// is the falsy one: every swept field that CAN be falsy is falsy on it, which
|
|
||||||
// is also the state an ordinary install boots in for three of them, while the
|
|
||||||
// fields that cannot be falsy stay hostile-truthy. That makes it a MIX, and a
|
|
||||||
// deliberate one — the interaction between a falsy flag and a still-hostile
|
|
||||||
// theme is a shape a stored record really produces.
|
|
||||||
//
|
|
||||||
// The verdict is the combined boot, always. When it goes red the same view is
|
|
||||||
// re-booted one field at a time, so the failure NAMES a culprit instead of
|
|
||||||
// leaving a reader to bisect twelve fields — but that loop only decorates the
|
|
||||||
// message. It cannot clear the failure. A dereference that needs two corrupted
|
|
||||||
// fields at once is reproduced by neither field alone, and a version of this
|
|
||||||
// file that asserted on the named list reported exactly that case green while
|
|
||||||
// watching the popup die.
|
|
||||||
const UNROUTED = CONTRACT.filter((row) => swept(row) && !routes(row));
|
|
||||||
const HOSTILE_SLOTS = Math.max(
|
|
||||||
...UNROUTED.map((row) => sweptValues(row).length),
|
|
||||||
);
|
|
||||||
|
|
||||||
function unroutedValues(slot) {
|
|
||||||
const fields = {};
|
|
||||||
for (const row of UNROUTED) {
|
|
||||||
const values = sweptValues(row);
|
|
||||||
fields[row.field] = values[slot % values.length];
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("every field the router does not read, corrupted at once, onto", () => {
|
|
||||||
for (const view of RESTORABLE_VIEWS) {
|
|
||||||
for (let slot = 0; slot < HOSTILE_SLOTS; slot++) {
|
|
||||||
test(`${view}: hostile value ${slot + 1} in all ${
|
|
||||||
UNROUTED.length
|
|
||||||
} of them`, async () => {
|
|
||||||
const fields = unroutedValues(slot);
|
|
||||||
const together = await restoredHealth(
|
|
||||||
restoringOnto(view, fields),
|
|
||||||
view,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The per-field re-boot only DECORATES the message. The
|
|
||||||
// verdict is `together`, unconditionally: a dereference that
|
|
||||||
// needs two corrupted fields at once is reproduced by NEITHER
|
|
||||||
// field alone, so an assertion on the named list would report
|
|
||||||
// an observed dead popup as green.
|
|
||||||
const named = [];
|
|
||||||
if (together.errors.length > 0 || !together.restored) {
|
|
||||||
for (const row of UNROUTED) {
|
|
||||||
const one = await restoredHealth(
|
|
||||||
restoringOnto(view, {
|
|
||||||
[row.field]: fields[row.field],
|
|
||||||
}),
|
|
||||||
view,
|
|
||||||
);
|
|
||||||
if (one.errors.length === 0 && one.restored) continue;
|
|
||||||
named.push(
|
|
||||||
`${row.field}=${JSON.stringify(
|
|
||||||
fields[row.field],
|
|
||||||
)}: ` +
|
|
||||||
(one.errors.join("; ") || `fell off ${view}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (named.length === 0) {
|
|
||||||
named.push(
|
|
||||||
"no single field reproduces it; it takes two or " +
|
|
||||||
`more of ${JSON.stringify(fields)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect({
|
|
||||||
view: view,
|
|
||||||
together: together,
|
|
||||||
fields: named,
|
|
||||||
}).toEqual({ view: view, together: RESTORED, fields: [] });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// The values that only mean something on the restore path: a viewData that
|
|
||||||
// PASSES a branch's gate and then hands its renderer something dereferenced,
|
|
||||||
// and the index values whose route through hasValidAddress() differs from the
|
|
||||||
// row's own hostile set.
|
|
||||||
describe("a restore-only hostile value onto", () => {
|
|
||||||
for (const row of CONTRACT) {
|
|
||||||
for (const entry of row.hostileRestore || []) {
|
|
||||||
for (const view of entry.views || RESTORABLE_VIEWS) {
|
|
||||||
test(`${view}: ${row.field} = ${JSON.stringify(
|
|
||||||
entry.value,
|
|
||||||
)}`, async () => {
|
|
||||||
await expect(
|
|
||||||
bootHealth(
|
|
||||||
restoringOnto(view, { [row.field]: entry.value }),
|
|
||||||
),
|
|
||||||
).resolves.toEqual(HEALTHY);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -35,11 +35,6 @@ const POPUP_HTML_PATH = path.join(POPUP_DIR, "index.html");
|
|||||||
const RUNTIME_CREATED_IDS = new Set([
|
const RUNTIME_CREATED_IDS = new Set([
|
||||||
// Created by updateDebugBanner() in src/popup/views/helpers.js.
|
// Created by updateDebugBanner() in src/popup/views/helpers.js.
|
||||||
"debug-banner",
|
"debug-banner",
|
||||||
// Created by showSaveFailureBanner() in the same file, on the first save
|
|
||||||
// that fails. Absent from the markup on purpose: a popup where nothing has
|
|
||||||
// failed must not have to carry an empty banner
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362).
|
|
||||||
"save-failure-banner",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Every id lookup the popup performs with a literal argument, as
|
// Every id lookup the popup performs with a literal argument, as
|
||||||
|
|||||||
@@ -13,26 +13,61 @@
|
|||||||
// calls, is exactly the defect: what has to be true is that BOOTING the popup
|
// calls, is exactly the defect: what has to be true is that BOOTING the popup
|
||||||
// on a bad blob lands on it.
|
// on a bad blob lands on it.
|
||||||
//
|
//
|
||||||
// The boot harness and its DOM stub — built FROM src/popup/index.html, so
|
// The DOM stub is built FROM src/popup/index.html — every id in the markup,
|
||||||
// "which views are visible" is answered against the real element set — live in
|
// with the classes the markup gives it — so "which views are visible" is
|
||||||
// tests/support/popupBoot.js, since tests/persistedEntryFloors.test.js needs
|
// answered against the real element set, and a recovery screen with no markup
|
||||||
// the same boot.
|
// behind it cannot pass.
|
||||||
//
|
//
|
||||||
// The fourth case is the upgrade one, and it is the case that must NOT reach
|
// The fourth case is the upgrade one, and it is the case that must NOT reach
|
||||||
// the recovery screen: every install in the field has a valid profile with no
|
// the recovery screen: every install in the field has a valid profile with no
|
||||||
// version field, and showing those users a wipe prompt would be a worse defect
|
// version field, and showing those users a wipe prompt would be a worse defect
|
||||||
// than the one being fixed. It is migrated in place and keeps working.
|
// than the one being fixed. It is migrated in place and keeps working.
|
||||||
|
|
||||||
const {
|
const fs = require("fs");
|
||||||
bootPopup,
|
const path = require("path");
|
||||||
cleanupPopup,
|
|
||||||
unversionedValidProfile,
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
ADDRESS,
|
|
||||||
TOKEN_ADDRESS,
|
const POPUP_HTML = fs.readFileSync(
|
||||||
} = require("./support/popupBoot");
|
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fixed address, never used for anything but these tests.
|
||||||
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
// A fixed ERC-20 contract address, same rule.
|
||||||
|
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||||
|
|
||||||
// ------------------------------------------------------------- fixtures
|
// ------------------------------------------------------------- fixtures
|
||||||
|
|
||||||
|
// A profile in the shape every install in the field has it: complete, valid,
|
||||||
|
// and carrying no version field, because no build ever wrote one.
|
||||||
|
function unversionedValidProfile() {
|
||||||
|
return {
|
||||||
|
hasWallet: true,
|
||||||
|
wallets: [
|
||||||
|
{
|
||||||
|
type: "hd",
|
||||||
|
name: "Wallet 1",
|
||||||
|
xpub: "xpub-wallet-1",
|
||||||
|
encryptedSecret: "encrypted-secret-1",
|
||||||
|
nextIndex: 1,
|
||||||
|
addresses: [
|
||||||
|
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeAddress: ADDRESS,
|
||||||
|
networkId: "mainnet",
|
||||||
|
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
||||||
|
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
||||||
|
allowedSites: { [ADDRESS]: ["dapp.example"] },
|
||||||
|
deniedSites: {},
|
||||||
|
trackedTokens: [],
|
||||||
|
theme: "system",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// The three blobs from the issue, each with the error it produced.
|
// The three blobs from the issue, each with the error it produced.
|
||||||
const CORRUPT_BLOBS = [
|
const CORRUPT_BLOBS = [
|
||||||
{
|
{
|
||||||
@@ -68,7 +103,235 @@ const CORRUPT_BLOBS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
afterEach(cleanupPopup);
|
// ------------------------------------------------------------- DOM stub
|
||||||
|
|
||||||
|
function makeElement(id, className) {
|
||||||
|
const classes = new Set(
|
||||||
|
(className || "").split(/\s+/).filter((name) => name !== ""),
|
||||||
|
);
|
||||||
|
const el = {
|
||||||
|
id,
|
||||||
|
tagName: "DIV",
|
||||||
|
textContent: "",
|
||||||
|
value: "",
|
||||||
|
innerHTML: "",
|
||||||
|
href: "",
|
||||||
|
download: "",
|
||||||
|
disabled: false,
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
listeners: {},
|
||||||
|
clicked: 0,
|
||||||
|
classList: {
|
||||||
|
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||||
|
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||||
|
contains: (n) => classes.has(n),
|
||||||
|
toggle: (n, force) => {
|
||||||
|
const on = force === undefined ? !classes.has(n) : force;
|
||||||
|
if (on) classes.add(n);
|
||||||
|
else classes.delete(n);
|
||||||
|
return on;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addEventListener: (name, fn) => {
|
||||||
|
el.listeners[name] = el.listeners[name] || [];
|
||||||
|
el.listeners[name].push(fn);
|
||||||
|
},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
appendChild: () => {},
|
||||||
|
remove: () => {},
|
||||||
|
focus: () => {},
|
||||||
|
select: () => {},
|
||||||
|
setAttribute: (name, value) => {
|
||||||
|
el[name] = value;
|
||||||
|
},
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
click: () => {
|
||||||
|
el.clicked += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every id in the markup, with the classes the markup gives it. A view the
|
||||||
|
// popup is supposed to reveal has to exist here, which means it has to exist
|
||||||
|
// in src/popup/index.html.
|
||||||
|
function idsFromHtml(html) {
|
||||||
|
const out = new Map();
|
||||||
|
const tags = html.match(/<[a-zA-Z][^>]*>/g) || [];
|
||||||
|
for (const tag of tags) {
|
||||||
|
const id = /\bid="([^"]+)"/.exec(tag);
|
||||||
|
if (!id) continue;
|
||||||
|
const cls = /\bclass="([^"]*)"/.exec(tag);
|
||||||
|
out.set(id[1], cls ? cls[1] : "");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDocument(html) {
|
||||||
|
const authored = idsFromHtml(html);
|
||||||
|
const els = new Map();
|
||||||
|
for (const [id, className] of authored) {
|
||||||
|
els.set(id, makeElement(id, className));
|
||||||
|
}
|
||||||
|
const created = [];
|
||||||
|
const doc = {
|
||||||
|
listeners: {},
|
||||||
|
getElementById(id) {
|
||||||
|
// Created on demand by updateDebugBanner(); absent is the state a
|
||||||
|
// non-debug, non-testnet popup is in.
|
||||||
|
if (id === "debug-banner") return null;
|
||||||
|
if (!els.has(id)) els.set(id, makeElement(id, ""));
|
||||||
|
return els.get(id);
|
||||||
|
},
|
||||||
|
createElement(tag) {
|
||||||
|
const el = makeElement("created-" + tag, "");
|
||||||
|
el.tagName = String(tag).toUpperCase();
|
||||||
|
created.push(el);
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
addEventListener(name, fn) {
|
||||||
|
doc.listeners[name] = doc.listeners[name] || [];
|
||||||
|
doc.listeners[name].push(fn);
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
documentElement: makeElement("html", ""),
|
||||||
|
body: {
|
||||||
|
prepend: () => {},
|
||||||
|
appendChild: () => {},
|
||||||
|
removeChild: () => {},
|
||||||
|
},
|
||||||
|
elements: els,
|
||||||
|
authoredIds: authored,
|
||||||
|
created,
|
||||||
|
};
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- harness
|
||||||
|
|
||||||
|
// Boot the real popup entry point over `stored`, exactly as the browser does:
|
||||||
|
// storage already holds the record, the page loads, DOMContentLoaded fires.
|
||||||
|
async function bootPopup(stored) {
|
||||||
|
jest.resetModules();
|
||||||
|
|
||||||
|
// The two modules that reach the network. Neither is on the path under
|
||||||
|
// test; both would make this suite hit the internet.
|
||||||
|
jest.doMock("../src/shared/prices", () => ({
|
||||||
|
prices: {},
|
||||||
|
refreshPrices: jest.fn(async () => {}),
|
||||||
|
clearPrices: jest.fn(),
|
||||||
|
getPrice: () => null,
|
||||||
|
formatUsd: () => "",
|
||||||
|
formatAddressTotal: () => "",
|
||||||
|
getAddressValue: () => ({ usd: null, partial: false }),
|
||||||
|
getWalletValue: () => ({ usd: null, partial: false }),
|
||||||
|
getTotalValue: () => ({ usd: null, partial: false }),
|
||||||
|
}));
|
||||||
|
jest.doMock("../src/shared/balances", () => ({
|
||||||
|
fetchTokenBalances: jest.fn(async () => []),
|
||||||
|
refreshBalances: jest.fn(async () => {}),
|
||||||
|
lookupTokenInfo: jest.fn(async () => null),
|
||||||
|
getProvider: () => ({}),
|
||||||
|
scanForAddresses: jest.fn(async () => []),
|
||||||
|
}));
|
||||||
|
jest.doMock("../src/shared/transactions", () => ({
|
||||||
|
fetchRecentTransactions: jest.fn(async () => []),
|
||||||
|
filterTransactions: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const storage = makeStorageStub(
|
||||||
|
stored === undefined ? {} : { autistmask: stored },
|
||||||
|
);
|
||||||
|
const document = makeDocument(POPUP_HTML);
|
||||||
|
const reloads = [];
|
||||||
|
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: storage.local },
|
||||||
|
runtime: {
|
||||||
|
sendMessage: jest.fn(async () => ({})),
|
||||||
|
getURL: (p) => "chrome-extension://autistmask/" + p,
|
||||||
|
onMessage: { addListener: () => {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.document = document;
|
||||||
|
globalThis.window = {
|
||||||
|
location: {
|
||||||
|
search: "",
|
||||||
|
href: "chrome-extension://autistmask/src/popup/index.html",
|
||||||
|
reload: () => reloads.push(Date.now()),
|
||||||
|
},
|
||||||
|
matchMedia: () => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
}),
|
||||||
|
addEventListener: () => {},
|
||||||
|
};
|
||||||
|
// The 10s refresh loop init() starts would outlive the test.
|
||||||
|
const realSetInterval = globalThis.setInterval;
|
||||||
|
globalThis.setInterval = () => 0;
|
||||||
|
|
||||||
|
require("../src/popup/index");
|
||||||
|
|
||||||
|
const booted = [];
|
||||||
|
for (const fn of document.listeners.DOMContentLoaded || []) {
|
||||||
|
booted.push(fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the browser console would have shown. A throw out of init() is the
|
||||||
|
// blank popup this issue is about, so it is captured rather than thrown:
|
||||||
|
// the assertion that matters is what ended up on screen.
|
||||||
|
const pageErrors = [];
|
||||||
|
for (const p of booted) {
|
||||||
|
try {
|
||||||
|
await p;
|
||||||
|
} catch (e) {
|
||||||
|
pageErrors.push(String((e && e.message) || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
globalThis.setInterval = realSetInterval;
|
||||||
|
|
||||||
|
return {
|
||||||
|
storage,
|
||||||
|
document,
|
||||||
|
pageErrors,
|
||||||
|
reloaded: () => reloads.length,
|
||||||
|
node: (id) => document.getElementById(id),
|
||||||
|
text: (id) => document.getElementById(id).textContent,
|
||||||
|
value: (id) => document.getElementById(id).value,
|
||||||
|
hidden: (id) =>
|
||||||
|
document.getElementById(id).classList.contains("hidden"),
|
||||||
|
click: async (id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const fns = el.listeners.click || [];
|
||||||
|
for (const fn of fns) await fn();
|
||||||
|
await settle();
|
||||||
|
},
|
||||||
|
// The view ids whose section is not hidden, as the audit measured them.
|
||||||
|
visibleViews: () => {
|
||||||
|
const out = [];
|
||||||
|
for (const [id, el] of document.elements) {
|
||||||
|
if (!id.startsWith("view-")) continue;
|
||||||
|
if (!el.classList.contains("hidden")) out.push(id.slice(5));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.chrome;
|
||||||
|
delete globalThis.document;
|
||||||
|
delete globalThis.window;
|
||||||
|
});
|
||||||
|
|
||||||
// --------------------------------------------------------------- tests
|
// --------------------------------------------------------------- tests
|
||||||
|
|
||||||
|
|||||||
@@ -1,347 +0,0 @@
|
|||||||
// Boot the REAL popup entry point over a stored record, exactly as the browser
|
|
||||||
// does: storage already holds the record, the page loads, DOMContentLoaded
|
|
||||||
// fires.
|
|
||||||
//
|
|
||||||
// Written for https://git.eeqj.de/sneak/AutistMask/issues/311 inside
|
|
||||||
// tests/stateRecovery.test.js and lifted here unchanged in substance when
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/362 needed the same boot for a
|
|
||||||
// second field. Assertions about a corrupt stored profile have to be made
|
|
||||||
// through the entry point rather than against a view module: a screen that
|
|
||||||
// renders perfectly when something calls it, and that nothing calls, IS the
|
|
||||||
// defect.
|
|
||||||
//
|
|
||||||
// The DOM stub is built FROM src/popup/index.html — every id in the markup,
|
|
||||||
// with the classes the markup gives it — so "which views are visible" is
|
|
||||||
// answered against the real element set, and a screen with no markup behind it
|
|
||||||
// cannot pass.
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const { makeStorageStub } = require("./storageStub");
|
|
||||||
|
|
||||||
const POPUP_HTML = fs.readFileSync(
|
|
||||||
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fixed addresses, never used for anything but these tests.
|
|
||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
||||||
|
|
||||||
// A profile in the shape every install in the field has it: complete, valid,
|
|
||||||
// and carrying no version field, because no build ever wrote one. The starting
|
|
||||||
// point for "and now corrupt exactly one field of it".
|
|
||||||
function unversionedValidProfile(extra) {
|
|
||||||
return {
|
|
||||||
hasWallet: true,
|
|
||||||
wallets: [
|
|
||||||
{
|
|
||||||
type: "hd",
|
|
||||||
name: "Wallet 1",
|
|
||||||
xpub: "xpub-wallet-1",
|
|
||||||
encryptedSecret: "encrypted-secret-1",
|
|
||||||
nextIndex: 1,
|
|
||||||
addresses: [
|
|
||||||
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeAddress: ADDRESS,
|
|
||||||
networkId: "mainnet",
|
|
||||||
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
|
||||||
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
|
||||||
allowedSites: { [ADDRESS]: ["dapp.example"] },
|
|
||||||
deniedSites: {},
|
|
||||||
trackedTokens: [],
|
|
||||||
theme: "system",
|
|
||||||
...(extra || {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeElement(id, className) {
|
|
||||||
const classes = new Set(
|
|
||||||
(className || "").split(/\s+/).filter((name) => name !== ""),
|
|
||||||
);
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
tagName: "DIV",
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
href: "",
|
|
||||||
download: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
listeners: {},
|
|
||||||
clicked: 0,
|
|
||||||
classList: {
|
|
||||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
||||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
||||||
contains: (n) => classes.has(n),
|
|
||||||
toggle: (n, force) => {
|
|
||||||
const on = force === undefined ? !classes.has(n) : force;
|
|
||||||
if (on) classes.add(n);
|
|
||||||
else classes.delete(n);
|
|
||||||
return on;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: (name, fn) => {
|
|
||||||
el.listeners[name] = el.listeners[name] || [];
|
|
||||||
el.listeners[name].push(fn);
|
|
||||||
},
|
|
||||||
removeEventListener: () => {},
|
|
||||||
appendChild: () => {},
|
|
||||||
remove: () => {},
|
|
||||||
focus: () => {},
|
|
||||||
select: () => {},
|
|
||||||
setAttribute: (name, value) => {
|
|
||||||
el[name] = value;
|
|
||||||
},
|
|
||||||
querySelector: () => null,
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
// The Receive view draws its QR onto #receive-qr through the qrcode
|
|
||||||
// package, which calls getContext("2d") and then createImageData/
|
|
||||||
// putImageData on the result. Without this the render throws from
|
|
||||||
// inside a promise the view does not await, which takes the whole node
|
|
||||||
// process down rather than failing a test — so a suite that boots onto
|
|
||||||
// Receive could not report anything.
|
|
||||||
getContext: () => ({
|
|
||||||
createImageData: (w, h) => ({
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
data: new Uint8ClampedArray(w * h * 4),
|
|
||||||
}),
|
|
||||||
putImageData: () => {},
|
|
||||||
clearRect: () => {},
|
|
||||||
}),
|
|
||||||
click: () => {
|
|
||||||
el.clicked += 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// src/ reaches parentElement only to hide or unhide the wrapper a field
|
|
||||||
// sits in (txStatus.js renderSuccess(), transactionDetail.js render()).
|
|
||||||
// The stub is flat — it is built from the ids in the markup, not from its
|
|
||||||
// tree — so each element gets a wrapper of its own, made on demand so this
|
|
||||||
// does not recurse. It is never registered by id, so nothing can mistake
|
|
||||||
// it for a view. Without it, success-tx and transaction throw on the first
|
|
||||||
// line that touches a wrapper and cannot be booted onto at all.
|
|
||||||
let parent = null;
|
|
||||||
Object.defineProperty(el, "parentElement", {
|
|
||||||
get() {
|
|
||||||
if (!parent) parent = makeElement(id + "-parent", "");
|
|
||||||
return parent;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every id in the markup, with the classes the markup gives it. A view the
|
|
||||||
// popup is supposed to reveal has to exist here, which means it has to exist
|
|
||||||
// in src/popup/index.html.
|
|
||||||
function idsFromHtml(html) {
|
|
||||||
const out = new Map();
|
|
||||||
const tags = html.match(/<[a-zA-Z][^>]*>/g) || [];
|
|
||||||
for (const tag of tags) {
|
|
||||||
const id = /\bid="([^"]+)"/.exec(tag);
|
|
||||||
if (!id) continue;
|
|
||||||
const cls = /\bclass="([^"]*)"/.exec(tag);
|
|
||||||
out.set(id[1], cls ? cls[1] : "");
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ids the popup creates at runtime rather than authoring in the markup, and
|
|
||||||
// that must therefore read as ABSENT until something creates them. Answering
|
|
||||||
// with a fresh element instead would make "is the banner up?" always true.
|
|
||||||
const RUNTIME_IDS = new Set(["debug-banner", "save-failure-banner"]);
|
|
||||||
|
|
||||||
function makeDocument(html) {
|
|
||||||
const authored = idsFromHtml(html);
|
|
||||||
const els = new Map();
|
|
||||||
for (const [id, className] of authored) {
|
|
||||||
els.set(id, makeElement(id, className));
|
|
||||||
}
|
|
||||||
const created = [];
|
|
||||||
const prepended = [];
|
|
||||||
const doc = {
|
|
||||||
listeners: {},
|
|
||||||
getElementById(id) {
|
|
||||||
if (RUNTIME_IDS.has(id) && !els.has(id)) return null;
|
|
||||||
if (!els.has(id)) els.set(id, makeElement(id, ""));
|
|
||||||
return els.get(id);
|
|
||||||
},
|
|
||||||
createElement(tag) {
|
|
||||||
const el = makeElement("created-" + tag, "");
|
|
||||||
el.tagName = String(tag).toUpperCase();
|
|
||||||
created.push(el);
|
|
||||||
return el;
|
|
||||||
},
|
|
||||||
addEventListener(name, fn) {
|
|
||||||
doc.listeners[name] = doc.listeners[name] || [];
|
|
||||||
doc.listeners[name].push(fn);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
documentElement: makeElement("html", ""),
|
|
||||||
body: {
|
|
||||||
// Recorded, and registered under its id: a banner the popup
|
|
||||||
// prepends is on the page from then on, and a test asking for it
|
|
||||||
// by id has to find it.
|
|
||||||
prepend: (el) => {
|
|
||||||
prepended.push(el);
|
|
||||||
if (el && el.id) els.set(el.id, el);
|
|
||||||
},
|
|
||||||
appendChild: () => {},
|
|
||||||
removeChild: () => {},
|
|
||||||
},
|
|
||||||
elements: els,
|
|
||||||
authoredIds: authored,
|
|
||||||
created,
|
|
||||||
prepended,
|
|
||||||
};
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function settle() {
|
|
||||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Boot the popup over `stored`.
|
|
||||||
*
|
|
||||||
* @param {*} stored the record storage holds, or undefined for a first run.
|
|
||||||
* @param {object} [options]
|
|
||||||
* @param {object} [options.storage] a storage stub from makeStorageStub(), for
|
|
||||||
* a test that needs to make writes fail or to watch the round trips.
|
|
||||||
* @returns {Promise<object>} handles onto the booted page.
|
|
||||||
*/
|
|
||||||
async function bootPopup(stored, options) {
|
|
||||||
jest.resetModules();
|
|
||||||
|
|
||||||
// The three modules that reach the network. None is on the path under
|
|
||||||
// test; all would make the suite hit the internet.
|
|
||||||
jest.doMock("../../src/shared/prices", () => ({
|
|
||||||
prices: {},
|
|
||||||
refreshPrices: jest.fn(async () => {}),
|
|
||||||
clearPrices: jest.fn(),
|
|
||||||
getPrice: () => null,
|
|
||||||
formatUsd: () => "",
|
|
||||||
formatAddressTotal: () => "",
|
|
||||||
getAddressValue: () => ({ usd: null, partial: false }),
|
|
||||||
getWalletValue: () => ({ usd: null, partial: false }),
|
|
||||||
getTotalValue: () => ({ usd: null, partial: false }),
|
|
||||||
}));
|
|
||||||
jest.doMock("../../src/shared/balances", () => ({
|
|
||||||
fetchTokenBalances: jest.fn(async () => []),
|
|
||||||
refreshBalances: jest.fn(async () => {}),
|
|
||||||
lookupTokenInfo: jest.fn(async () => null),
|
|
||||||
getProvider: () => ({}),
|
|
||||||
scanForAddresses: jest.fn(async () => []),
|
|
||||||
}));
|
|
||||||
jest.doMock("../../src/shared/transactions", () => ({
|
|
||||||
fetchRecentTransactions: jest.fn(async () => []),
|
|
||||||
filterTransactions: () => [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const storage =
|
|
||||||
(options && options.storage) ||
|
|
||||||
makeStorageStub(stored === undefined ? {} : { autistmask: stored });
|
|
||||||
const document = makeDocument(POPUP_HTML);
|
|
||||||
const reloads = [];
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: storage.local },
|
|
||||||
runtime: {
|
|
||||||
sendMessage: jest.fn(async () => ({})),
|
|
||||||
getURL: (p) => "chrome-extension://autistmask/" + p,
|
|
||||||
onMessage: { addListener: () => {} },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
globalThis.document = document;
|
|
||||||
globalThis.window = {
|
|
||||||
location: {
|
|
||||||
search: "",
|
|
||||||
href: "chrome-extension://autistmask/src/popup/index.html",
|
|
||||||
reload: () => reloads.push(Date.now()),
|
|
||||||
},
|
|
||||||
matchMedia: () => ({
|
|
||||||
matches: false,
|
|
||||||
addEventListener: () => {},
|
|
||||||
removeEventListener: () => {},
|
|
||||||
}),
|
|
||||||
addEventListener: () => {},
|
|
||||||
};
|
|
||||||
// The 10s refresh loop init() starts would outlive the test.
|
|
||||||
const realSetInterval = globalThis.setInterval;
|
|
||||||
globalThis.setInterval = () => 0;
|
|
||||||
|
|
||||||
require("../../src/popup/index");
|
|
||||||
|
|
||||||
const booted = [];
|
|
||||||
for (const fn of document.listeners.DOMContentLoaded || []) {
|
|
||||||
booted.push(fn());
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the browser console would have shown. A throw out of init() is the
|
|
||||||
// blank popup issue 311 is about, so it is captured rather than thrown:
|
|
||||||
// the assertion that matters is what ended up on screen.
|
|
||||||
const pageErrors = [];
|
|
||||||
for (const p of booted) {
|
|
||||||
try {
|
|
||||||
await p;
|
|
||||||
} catch (e) {
|
|
||||||
pageErrors.push(String((e && e.message) || e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
globalThis.setInterval = realSetInterval;
|
|
||||||
|
|
||||||
return {
|
|
||||||
storage,
|
|
||||||
document,
|
|
||||||
pageErrors,
|
|
||||||
reloaded: () => reloads.length,
|
|
||||||
node: (id) => document.getElementById(id),
|
|
||||||
text: (id) => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
return el ? el.textContent : null;
|
|
||||||
},
|
|
||||||
value: (id) => document.getElementById(id).value,
|
|
||||||
hidden: (id) =>
|
|
||||||
document.getElementById(id).classList.contains("hidden"),
|
|
||||||
click: async (id) => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
const fns = el.listeners.click || [];
|
|
||||||
for (const fn of fns) await fn();
|
|
||||||
await settle();
|
|
||||||
},
|
|
||||||
settle,
|
|
||||||
// The view ids whose section is not hidden, as the audit measured them.
|
|
||||||
visibleViews: () => {
|
|
||||||
const out = [];
|
|
||||||
for (const [id, el] of document.elements) {
|
|
||||||
if (!id.startsWith("view-")) continue;
|
|
||||||
if (!el.classList.contains("hidden")) out.push(id.slice(5));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupPopup() {
|
|
||||||
delete globalThis.chrome;
|
|
||||||
delete globalThis.document;
|
|
||||||
delete globalThis.window;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
settle,
|
|
||||||
unversionedValidProfile,
|
|
||||||
ADDRESS,
|
|
||||||
TOKEN_ADDRESS,
|
|
||||||
POPUP_HTML,
|
|
||||||
};
|
|
||||||
@@ -127,7 +127,6 @@ const confirmTx = require("../src/popup/views/confirmTx");
|
|||||||
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
|
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
|
||||||
|
|
||||||
const HOLDER = "0x" + "a".repeat(40);
|
const HOLDER = "0x" + "a".repeat(40);
|
||||||
const SECOND_HOLDER = "0x" + "b".repeat(40);
|
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||||
const BLOCKSCOUT = "https://blockscout.example/api/v2";
|
const BLOCKSCOUT = "https://blockscout.example/api/v2";
|
||||||
// Bundled, 18 decimals. The wallet knows this token's scale without asking
|
// Bundled, 18 decimals. The wallet knows this token's scale without asking
|
||||||
@@ -177,38 +176,6 @@ async function fetchOnto(items) {
|
|||||||
return balances;
|
return balances;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same, for two addresses of one wallet holding the same contract. Sending
|
|
||||||
// is from the first. Two addresses is what it takes to reach
|
|
||||||
// explorerDecimals()'s disagreement check, which is only reachable across rows.
|
|
||||||
async function fetchOntoBoth(itemsA, itemsB) {
|
|
||||||
debugFetch.mockImplementation(async () => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
json: async () => itemsA,
|
|
||||||
}));
|
|
||||||
const a = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
|
||||||
debugFetch.mockImplementation(async () => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
json: async () => itemsB,
|
|
||||||
}));
|
|
||||||
const b = await fetchTokenBalances(SECOND_HOLDER, BLOCKSCOUT, []);
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
name: "Wallet 1",
|
|
||||||
addresses: [
|
|
||||||
{ address: HOLDER, balance: "1.0", tokenBalances: a },
|
|
||||||
{ address: SECOND_HOLDER, balance: "1.0", tokenBalances: b },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.selectedWallet = 0;
|
|
||||||
state.selectedAddress = 0;
|
|
||||||
return { a, b };
|
|
||||||
}
|
|
||||||
|
|
||||||
function el(id) {
|
function el(id) {
|
||||||
return global.document.getElementById(id);
|
return global.document.getElementById(id);
|
||||||
}
|
}
|
||||||
@@ -316,85 +283,6 @@ describe("the Send screen resolves the scale rather than reading the stored one"
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// balances.js resolves the display scale WITHOUT `wallets`, so its explorer leg
|
|
||||||
// is the row it is formatting. send.js resolves WITH `wallets`, so its explorer
|
|
||||||
// leg is explorerDecimals(), which answers null when two addresses report
|
|
||||||
// different scales for one contract — the check that must apply before a scale
|
|
||||||
// encodes a transfer. The two therefore disagree exactly here, and a stored
|
|
||||||
// balance formatted at a scale the Send screen just refused is not a balance it
|
|
||||||
// may state: it would leave validateTransfer() satisfied, the unknown-balance
|
|
||||||
// sentence unfired, and the fee-estimate failure as the only thing on screen.
|
|
||||||
describe("a scale the explorer's own rows disagree about", () => {
|
|
||||||
// 5000000 units at the "6" address A reports, 5e18 at the "18" address B
|
|
||||||
// reports: both format to "5.0", so the disagreement is in the scale alone
|
|
||||||
// and not in the quantity.
|
|
||||||
function novel(decimals, value) {
|
|
||||||
return row(
|
|
||||||
{
|
|
||||||
address_hash: NOVEL,
|
|
||||||
symbol: "NOVEL",
|
|
||||||
name: "Novel Token",
|
|
||||||
decimals,
|
|
||||||
},
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("is stored per row, because storage holds the explorer's own answer", async () => {
|
|
||||||
const { a, b } = await fetchOntoBoth(
|
|
||||||
[novel("6", 5000000n)],
|
|
||||||
[novel("18", FIVE_WETH)],
|
|
||||||
);
|
|
||||||
expect(a[0].decimals).toBe(6);
|
|
||||||
expect(a[0].balance).toBe("5.0");
|
|
||||||
expect(b[0].decimals).toBe(18);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolves to null on the Send screen, and takes the balance with it", async () => {
|
|
||||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
|
|
||||||
const txInfo = await reviewSend(NOVEL, "1.5");
|
|
||||||
expect(txInfo.tokenDecimals).toBeNull();
|
|
||||||
// The regression this closes: null scale alongside a non-null balance.
|
|
||||||
expect(txInfo.tokenBalance).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("so the user is told the balance is unknown, not only that the fee failed", async () => {
|
|
||||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
|
|
||||||
const txInfo = await reviewSend(NOVEL, "1.5");
|
|
||||||
confirmTx.show(txInfo);
|
|
||||||
await settle();
|
|
||||||
// Before the fix: "5.0 NOVEL", an empty confirm-errors, and
|
|
||||||
// confirm-fee-unknown-error — "the network fee could not be
|
|
||||||
// estimated... please go back and try again" — as the only explanation
|
|
||||||
// for a screen that can never proceed.
|
|
||||||
expect(errors()).not.toBe("");
|
|
||||||
expect(errors()).toContain("This token's balance is unknown");
|
|
||||||
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
|
|
||||||
expect(sendDisabled()).toBe(true);
|
|
||||||
// The fee line still reports the estimate as unavailable, because it
|
|
||||||
// genuinely is — displayedDecimals() refuses the same missing scale.
|
|
||||||
// What changed is that it is no longer the ONLY thing on the screen,
|
|
||||||
// and no longer the only offered explanation. This is exactly how the
|
|
||||||
// token nothing knows the scale of already behaved.
|
|
||||||
expect(text("confirm-fee-amount")).toBe("Unable to estimate");
|
|
||||||
expect(el("confirm-fee-unknown-error").style.visibility).toBe(
|
|
||||||
"visible",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("while agreeing rows leave the scale usable", async () => {
|
|
||||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
|
||||||
const txInfo = await reviewSend(NOVEL, "1.5");
|
|
||||||
expect(txInfo.tokenDecimals).toBe(6);
|
|
||||||
expect(txInfo.tokenBalance).toBe("5.0");
|
|
||||||
confirmTx.show(txInfo);
|
|
||||||
await settle();
|
|
||||||
expect(text("confirm-balance")).toBe("5.0 NOVEL");
|
|
||||||
expect(errors()).toBe("");
|
|
||||||
expect(sendDisabled()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the confirmation screen tells an unknown balance from a zero one", () => {
|
describe("the confirmation screen tells an unknown balance from a zero one", () => {
|
||||||
function txInfo(tokenBalance) {
|
function txInfo(tokenBalance) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user