Compare commits
4 Commits
78a5573d86
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| 1197d2171b | |||
| a098bb0c32 | |||
| 45500e66cf | |||
| 1b52aa1723 |
74
README.md
74
README.md
@@ -800,7 +800,12 @@ 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 styling co-located with the markup and eliminates CSS file management. The
|
||||||
|
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
|
||||||
@@ -849,6 +854,12 @@ 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
|
||||||
@@ -902,6 +913,27 @@ the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). An
|
|||||||
unbounded allowance or permit needs no scale to describe and is still shown as
|
unbounded allowance or permit needs no scale to describe and is still shown as
|
||||||
`Unlimited`.
|
`Unlimited`.
|
||||||
|
|
||||||
|
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
|
||||||
|
sources are read as authoritative, so a value written into one of them cannot be
|
||||||
|
recognized as a guess afterwards: a fabricated `18` reads exactly like a real
|
||||||
|
`18`, and the refusal above then never fires. So `fetchTokenBalances()` in
|
||||||
|
`src/shared/balances.js` stores what the explorer reported or `null`, never a
|
||||||
|
default, and the same holds for the history list's token transfers in
|
||||||
|
`src/shared/transactions.js`. A token whose `decimals()` reverts has no scale
|
||||||
|
anywhere, and a holding of it carries no quantity either: its balance is `null`
|
||||||
|
— read as unknown, never as zero — and the balance list says so rather than
|
||||||
|
printing `0.0000` for money that is really there. `0` is a real scale and is
|
||||||
|
never treated as absent.
|
||||||
|
|
||||||
|
`tokenBalances[].decimals` is therefore the explorer's own answer and nothing
|
||||||
|
else, which is not the same question as the scale a screen should render at.
|
||||||
|
Anything that needs the second one calls `resolveTokenDecimals()` — the balance
|
||||||
|
list, the approval and swap lines, and the Send screen, which carries the
|
||||||
|
resolved scale onto the pending transaction for `transferAmount.js` to encode
|
||||||
|
and compare against. Reading the stored field directly instead answers `null`
|
||||||
|
for a bundled or tracked token the explorer merely omitted, which is not a
|
||||||
|
refusal the wallet has any reason to make.
|
||||||
|
|
||||||
#### Partial USD totals
|
#### Partial USD totals
|
||||||
|
|
||||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
||||||
@@ -1027,12 +1059,24 @@ because nothing dereferences them structurally.
|
|||||||
Which field is which is not written in prose anywhere, deliberately.
|
Which field is which is not written in prose anywhere, deliberately.
|
||||||
`tests/persistedFieldContract.test.js` is the list: one row per persisted field,
|
`tests/persistedFieldContract.test.js` is the list: one row per persisted field,
|
||||||
naming the property that field's floor is claimed to have and proving it by
|
naming the property that field's floor is claimed to have and proving it by
|
||||||
driving the real code with hostile values — including a boot of the real popup
|
driving the real code with hostile values — and, for every field whose only
|
||||||
entry point for every field whose only defence is that nothing dereferences it.
|
defence is that nothing dereferences it, by booting the real popup entry point
|
||||||
A field added to `PERSISTED_FIELDS` with no row fails `make check`, and so does
|
over that value onto every view the popup can reopen onto. That last part is
|
||||||
a row whose claim is false. The per-field justification that used to live in the
|
what makes the claim falsifiable, because this defect class lives on the restore
|
||||||
header of `src/shared/stateSchema.js` shipped a false claim in three consecutive
|
path rather than on the home screen. Read the claim narrowly, as that file
|
||||||
changes, each caught only by a reviewer re-deriving thirty fields by hand.
|
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
|
The `allowedSites` case is why the entry check is not optional. A stored
|
||||||
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
||||||
@@ -1149,13 +1193,17 @@ 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 with "Address
|
button for HD and xprv wallets, then one block per address. The block
|
||||||
N" (bold when active), the ENS name if resolved, the full address, an
|
opens with a row carrying the colour dot, "Address N" (bold when active),
|
||||||
`[info]` button, an `[x]` button (only on HD and xprv wallets holding more
|
an `[info]` button and an `[x]` button (only on HD and xprv wallets
|
||||||
than one address), the address USD total, and a balance line for ETH and
|
holding more than one address); the ENS name, if resolved, is below it;
|
||||||
for each token shown for that address
|
then the full address on a row of its own, followed by the address USD
|
||||||
|
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
|
of every wallet, deduplicated by hash and filtered. Each row is three
|
||||||
|
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
|
||||||
|
|||||||
85
TODO.md
85
TODO.md
@@ -45,6 +45,34 @@ 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
|
- 2026-08-23: A persisted container whose ENTRIES were dereferenced unchecked no
|
||||||
longer reaches a `.map()` or a `.toLowerCase()`
|
longer reaches a `.map()` or a `.toLowerCase()`
|
||||||
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). `allowedSites` was
|
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). `allowedSites` was
|
||||||
@@ -67,8 +95,21 @@ but the review is broader than any of them.
|
|||||||
justification in the header of `src/shared/stateSchema.js` — which had shipped
|
justification in the header of `src/shared/stateSchema.js` — which had shipped
|
||||||
a false claim in three consecutive changes — is replaced by
|
a false claim in three consecutive changes — is replaced by
|
||||||
`tests/persistedFieldContract.test.js`, one row per persisted field, each
|
`tests/persistedFieldContract.test.js`, one row per persisted field, each
|
||||||
proven by driving the real code with hostile values; a field with no row, or a
|
proven by driving the real code with hostile values — and, for a field whose
|
||||||
row whose claim is false, now fails `make check`.
|
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
|
||||||
@@ -146,6 +187,46 @@ but the review is broader than any of them.
|
|||||||
`src/shared/restorableViews.js`, since `persistedState.js` requires it and
|
`src/shared/restorableViews.js`, since `persistedState.js` requires it and
|
||||||
that module is in the background bundle.
|
that module is in the background bundle.
|
||||||
|
|
||||||
|
- 2026-08-23: An explorer that reports no `decimals` for a token no longer has a
|
||||||
|
scale invented for it before storage
|
||||||
|
([#349](https://git.eeqj.de/sneak/AutistMask/issues/349)).
|
||||||
|
`fetchTokenBalances()` did `parseInt(item.token.decimals || "18", 10)` on the
|
||||||
|
way in, so a token whose `decimals()` reverts was written to
|
||||||
|
`tokenBalances[].decimals` as a fabricated `18` that no reader could tell from
|
||||||
|
a real one. That is upstream of the resolve-or-refuse rule
|
||||||
|
([#306](https://git.eeqj.de/sneak/AutistMask/issues/306),
|
||||||
|
[#340](https://git.eeqj.de/sneak/AutistMask/issues/340)): both approval paths
|
||||||
|
read this stored value as an authoritative source, so the guess walked past
|
||||||
|
refusals that were intact and simply never fired. The stored value is now the
|
||||||
|
explorer's own answer or `null`, and both the ERC-20 amount line and the swap
|
||||||
|
lines reach `unknownDecimalsAmount()` on it. The history list's token
|
||||||
|
transfers carried the same `|| "18"` and now state base units with the scale
|
||||||
|
unknown rather than a quantity. A holding whose scale nothing knows carries
|
||||||
|
`balance: null` — unknown, not zero — and the balance list, the USD total, the
|
||||||
|
Send screen and the confirmation screen each say so instead of printing
|
||||||
|
`0.0000` for money that is really there. The uint8 check is one shared
|
||||||
|
`toDecimals()` rather than three copies, and it answers `0` for a real scale
|
||||||
|
of zero: `|| "18"` collapsed that to eighteen, the trap of
|
||||||
|
[#246](https://git.eeqj.de/sneak/AutistMask/issues/246). Existing installs
|
||||||
|
hold `18`s that cannot be told apart retroactively; they display exactly as
|
||||||
|
they do today until the next balance refresh, which rewrites `tokenBalances`
|
||||||
|
wholesale and needs no user action. No `|| 18` or `?? 18` fallback remains
|
||||||
|
anywhere in `src/`; the literal `18`s that do remain are real data, not
|
||||||
|
defaults — 432 per-token `decimals: 18` entries in the bundled
|
||||||
|
`src/shared/tokenList.js`, and, outside that file, only native ETH's
|
||||||
|
protocol-defined scale in `src/shared/uniswap.js` and the fixed-point
|
||||||
|
comparison scale in `src/shared/txValidation.js`. `tokenBalances[].decimals`
|
||||||
|
is the explorer's answer alone and not the scale a screen renders at, so the
|
||||||
|
Send screen resolves through `resolveTokenDecimals()` like every other
|
||||||
|
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
|
||||||
also closes the cold-worker wrong-chain send
|
also closes the cold-worker wrong-chain send
|
||||||
|
|||||||
49
build.js
49
build.js
@@ -51,6 +51,17 @@ 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 });
|
||||||
}
|
}
|
||||||
@@ -257,6 +268,42 @@ 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")
|
||||||
@@ -524,6 +571,8 @@ 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
|
||||||
|
|||||||
BIN
icons/icon128.png
Normal file
BIN
icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/icon16.png
Normal file
BIN
icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 292 B |
BIN
icons/icon32.png
Normal file
BIN
icons/icon32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 534 B |
BIN
icons/icon48.png
Normal file
BIN
icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 725 B |
@@ -9,6 +9,12 @@
|
|||||||
"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,6 +5,12 @@
|
|||||||
"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,10 +213,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- active address display -->
|
<!-- active address display -->
|
||||||
<div
|
<div id="active-address-display" class="text-xs mb-3"></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">
|
||||||
@@ -292,7 +289,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 break-all"
|
class="text-xs mb-1 cursor-pointer"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
id="address-line"
|
id="address-line"
|
||||||
>
|
>
|
||||||
@@ -380,14 +377,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>
|
||||||
<p class="text-xs mb-3">
|
<div 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>
|
||||||
</p>
|
</div>
|
||||||
<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.
|
||||||
@@ -440,7 +437,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="text-xs mb-1 cursor-pointer break-all"
|
class="text-xs mb-1 cursor-pointer"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
id="address-token-line"
|
id="address-token-line"
|
||||||
>
|
>
|
||||||
@@ -573,19 +570,16 @@
|
|||||||
<!-- 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
|
<div id="confirm-token-contract" class="text-xs"></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 break-all"></div>
|
<div id="confirm-from" class="text-xs"></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 break-all"></div>
|
<div id="confirm-to" class="text-xs"></div>
|
||||||
<div
|
<div
|
||||||
id="confirm-to-ens"
|
id="confirm-to-ens"
|
||||||
class="text-xs text-muted hidden"
|
class="text-xs text-muted hidden"
|
||||||
@@ -728,7 +722,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 break-all"></div>
|
<div id="wait-tx-to" class="text-xs"></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>
|
||||||
@@ -747,7 +741,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 break-all"></div>
|
<div id="success-tx-to" class="text-xs"></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>
|
||||||
@@ -774,7 +768,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 break-all"></div>
|
<div id="error-tx-to" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div
|
<div
|
||||||
@@ -811,9 +805,9 @@
|
|||||||
<canvas id="receive-qr"></canvas>
|
<canvas id="receive-qr"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
|
class="border border-border p-2 mb-3 text-xs cursor-pointer"
|
||||||
>
|
>
|
||||||
<span id="receive-address-block" class="select-all"></span>
|
<div id="receive-address-block" class="select-all"></div>
|
||||||
<span id="receive-etherscan-link"></span>
|
<span id="receive-etherscan-link"></span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -1239,7 +1233,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="delete-address-value"
|
id="delete-address-value"
|
||||||
class="text-xs mb-2 break-all min-h-[1rem]"
|
class="text-xs mb-2 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"
|
||||||
@@ -1429,14 +1423,11 @@
|
|||||||
</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
|
<div id="tx-detail-from" class="text-xs"></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 break-all"></div>
|
<div id="tx-detail-to" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1473,7 +1464,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="tx-detail-token-contract"
|
id="tx-detail-token-contract"
|
||||||
class="text-xs break-all"
|
class="text-xs"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1567,11 +1558,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 break-all"></div>
|
<div id="approve-tx-from" class="text-xs"></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 break-all"></div>
|
<div id="approve-tx-to" class="text-xs"></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>
|
||||||
@@ -1673,7 +1664,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 break-all"></div>
|
<div id="approve-sign-from" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
@@ -44,3 +44,23 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
truncateMiddle,
|
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
@@ -229,10 +228,12 @@ function renderTransactions(txs) {
|
|||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + sym)
|
? escapeHtml(tx.value + " " + sym)
|
||||||
: escapeHtml(sym);
|
: escapeHtml(sym);
|
||||||
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
// The counterparty used to be squeezed in beside the amount and
|
||||||
const displayAddr =
|
// truncated to whatever was left over. It gets its own row now and
|
||||||
title || ensName || truncateMiddle(counterparty, maxAddr);
|
// is shown whole; the title or ENS name, where there is one, names
|
||||||
const addrStr = escapeHtml(displayAddr);
|
// it on the line above rather than replacing it.
|
||||||
|
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;" : "";
|
||||||
@@ -240,7 +241,8 @@ 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}${addrStr}</span><span>${amountStr}</span></div>`;
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
|
||||||
|
html += `<div class="am-address">${addrStr}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
truncateMiddle,
|
|
||||||
balanceLine,
|
balanceLine,
|
||||||
|
unknownableAmount,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
@@ -118,7 +118,9 @@ function show() {
|
|||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
amount = tb ? parseFloat(tb.balance || "0") : 0;
|
// null when the scale is unknown: no quantity to show, and none to
|
||||||
|
// price. balanceLine() states that rather than printing 0.0000.
|
||||||
|
amount = tb ? unknownableAmount(tb.balance) : 0;
|
||||||
price = getPrice(symbol);
|
price = getPrice(symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +154,7 @@ function show() {
|
|||||||
attachCopyHandlers($("address-token-line"));
|
attachCopyHandlers($("address-token-line"));
|
||||||
|
|
||||||
// USD total for this token only
|
// USD total for this token only
|
||||||
const usdVal = price ? amount * price : null;
|
const usdVal = price && amount !== null ? amount * price : null;
|
||||||
const usdStr = formatUsd(usdVal);
|
const usdStr = formatUsd(usdVal);
|
||||||
$("address-token-usd-total").innerHTML = usdStr || " ";
|
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||||
|
|
||||||
@@ -302,10 +304,12 @@ function renderTransactions(txs) {
|
|||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + sym)
|
? escapeHtml(tx.value + " " + sym)
|
||||||
: escapeHtml(sym);
|
: escapeHtml(sym);
|
||||||
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
// The counterparty used to be squeezed in beside the amount and
|
||||||
const displayAddr =
|
// truncated to whatever was left over. It gets its own row now and
|
||||||
title || ensName || truncateMiddle(counterparty, maxAddr);
|
// is shown whole; the title or ENS name, where there is one, names
|
||||||
const addrStr = escapeHtml(displayAddr);
|
// it on the line above rather than replacing it.
|
||||||
|
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;" : "";
|
||||||
@@ -313,7 +317,8 @@ 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}${addrStr}</span><span>${amountStr}</span></div>`;
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
|
||||||
|
html += `<div class="am-address">${addrStr}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,12 +139,17 @@ function show(txInfo) {
|
|||||||
|
|
||||||
// Balance (with inline USD)
|
// Balance (with inline USD)
|
||||||
if (isErc20) {
|
if (isErc20) {
|
||||||
const bal = txInfo.tokenBalance || "0";
|
// null is a balance whose scale nothing knows, not a balance of zero
|
||||||
const balUsd = tokenPrice ? parseFloat(bal) * tokenPrice : null;
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
||||||
$("confirm-balance").textContent = valueWithUsd(
|
// refused at encode time for the same missing scale; what this line
|
||||||
bal + " " + symbol,
|
// must not do is state a quantity nobody established.
|
||||||
balUsd,
|
const bal = txInfo.tokenBalance;
|
||||||
);
|
const balUsd =
|
||||||
|
tokenPrice && bal != null ? parseFloat(bal) * tokenPrice : null;
|
||||||
|
$("confirm-balance").textContent =
|
||||||
|
bal == null
|
||||||
|
? "unknown (" + symbol + ")"
|
||||||
|
: valueWithUsd(bal + " " + symbol, balUsd);
|
||||||
} else {
|
} else {
|
||||||
const bal = txInfo.balance || "0";
|
const bal = txInfo.balance || "0";
|
||||||
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||||
@@ -235,17 +240,22 @@ function renderValidation(txInfo) {
|
|||||||
}
|
}
|
||||||
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
||||||
messages.push(
|
messages.push(
|
||||||
"Insufficient " +
|
txInfo.tokenBalance == null
|
||||||
symbol +
|
? "This token's balance is unknown, because nothing this" +
|
||||||
" balance. You have " +
|
" wallet can consult reports how many decimal places it" +
|
||||||
txInfo.tokenBalance +
|
" uses, so the amount you are trying to send cannot be" +
|
||||||
" " +
|
" checked against it."
|
||||||
symbol +
|
: "Insufficient " +
|
||||||
" but are trying to send " +
|
symbol +
|
||||||
txInfo.amount +
|
" balance. You have " +
|
||||||
" " +
|
txInfo.tokenBalance +
|
||||||
symbol +
|
" " +
|
||||||
".",
|
symbol +
|
||||||
|
" but are trying to send " +
|
||||||
|
txInfo.amount +
|
||||||
|
" " +
|
||||||
|
symbol +
|
||||||
|
".",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
||||||
|
|||||||
@@ -229,6 +229,15 @@ 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,
|
||||||
@@ -236,9 +245,18 @@ function showFlash(msg, duration = 2000) {
|
|||||||
// attacker-chosen length until it has been through displaySymbol. This is
|
// attacker-chosen length until it has been through displaySymbol. This is
|
||||||
// the row that issue #307 was reported against: every screen that lists a
|
// the row that issue #307 was reported against: every screen that lists a
|
||||||
// holding renders through here.
|
// holding renders through here.
|
||||||
|
//
|
||||||
|
// `amount` is null for a holding whose scale nothing knows
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to
|
||||||
|
// 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
|
||||||
|
// says so instead.
|
||||||
function balanceLine(symbol, amount, price, tokenId) {
|
function balanceLine(symbol, amount, price, tokenId) {
|
||||||
const qty = amount.toFixed(4);
|
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||||
const usd = price ? formatUsd(amount * price) || " " : " ";
|
const usd =
|
||||||
|
price && amount !== null
|
||||||
|
? formatUsd(amount * price) || " "
|
||||||
|
: " ";
|
||||||
// tokenId is a contract address out of the same explorer JSON, and it
|
// tokenId is a contract address out of the same explorer JSON, and it
|
||||||
// lands inside a quoted attribute.
|
// lands inside a quoted attribute.
|
||||||
const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
|
const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
|
||||||
@@ -265,7 +283,12 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
);
|
);
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const t of addr.tokenBalances || []) {
|
for (const t of addr.tokenBalances || []) {
|
||||||
const bal = parseFloat(t.balance || "0");
|
// A null balance is a holding of an unstatable amount, not a holding
|
||||||
|
// of zero, so the show-zero setting has no say over it: hiding it
|
||||||
|
// would be asserting the zero nobody established. Anything that does
|
||||||
|
// not parse to a finite number is unknown for the same reason — the
|
||||||
|
// `|| "0"` this replaced turned both into a confident zero.
|
||||||
|
const bal = unknownableAmount(t.balance);
|
||||||
if (bal === 0 && !showZero) continue;
|
if (bal === 0 && !showZero) continue;
|
||||||
html += balanceLine(
|
html += balanceLine(
|
||||||
t.symbol,
|
t.symbol,
|
||||||
@@ -298,11 +321,22 @@ function addressHoldsFunds(addr) {
|
|||||||
if (!addr) return false;
|
if (!addr) return false;
|
||||||
if (parseFloat(addr.balance || "0") > 0) return true;
|
if (parseFloat(addr.balance || "0") > 0) return true;
|
||||||
for (const t of addr.tokenBalances || []) {
|
for (const t of addr.tokenBalances || []) {
|
||||||
if (parseFloat(t.balance || "0") > 0) return true;
|
// A null balance is a holding whose amount could not be stated —
|
||||||
|
// balances.js drops a row of zero base units before the scale is
|
||||||
|
// consulted, so a row that survived with no quantity is holding
|
||||||
|
// something. Warning about funds must err towards warning.
|
||||||
|
const bal = unknownableAmount(t.balance);
|
||||||
|
if (bal === null || bal > 0) return true;
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -490,17 +524,29 @@ function attachCopyHandlers(container) {
|
|||||||
|
|
||||||
// Unified address rendering.
|
// Unified address rendering.
|
||||||
//
|
//
|
||||||
// Produces consistent HTML for any Ethereum address:
|
// Two stacked rows, in this order:
|
||||||
// • Color dot
|
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 —
|
||||||
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
|
// Address 2") and the explorer link icon. Optional ENS name below it.
|
||||||
// • Optional ENS name shown bold above address
|
// 2. The address itself, alone on a full-width row that never wraps
|
||||||
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
|
// (see .am-address in styles/main.css).
|
||||||
// • 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 (min 32 chars enforced)
|
// maxLen — if set, truncate address display. Floored at 32 characters
|
||||||
|
// 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
|
||||||
@@ -508,22 +554,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 ? truncateMiddle(address, maxLen) : address;
|
const displayAddr = maxLen
|
||||||
|
? 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 += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
|
html += `<span class="font-bold">${escapeHtml(title)}</span>`;
|
||||||
}
|
}
|
||||||
|
html += `${extLink}</div>`;
|
||||||
if (ensName) {
|
if (ensName) {
|
||||||
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
|
html += `<div class="font-bold">${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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,6 +604,7 @@ module.exports = {
|
|||||||
balanceLine,
|
balanceLine,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
addressHoldsFunds,
|
addressHoldsFunds,
|
||||||
|
unknownableAmount,
|
||||||
addressColor,
|
addressColor,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
truncateMiddle,
|
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
@@ -117,10 +116,13 @@ 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 maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
const titleStr = title ? escapeHtml(title) : "";
|
||||||
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
|
const addrStr = escapeHtml(counterparty);
|
||||||
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;" : "";
|
||||||
@@ -128,7 +130,8 @@ 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}${addrStr}</span><span>${amountStr}</span></div>`;
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${titleStr}</span><span>${amountStr}</span></div>`;
|
||||||
|
html += `<div class="am-address">${addrStr}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
@@ -252,17 +255,22 @@ function walletListHtml() {
|
|||||||
: "";
|
: "";
|
||||||
const dot = addressDotHtml(addr.address);
|
const dot = addressDotHtml(addr.address);
|
||||||
const titleBold = isActive ? "font-bold" : "";
|
const titleBold = isActive ? "font-bold" : "";
|
||||||
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
|
// [info] and [x] ride on the "Address N" line, which was empty
|
||||||
|
// 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 flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
|
html += `<div class="text-xs font-bold">${escapeHtml(addr.ensName)}</div>`;
|
||||||
}
|
}
|
||||||
html += `<div class="flex text-xs items-center justify-between">`;
|
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
||||||
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(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
const { state, currentAddress } = require("../../shared/state");
|
const { state, currentAddress } = require("../../shared/state");
|
||||||
let ctx;
|
let ctx;
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
|
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
||||||
const { resolveSymbol } = require("../../shared/tokenList");
|
const { resolveSymbol } = require("../../shared/tokenList");
|
||||||
const { isLowHolderCount } = require("../../shared/holders");
|
const { isLowHolderCount } = require("../../shared/holders");
|
||||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||||
@@ -159,9 +160,14 @@ function updateSendBalance() {
|
|||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
const bal = tb ? tb.balance || "0" : "0";
|
// A null balance is a holding whose scale nothing knows. Saying "0"
|
||||||
|
// for it would be a claim about the amount; the send itself is
|
||||||
|
// refused later by transferAmountUnits() for the same missing scale.
|
||||||
|
const bal = tb ? tb.balance : "0";
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
"Current balance: " + bal + " " + symbol;
|
bal == null
|
||||||
|
? "Current balance: unknown (" + symbol + ")"
|
||||||
|
: "Current balance: " + bal + " " + symbol;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +241,45 @@ function init(_ctx) {
|
|||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
tokenBalance = tb ? tb.balance || "0" : "0";
|
// null carried through rather than flattened to "0": the confirm
|
||||||
tokenDecimals = tb ? tb.decimals : null;
|
// screen states an unknown balance as unknown, and
|
||||||
|
// validateTransfer() treats it as no balance to spend from, which
|
||||||
|
// is the fail-closed side of an amount nobody can check.
|
||||||
|
tokenBalance = tb ? (tb.balance ?? null) : "0";
|
||||||
|
// Resolved the same way balances.js resolved the scale it
|
||||||
|
// DISPLAYED this token's balance at: bundled list, then the user's
|
||||||
|
// tracked tokens, then the explorer. The stored
|
||||||
|
// tokenBalances[].decimals is the explorer's own answer alone, so
|
||||||
|
// reading it raw carries a null forward for a token the wallet
|
||||||
|
// does know the scale of — and displayedDecimals() then throws
|
||||||
|
// inside estimateGas(), which the confirmation screen reports as
|
||||||
|
// an unestimable fee. Unsendable, over a scale that was never in
|
||||||
|
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||||
|
// Still null when nothing knows: no fallback.
|
||||||
|
//
|
||||||
|
// 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, {
|
||||||
|
trackedTokens: state.trackedTokens,
|
||||||
|
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,10 +137,16 @@ 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 {
|
||||||
|
|||||||
@@ -23,33 +23,14 @@
|
|||||||
// disputed is refused rather than guessed at.
|
// disputed is refused rather than guessed at.
|
||||||
|
|
||||||
// Solidity's decimals() is a uint8, and every source here is ultimately
|
// Solidity's decimals() is a uint8, and every source here is ultimately
|
||||||
// reporting that call's result.
|
// reporting that call's result. toDecimals() is that check, shared with the
|
||||||
const { MAX_DECIMALS } = require("./transferAmount");
|
// send path rather than copied: the bundled list stores numbers, the
|
||||||
|
// explorer's copy arrives as a string, and a token the user added by hand
|
||||||
|
// carries whatever lookupTokenInfo() got back, so the accepted types are
|
||||||
|
// enumerated rather than coerced.
|
||||||
|
const { toDecimals } = require("./transferAmount");
|
||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
|
|
||||||
// A decimals value as a number, or null if it is not one. The bundled list
|
|
||||||
// stores numbers, the explorer's copy arrives as a string, and a token the
|
|
||||||
// user added by hand can carry whatever lookupTokenInfo() got back, so the
|
|
||||||
// accepted types are enumerated rather than coerced: Number([]) is 0 and
|
|
||||||
// Number(true) is 1, so a coercing check would read an empty array as a scale
|
|
||||||
// of zero and format the amount as whole tokens.
|
|
||||||
function toDecimals(value) {
|
|
||||||
let n;
|
|
||||||
if (typeof value === "number") {
|
|
||||||
n = value;
|
|
||||||
} else if (typeof value === "bigint") {
|
|
||||||
if (value < 0n || value > BigInt(MAX_DECIMALS)) return null;
|
|
||||||
n = Number(value);
|
|
||||||
} else if (typeof value === "string") {
|
|
||||||
if (!/^[0-9]+$/.test(value)) return null;
|
|
||||||
n = Number(value);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) return null;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every decimals the explorer reported for this contract, across all the
|
// Every decimals the explorer reported for this contract, across all the
|
||||||
// addresses whose balances have been fetched. They describe one contract, so
|
// addresses whose balances have been fetched. They describe one contract, so
|
||||||
// they should agree; a set that does not agree is a scale in dispute, and this
|
// they should agree; a set that does not agree is a scale in dispute, and this
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const { deriveAddressFromXpub } = require("./wallet");
|
|||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||||
|
const { toDecimals } = require("./transferAmount");
|
||||||
|
const { resolveTokenDecimals } = require("./approvalAmount");
|
||||||
|
|
||||||
// Use a static network to skip auto-detection (which can fail and cause
|
// Use a static network to skip auto-detection (which can fail and cause
|
||||||
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
||||||
@@ -66,10 +68,28 @@ function formatTokenBalance(raw, decimals) {
|
|||||||
return parts[0] + "." + dec;
|
return parts[0] + "." + dec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The explorer's reported holding as an exact base-unit integer, or null when
|
||||||
|
// it reported nothing usable. Base units carry no scale, so this value is
|
||||||
|
// meaningful before the scale is known — which is what lets a holding of zero
|
||||||
|
// be recognised as zero without guessing a scale to divide it by.
|
||||||
|
function rawUnits(value) {
|
||||||
|
if (typeof value === "bigint") return value >= 0n ? value : null;
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || !/^[0-9]+$/.test(value)) return null;
|
||||||
|
return BigInt(value);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch token balances for a single address from Blockscout.
|
// Fetch token balances for a single address from Blockscout.
|
||||||
// Returns [{ address, symbol, decimals, balance }].
|
// Returns [{ address, name, symbol, decimals, balance, holders }].
|
||||||
// Filters out spam: only shows tokens that are in the known token list,
|
// Filters out spam: only shows tokens that are in the known token list,
|
||||||
// explicitly tracked by the user, or have >= 1000 holders.
|
// explicitly tracked by the user, or have >= 1000 holders.
|
||||||
|
//
|
||||||
|
// `decimals` and `balance` are each null when the answer is unknown, the same
|
||||||
|
// way `holders` already is. Absence is never filled in here: this is the
|
||||||
|
// upstream of every screen that displays a token amount, so a value invented
|
||||||
|
// at this point is indistinguishable from a real one everywhere below it.
|
||||||
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||||
try {
|
try {
|
||||||
const resp = await debugFetch(
|
const resp = await debugFetch(
|
||||||
@@ -94,11 +114,46 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
|||||||
// is unchanged.
|
// is unchanged.
|
||||||
const type = String(item.token?.type || "").toUpperCase();
|
const type = String(item.token?.type || "").toUpperCase();
|
||||||
if (type !== "ERC-20") continue;
|
if (type !== "ERC-20") continue;
|
||||||
const decimals = parseInt(item.token.decimals || "18", 10);
|
|
||||||
const bal = formatTokenBalance(item.value || "0", decimals);
|
|
||||||
if (bal === "0.0") continue;
|
|
||||||
|
|
||||||
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
||||||
|
|
||||||
|
// What the explorer reported, or null. NEVER a default: this
|
||||||
|
// value is written to state and every later reader — the approval
|
||||||
|
// screen's amount line, the swap lines, the Send screen — takes it
|
||||||
|
// as the token's resolved scale. A fabricated 18 reads exactly
|
||||||
|
// like a real 18 at that point, so it does not merely display the
|
||||||
|
// wrong quantity, it walks straight past the refusal those screens
|
||||||
|
// already have for a scale nobody knows
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||||
|
const decimals = toDecimals(item.token.decimals);
|
||||||
|
|
||||||
|
const raw = rawUnits(item.value);
|
||||||
|
// No usable amount at all is nothing to list, exactly as a
|
||||||
|
// formatted "0.0" was before. Checked on the base-unit integer so
|
||||||
|
// it does not depend on knowing the scale: zero base units is zero
|
||||||
|
// tokens at every scale, and a value the explorer did not report
|
||||||
|
// as an integer is not a holding.
|
||||||
|
if (raw === null || raw === 0n) continue;
|
||||||
|
|
||||||
|
// The scale this row's balance is DISPLAYED at, which is not the
|
||||||
|
// same question as what the explorer said. The bundled list and
|
||||||
|
// the tokens the user tracks both outrank the explorer already
|
||||||
|
// (resolveTokenDecimals), so a token they know keeps showing its
|
||||||
|
// real quantity even when the explorer's entry omits decimals.
|
||||||
|
// Only what neither of them nor the explorer knows is unknown.
|
||||||
|
// The stored `decimals` above stays the explorer's own answer
|
||||||
|
// either way: copying another source into it would make
|
||||||
|
// explorerDecimals()'s disagreement check compare something other
|
||||||
|
// than explorer values.
|
||||||
|
const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
|
||||||
|
const scale = known !== null ? known : decimals;
|
||||||
|
// null is a holding of an amount that cannot be stated, which is
|
||||||
|
// not the same as a holding of zero, and must never render as one.
|
||||||
|
// With a scale, the display filter proper applies: a balance that
|
||||||
|
// rounds to zero at six places is dust and is not listed. Without
|
||||||
|
// one there is no such judgement to make, and the row is kept.
|
||||||
|
const bal = scale === null ? null : formatTokenBalance(raw, scale);
|
||||||
|
if (bal === "0.0") continue;
|
||||||
// null means the explorer reported no count, which is not the
|
// null means the explorer reported no count, which is not the
|
||||||
// same as a count of zero. This gate is not the low-holder
|
// same as a count of zero. This gate is not the low-holder
|
||||||
// display filter: it has no user-facing off switch and governs
|
// display filter: it has no user-facing off switch and governs
|
||||||
@@ -127,7 +182,15 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
|||||||
address: item.token.address_hash,
|
address: item.token.address_hash,
|
||||||
name: item.token.name || "",
|
name: item.token.name || "",
|
||||||
symbol: item.token.symbol || "???",
|
symbol: item.token.symbol || "???",
|
||||||
|
// null means the explorer reported no usable scale — unknown,
|
||||||
|
// not 18. Distinguishable from a real 18 at read time is the
|
||||||
|
// entire point: resolveTokenDecimals() falls through a null to
|
||||||
|
// its refusal, and takes an 18 as the answer.
|
||||||
decimals: decimals,
|
decimals: decimals,
|
||||||
|
// null means nothing anywhere knows the scale, so there is no
|
||||||
|
// token quantity to state. Not "0.0": a nonzero holding shown
|
||||||
|
// as zero is the same lie in the balance list that the
|
||||||
|
// approval screens refuse to tell.
|
||||||
balance: bal,
|
balance: bal,
|
||||||
holders: holders,
|
holders: holders,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ function isRecord(value) {
|
|||||||
// alternative — refusing the whole record — sends a user whose wallets are
|
// alternative — refusing the whole record — sends a user whose wallets are
|
||||||
// perfectly readable to an export-or-erase screen over a token list. An entry
|
// perfectly readable to an export-or-erase screen over a token list. An entry
|
||||||
// that is a record with a text address is kept verbatim, extra fields and all.
|
// that is a record with a text address is kept verbatim, extra fields and all.
|
||||||
|
//
|
||||||
|
// Verbatim is load-bearing for the fields BESIDE the address. A tokenBalances
|
||||||
|
// entry carries `decimals: null` and `balance: null` when nothing knows the
|
||||||
|
// token's scale (src/shared/balances.js,
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/349), and those nulls are the
|
||||||
|
// record that the value is unknown. Only `address` decides whether an entry
|
||||||
|
// survives, so an unknown-scale holding is kept — flooring a null here to some
|
||||||
|
// default would put the guess back one layer down from where it was removed.
|
||||||
function tokenRefs(value) {
|
function tokenRefs(value) {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.filter(
|
return value.filter(
|
||||||
|
|||||||
@@ -78,9 +78,18 @@ function getAddressValue(addr) {
|
|||||||
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
||||||
let partial = false;
|
let partial = false;
|
||||||
for (const token of addr.tokenBalances || []) {
|
for (const token of addr.tokenBalances || []) {
|
||||||
const tokenBal = parseFloat(token.balance || "0");
|
// A null balance is a holding whose scale nothing knows, so it has no
|
||||||
|
// quantity to price — but it is still a holding, and a total that
|
||||||
|
// silently omits it would read as complete. That is exactly what
|
||||||
|
// `partial` is for (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||||
|
if (token.balance == null) {
|
||||||
|
partial = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tokenBal = parseFloat(token.balance);
|
||||||
// A balance of zero is not a holding: it can neither add to the total
|
// A balance of zero is not a holding: it can neither add to the total
|
||||||
// nor make it incomplete.
|
// nor make it incomplete. Anything that is not a number at all is not
|
||||||
|
// a holding this can price either, and is left to the same rule.
|
||||||
if (!(tokenBal > 0)) continue;
|
if (!(tokenBal > 0)) continue;
|
||||||
if (prices[token.symbol]) {
|
if (prices[token.symbol]) {
|
||||||
usd += tokenBal * prices[token.symbol];
|
usd += tokenBal * prices[token.symbol];
|
||||||
|
|||||||
@@ -33,10 +33,21 @@
|
|||||||
// tests/persistedFieldContract.test.js: one row per persisted field, naming
|
// tests/persistedFieldContract.test.js: one row per persisted field, naming
|
||||||
// the property that field's floor is claimed to have, and PROVING it by
|
// the property that field's floor is claimed to have, and PROVING it by
|
||||||
// driving the real code with hostile values — the gate for a field the gate
|
// driving the real code with hostile values — the gate for a field the gate
|
||||||
// refuses, normalizePersisted() for a field it floors, and a boot of the real
|
// refuses, normalizePersisted() for a field it floors, and, for a field whose
|
||||||
// popup entry point for a field whose only defence is that nothing
|
// only defence is that nothing dereferences it structurally, a boot of the
|
||||||
// dereferences it structurally. A field added to PERSISTED_FIELDS with no row
|
// real popup entry point onto EVERY view the popup can reopen onto.
|
||||||
// fails that suite; so does a row whose claim is false.
|
//
|
||||||
|
// That last part is the whole point, because this defect class lives on the
|
||||||
|
// RESTORE path and not on Home. Take the claim NARROWLY, exactly 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, 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
|
// 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
|
// hand-written justification per field, and it shipped a false one in three
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const { log, debugFetch } = require("./log");
|
|||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||||
|
// The uint8 test every scale in this wallet goes through. Shared, not copied:
|
||||||
|
// a scale is either reported or it is unknown, and "unknown" must mean the
|
||||||
|
// same thing here as it does on the screens that refuse to format one.
|
||||||
|
const { toDecimals } = require("./transferAmount");
|
||||||
// The plain 4-decimal rule. The history and balance lists deliberately keep
|
// The plain 4-decimal rule. The history and balance lists deliberately keep
|
||||||
// truncation without the approval screens' nonzero floor: the transaction
|
// truncation without the approval screens' nonzero floor: the transaction
|
||||||
// detail view is the authoritative record and already shows exact precision.
|
// detail view is the authoritative record and already shows exact precision.
|
||||||
@@ -92,21 +96,37 @@ function parseTx(tx, addrLower) {
|
|||||||
function parseTokenTransfer(tt, addrLower) {
|
function parseTokenTransfer(tt, addrLower) {
|
||||||
const from = tt.from?.hash || "";
|
const from = tt.from?.hash || "";
|
||||||
const to = tt.to?.hash || "";
|
const to = tt.to?.hash || "";
|
||||||
const decimals = parseInt(tt.total?.decimals || "18", 10);
|
// The explorer's own answer, or null. Never a default: a transfer of
|
||||||
|
// 5000000000 units formatted at a guessed 18 reads as 0.000000005, and
|
||||||
|
// nothing downstream can tell that from a real 18-decimal transfer of
|
||||||
|
// that size. `parseInt(x || "18", 10)` also collapsed a genuine scale of
|
||||||
|
// ZERO into 18 (https://git.eeqj.de/sneak/AutistMask/issues/246).
|
||||||
|
const decimals = toDecimals(tt.total?.decimals);
|
||||||
const rawVal = tt.total?.value || "0";
|
const rawVal = tt.total?.value || "0";
|
||||||
const direction =
|
const direction =
|
||||||
normalizeAddress(from) === addrLower ? "sent" : "received";
|
normalizeAddress(from) === addrLower ? "sent" : "received";
|
||||||
const sym = tt.token?.symbol || "?";
|
const sym = tt.token?.symbol || "?";
|
||||||
|
// Without a scale there is no token quantity, so none is stated: the list
|
||||||
|
// row falls back to the symbol alone and the detail screen to its
|
||||||
|
// direction label, exactly as the contract-call rows above already do.
|
||||||
|
// The exact figure is not lost — it is the base-unit line below, which is
|
||||||
|
// the one number that needs no scale to be true.
|
||||||
|
const formatted =
|
||||||
|
decimals === null ? "" : formatTxValue(formatUnits(rawVal, decimals));
|
||||||
|
const exact = decimals === null ? "" : formatUnits(rawVal, decimals);
|
||||||
return {
|
return {
|
||||||
hash: tt.transaction_hash,
|
hash: tt.transaction_hash,
|
||||||
blockNumber: tt.block_number,
|
blockNumber: tt.block_number,
|
||||||
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
|
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
|
||||||
from: from,
|
from: from,
|
||||||
to: to,
|
to: to,
|
||||||
value: formatTxValue(formatUnits(rawVal, decimals)),
|
value: formatted,
|
||||||
exactValue: formatUnits(rawVal, decimals),
|
exactValue: exact,
|
||||||
rawAmount: rawVal,
|
rawAmount: rawVal,
|
||||||
rawUnit: sym + " base units (10^-" + decimals + ")",
|
rawUnit:
|
||||||
|
decimals === null
|
||||||
|
? sym + " base units (decimals unknown)"
|
||||||
|
: sym + " base units (10^-" + decimals + ")",
|
||||||
valueGwei: null,
|
valueGwei: null,
|
||||||
symbol: sym,
|
symbol: sym,
|
||||||
direction: direction,
|
direction: direction,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function mismatchMessage(displayed, onChain) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A decimals value from either source as a number, or null if it is not one.
|
// A decimals value from any source as a number, or null if it is not one.
|
||||||
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
|
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
|
||||||
// as a string, so both of those are accepted alongside a plain number; anything
|
// as a string, so both of those are accepted alongside a plain number; anything
|
||||||
// fractional, negative, out of uint8 range, or of any other type at all is not.
|
// fractional, negative, out of uint8 range, or of any other type at all is not.
|
||||||
@@ -60,7 +60,16 @@ function mismatchMessage(displayed, onChain) {
|
|||||||
// The types are enumerated rather than coerced because Number() is far too
|
// The types are enumerated rather than coerced because Number() is far too
|
||||||
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
|
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
|
||||||
// admit an empty array as a scale of zero and encode a whole-token transfer
|
// admit an empty array as a scale of zero and encode a whole-token transfer
|
||||||
// against it.
|
// against it. Absence answers null and never a default, and a real scale of
|
||||||
|
// ZERO answers 0 — the two are different answers, which is the whole point:
|
||||||
|
// a falsy-collapsing `value || 18` cannot tell them apart, and neither can a
|
||||||
|
// reader of what it wrote (https://git.eeqj.de/sneak/AutistMask/issues/246).
|
||||||
|
//
|
||||||
|
// Exported because every module that has to decide whether it knows a token's
|
||||||
|
// scale needs exactly this test, and three separate copies of it is three
|
||||||
|
// places for the answer to drift: approvalAmount.js resolves the scale the
|
||||||
|
// approval screens display at, and balances.js decides what the explorer
|
||||||
|
// actually reported before it is stored.
|
||||||
function toDecimals(value) {
|
function toDecimals(value) {
|
||||||
let n;
|
let n;
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
@@ -110,6 +119,7 @@ module.exports = {
|
|||||||
displayedDecimals,
|
displayedDecimals,
|
||||||
transferAmountUnits,
|
transferAmountUnits,
|
||||||
mismatchMessage,
|
mismatchMessage,
|
||||||
|
toDecimals,
|
||||||
MAX_DECIMALS,
|
MAX_DECIMALS,
|
||||||
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
||||||
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
|
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
|
||||||
|
|||||||
203
tests/e2e/run.js
203
tests/e2e/run.js
@@ -1525,6 +1525,7 @@ 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
|
||||||
@@ -3262,6 +3263,8 @@ 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");
|
||||||
@@ -3425,6 +3428,206 @@ 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() {
|
||||||
|
|||||||
282
tests/fabricatedDecimals.test.js
Normal file
282
tests/fabricatedDecimals.test.js
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
// What the balance fetcher stores when the block explorer reports no decimals
|
||||||
|
// for a token, and what the approval screens then display.
|
||||||
|
//
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/349: `fetchTokenBalances()` did
|
||||||
|
// `parseInt(item.token.decimals || "18", 10)` BEFORE writing the row, so a
|
||||||
|
// token whose `decimals()` reverts — and which the explorer therefore reports
|
||||||
|
// no scale for — was stored with a fabricated 18. Nothing downstream could
|
||||||
|
// tell that from a real 18.
|
||||||
|
//
|
||||||
|
// That matters because it is upstream of two refusals that were already built
|
||||||
|
// and already merged. https://git.eeqj.de/sneak/AutistMask/issues/306 made the
|
||||||
|
// ERC-20 amount line resolve the real scale or refuse to format, and
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/340 did the same for the swap
|
||||||
|
// lines. Both read this stored value as an authoritative source, so the guess
|
||||||
|
// walked straight past them: the refusal was intact and simply never fired.
|
||||||
|
//
|
||||||
|
// So these tests run a real explorer response through the real fetcher and
|
||||||
|
// assert on the real approval screens. A test that hand-writes `decimals: null`
|
||||||
|
// onto state would pass on the broken build, because the fabrication is in the
|
||||||
|
// writer, not the readers.
|
||||||
|
|
||||||
|
jest.mock("../src/shared/log", () => ({
|
||||||
|
log: {
|
||||||
|
debugf: () => {},
|
||||||
|
infof: () => {},
|
||||||
|
warnf: () => {},
|
||||||
|
errorf: () => {},
|
||||||
|
},
|
||||||
|
debugFetch: jest.fn(),
|
||||||
|
setRuntimeDebug: () => {},
|
||||||
|
isDebug: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.fetch = jest.fn(() => {
|
||||||
|
throw new Error("tests must not perform network requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
global.chrome = { storage: makeStorageStub() };
|
||||||
|
|
||||||
|
const { AbiCoder, Interface } = require("ethers");
|
||||||
|
const { ERC20_ABI } = require("../src/shared/constants");
|
||||||
|
const { fetchTokenBalances } = require("../src/shared/balances");
|
||||||
|
const { debugFetch } = require("../src/shared/log");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
|
||||||
|
const { decodeCalldata } = require("../src/popup/views/approval");
|
||||||
|
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
|
||||||
|
|
||||||
|
const HOLDER = "0x" + "a".repeat(40);
|
||||||
|
const BLOCKSCOUT = "https://blockscout.example/api/v2";
|
||||||
|
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
||||||
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||||
|
const SPENDER = "0x1111111111111111111111111111111111111111";
|
||||||
|
// Outside the bundled list and untracked, so the explorer is the only source
|
||||||
|
// of a scale for it — which is the case the fabrication was hiding.
|
||||||
|
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
|
||||||
|
// In the bundled list, at 18 decimals, for the other side of a swap.
|
||||||
|
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||||
|
|
||||||
|
// The holding the explorer reports, in base units. Large enough that it does
|
||||||
|
// not round to zero even when divided by 10^18, which is what makes it the
|
||||||
|
// case the laundering actually REACHED: a smaller holding formatted at the
|
||||||
|
// fabricated 18 comes out "0.0", the balance list drops the row as dust, and
|
||||||
|
// the approval screens then find no source for the scale and refuse anyway —
|
||||||
|
// for the wrong reason, and only by luck.
|
||||||
|
const HOLDING = 5000000000000000000n;
|
||||||
|
|
||||||
|
// The amount in the dApp's calldata, which is a separate number from the
|
||||||
|
// holding. 1,000.00 of a 6-decimal token; formatted at the fabricated 18 it
|
||||||
|
// reads 0.000000001, and at a real scale of 0 it reads 1000000000.
|
||||||
|
const THOUSAND_AT_SIX = 1000000000n;
|
||||||
|
const HALF_WETH = 500000000000000000n;
|
||||||
|
|
||||||
|
const erc20Iface = new Interface(ERC20_ABI);
|
||||||
|
const coder = AbiCoder.defaultAbiCoder();
|
||||||
|
const routerIface = new Interface([
|
||||||
|
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// One Blockscout token-balances row. `token` is spread last so a test can
|
||||||
|
// override or blank a field; the base row carries no `decimals` at all, which
|
||||||
|
// is exactly what a token whose decimals() reverts produces.
|
||||||
|
function row(token = {}, value = HOLDING) {
|
||||||
|
return {
|
||||||
|
value: String(value),
|
||||||
|
token: {
|
||||||
|
type: "ERC-20",
|
||||||
|
address_hash: NOVEL,
|
||||||
|
symbol: "NOVEL",
|
||||||
|
name: "Novel Token",
|
||||||
|
// Well clear of the balance list's own spam floor, so the row is
|
||||||
|
// admitted on its holder count alone: neither the bundled list nor
|
||||||
|
// a tracked entry can supply a scale for it.
|
||||||
|
holders_count: "50000",
|
||||||
|
...token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondWith(items) {
|
||||||
|
debugFetch.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
json: async () => items,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and place the result exactly where refreshBalances() places it, so the
|
||||||
|
// approval screens read what a real refresh would have left on state.
|
||||||
|
async function fetchOnto(items, trackedTokens = []) {
|
||||||
|
respondWith(items);
|
||||||
|
const balances = await fetchTokenBalances(
|
||||||
|
HOLDER,
|
||||||
|
BLOCKSCOUT,
|
||||||
|
trackedTokens,
|
||||||
|
);
|
||||||
|
state.trackedTokens = trackedTokens;
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
addresses: [
|
||||||
|
{ address: HOLDER, balance: "1.0", tokenBalances: balances },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return balances;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ERC-20 approval screen's Amount line, and the swap decoder's.
|
||||||
|
function erc20AmountLine(data, tokenAddress) {
|
||||||
|
return decodeCalldata(data, tokenAddress).details.find(
|
||||||
|
(d) => d.label === "Amount",
|
||||||
|
).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapAmountLine(data) {
|
||||||
|
return decodeCalldata(data, ROUTER).details.find(
|
||||||
|
(d) => d.label === "Amount",
|
||||||
|
).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferData(amount) {
|
||||||
|
return erc20Iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function approveData(amount) {
|
||||||
|
return erc20Iface.encodeFunctionData("approve", [SPENDER, amount]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapData(tokenIn, amountIn, tokenOut, amountOutMin) {
|
||||||
|
const input = coder.encode(
|
||||||
|
["address", "uint256", "uint256", "address[]", "bool"],
|
||||||
|
[RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true],
|
||||||
|
);
|
||||||
|
return routerIface.encodeFunctionData("execute", [
|
||||||
|
"0x08",
|
||||||
|
[input],
|
||||||
|
9999999999n,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
debugFetch.mockReset();
|
||||||
|
state.trackedTokens = [];
|
||||||
|
state.wallets = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what fetchTokenBalances stores for an absent scale", () => {
|
||||||
|
test("the token is not in the bundled list, so the explorer is the only source", () => {
|
||||||
|
expect(TOKEN_BY_ADDRESS.has(NOVEL.toLowerCase())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an absent decimals is stored as null, not as 18", async () => {
|
||||||
|
const balances = await fetchOnto([row()]);
|
||||||
|
expect(balances).toHaveLength(1);
|
||||||
|
expect(balances[0].decimals).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an explicit null decimals is stored as null too", async () => {
|
||||||
|
const balances = await fetchOnto([row({ decimals: null })]);
|
||||||
|
expect(balances[0].decimals).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same explorer row twice, differing only in whether it reports a
|
||||||
|
// scale of 18. Before the fix both stored 18 and no reader could tell
|
||||||
|
// which one had actually been reported.
|
||||||
|
test("a real 18 is stored as 18, and so is distinguishable from absent", async () => {
|
||||||
|
const real = await fetchOnto([row({ decimals: "18" })]);
|
||||||
|
expect(real[0].decimals).toBe(18);
|
||||||
|
expect(real[0].balance).toBe("5.0");
|
||||||
|
const absent = await fetchOnto([row()]);
|
||||||
|
expect(absent[0].decimals).toBeNull();
|
||||||
|
expect(real[0].decimals).not.toBe(absent[0].decimals);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The falsy-collapse trap of
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/246. `decimals || "18"` reads
|
||||||
|
// a real scale of zero as absent and then as eighteen, which is eighteen
|
||||||
|
// orders of magnitude of error in the direction that displays as nothing.
|
||||||
|
test("a real scale of zero is stored as zero, not collapsed", async () => {
|
||||||
|
for (const reported of ["0", 0]) {
|
||||||
|
const balances = await fetchOnto([row({ decimals: reported })]);
|
||||||
|
expect(balances[0].decimals).toBe(0);
|
||||||
|
expect(balances[0].balance).toBe("5000000000000000000.0");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no quantity is stated for a holding whose scale is unknown", async () => {
|
||||||
|
const balances = await fetchOnto([row()]);
|
||||||
|
// Not "0.0": the holding is real and nonzero, and a zero here is the
|
||||||
|
// same lie the approval screens refuse to tell.
|
||||||
|
expect(balances[0].balance).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zero base units is zero tokens at every scale, so this filter never
|
||||||
|
// needed a scale in the first place and does not acquire one now.
|
||||||
|
test("a holding of zero base units is still dropped without a scale", async () => {
|
||||||
|
expect(await fetchOnto([row({}, 0n)])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the bundled list still supplies a quantity the explorer omitted", async () => {
|
||||||
|
const balances = await fetchOnto([
|
||||||
|
row({ address_hash: WETH, symbol: "WETH" }),
|
||||||
|
]);
|
||||||
|
// The stored decimals stay the explorer's own answer — absent. Copying
|
||||||
|
// another source in here would make explorerDecimals()'s disagreement
|
||||||
|
// check compare something other than explorer values.
|
||||||
|
expect(balances[0].decimals).toBeNull();
|
||||||
|
// The displayed quantity still comes out right, because the bundled
|
||||||
|
// list knows this token's scale and outranks the explorer anyway.
|
||||||
|
expect(balances[0].balance).toBe("5.0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the ERC-20 approval line reaches its refusal", () => {
|
||||||
|
test("a transfer of a token the explorer gave no scale for is not formatted", async () => {
|
||||||
|
await fetchOnto([row()]);
|
||||||
|
const line = erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL);
|
||||||
|
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
|
||||||
|
// The defect: a fabricated 18 renders this as 0.000000001, a quantity,
|
||||||
|
// and a wrong one.
|
||||||
|
expect(line).not.toMatch(/^0\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an approve of the same token is not formatted either", async () => {
|
||||||
|
await fetchOnto([row()]);
|
||||||
|
const line = erc20AmountLine(approveData(THOUSAND_AT_SIX), NOVEL);
|
||||||
|
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
|
||||||
|
expect(line).not.toMatch(/^0\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a scale the explorer did report still formats", async () => {
|
||||||
|
await fetchOnto([row({ decimals: "6" })]);
|
||||||
|
expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
|
||||||
|
"1000.0000",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the swap approval line reaches its refusal", () => {
|
||||||
|
test("a swap of a token the explorer gave no scale for is not formatted", async () => {
|
||||||
|
await fetchOnto([row()]);
|
||||||
|
const line = swapAmountLine(
|
||||||
|
swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH),
|
||||||
|
);
|
||||||
|
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
|
||||||
|
expect(line).not.toMatch(/^0\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a scale the explorer did report still formats", async () => {
|
||||||
|
await fetchOnto([row({ decimals: "6" })]);
|
||||||
|
expect(
|
||||||
|
swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
|
||||||
|
).toBe("1000.0000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no test in this file performed a network request", () => {
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -47,6 +47,12 @@ 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'"],
|
||||||
@@ -115,6 +121,51 @@ 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,6 +90,26 @@ 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(
|
||||||
|
|||||||
@@ -20,12 +20,56 @@
|
|||||||
// that no structural dereference of it is reachable from a
|
// that no structural dereference of it is reachable from a
|
||||||
// stored record — which cannot be argued, only driven, so the
|
// stored record — which cannot be argued, only driven, so the
|
||||||
// proof is a boot of the REAL popup entry point over a stored
|
// proof is a boot of the REAL popup entry point over a stored
|
||||||
// record carrying the hostile value.
|
// record carrying the hostile value, ONTO EVERY RESTORABLE
|
||||||
|
// VIEW. Home is not where this class of defect lives.
|
||||||
//
|
//
|
||||||
// Every row is driven through the boot regardless of kind, and a LOOSE row
|
// Every row is driven through a boot regardless of kind, but only a LOOSE row
|
||||||
// must additionally prove it is loose: if someone floors the field and leaves
|
// (or a row that sets `alsoSweep`) is swept across the restore path: that is
|
||||||
// the row saying LOOSE, the "survives verbatim" assertion fails. A field added
|
// what declaring LOOSE costs. ENTRIES and SCALAR rows are proven by their
|
||||||
// to PERSISTED_FIELDS with no row fails the first test in the file.
|
// 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:
|
// The three claims this replaced, all false, all caught here by construction:
|
||||||
// rpcUrl reaching `new JsonRpcProvider()` (a synchronous throw, not a caught
|
// rpcUrl reaching `new JsonRpcProvider()` (a synchronous throw, not a caught
|
||||||
@@ -61,12 +105,39 @@ const isIndexOrNull = (v) => v === null || (Number.isInteger(v) && v >= 0);
|
|||||||
const isTextOrNull = (v) => v === null || (isText(v) && v !== "");
|
const isTextOrNull = (v) => v === null || (isText(v) && v !== "");
|
||||||
const everyEntry = (v, fn) => Array.isArray(v) && v.every(fn);
|
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
|
// ------------------------------------------------------------------ the table
|
||||||
//
|
//
|
||||||
// `hostile` is values a stored record can carry that nothing in src/ ever
|
// `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,
|
// writes. Each one is driven through the floor AND through a real popup boot —
|
||||||
// so keep the list short and pointed. `floorOnly` is extra values checked
|
// and, for a swept row, through one boot per restorable view — so keep the
|
||||||
// against the floor alone, which is pure and free.
|
// 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 = [
|
const CONTRACT = [
|
||||||
{
|
{
|
||||||
@@ -213,6 +284,14 @@ const CONTRACT = [
|
|||||||
hostile: ["map", "__proto__", { a: 1 }],
|
hostile: ["map", "__proto__", { a: 1 }],
|
||||||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
||||||
holds: isIndexOrNull,
|
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",
|
field: "selectedAddress",
|
||||||
@@ -220,24 +299,110 @@ const CONTRACT = [
|
|||||||
hostile: ["map", "__proto__", { a: 1 }],
|
hostile: ["map", "__proto__", { a: 1 }],
|
||||||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
||||||
holds: isIndexOrNull,
|
holds: isIndexOrNull,
|
||||||
|
alsoSweep: true,
|
||||||
|
routes: true,
|
||||||
|
hostileRestore: [{ value: 5 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "currentView",
|
field: "currentView",
|
||||||
kind: KIND.LOOSE,
|
kind: KIND.LOOSE,
|
||||||
|
routes: true,
|
||||||
// Compared, and concatenated into the debug banner's textContent
|
// Compared, and concatenated into the debug banner's textContent
|
||||||
// (src/popup/views/helpers.js) with no gate in front of it, which
|
// (src/popup/views/helpers.js) with no gate in front of it, which
|
||||||
// coerces. Nothing renders FROM it without RESTORABLE_VIEWS.has()
|
// coerces. Nothing renders FROM it without RESTORABLE_VIEWS.has()
|
||||||
// first, and Set.has() answers false for any value.
|
// first, and Set.has() answers false for any value.
|
||||||
hostile: [42, "no-such-view", { a: 1 }],
|
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",
|
field: "viewData",
|
||||||
kind: KIND.LOOSE,
|
kind: KIND.LOOSE,
|
||||||
|
routes: true,
|
||||||
// The container is taken verbatim; what makes its ENTRIES safe is the
|
// The container is taken verbatim; what makes its ENTRIES safe is the
|
||||||
// per-branch guard in src/popup/viewRouter.js. Driven over every
|
// per-branch guard in src/popup/viewRouter.js. The sweep drives the
|
||||||
// restorable view in "a malformed viewData" below, which is the proof
|
// container shapes below onto every restorable view; hostileRestore
|
||||||
// this row rests on.
|
// adds the records that PASS a branch's gate and then hand its
|
||||||
hostile: [42, "notarecord", { a: 1 }],
|
// 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",
|
field: "lastBalanceRefresh",
|
||||||
@@ -245,6 +410,10 @@ const CONTRACT = [
|
|||||||
// Arithmetic only: `now - (s.lastBalanceRefresh || 0)` compares false
|
// Arithmetic only: `now - (s.lastBalanceRefresh || 0)` compares false
|
||||||
// for a non-number and forces a refresh.
|
// for a non-number and forces a refresh.
|
||||||
hostile: [true, "notatime", { a: 1 }],
|
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",
|
field: "tokenHolderCache",
|
||||||
@@ -254,6 +423,8 @@ const CONTRACT = [
|
|||||||
// src/shared/state.js, which are safe for any value, and otherwise
|
// src/shared/state.js, which are safe for any value, and otherwise
|
||||||
// only reset wholesale in src/shared/chainSwitchFields.js.
|
// only reset wholesale in src/shared/chainSwitchFields.js.
|
||||||
hostile: [42, "notarecord", [1, 2]],
|
hostile: [42, "notarecord", [1, 2]],
|
||||||
|
// `structuredClone(saved.tokenHolderCache || {})`.
|
||||||
|
neverFalsy: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "theme",
|
field: "theme",
|
||||||
@@ -261,11 +432,16 @@ const CONTRACT = [
|
|||||||
// Compared against "dark"/"light" in applyTheme() and otherwise falls
|
// Compared against "dark"/"light" in applyTheme() and otherwise falls
|
||||||
// to the system branch; assigned into an input .value, which coerces.
|
// to the system branch; assigned into an input .value, which coerces.
|
||||||
hostile: [42, "chartreuse", { a: 1 }],
|
hostile: [42, "chartreuse", { a: 1 }],
|
||||||
|
// `saved.theme || "system"`.
|
||||||
|
neverFalsy: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "dustThresholdGwei",
|
field: "dustThresholdGwei",
|
||||||
kind: KIND.LOOSE,
|
kind: KIND.LOOSE,
|
||||||
hostile: ["notanumber", true, { a: 1 }],
|
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",
|
"rememberSiteChoice",
|
||||||
@@ -281,6 +457,10 @@ const CONTRACT = [
|
|||||||
kind: KIND.LOOSE,
|
kind: KIND.LOOSE,
|
||||||
// A flag: only ever tested for truthiness, and written back verbatim.
|
// A flag: only ever tested for truthiness, and written back verbatim.
|
||||||
hostile: [42, "notabool", { a: 1 }],
|
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],
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -322,6 +502,10 @@ function profileWith(field, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("the floor each row claims", () => {
|
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) {
|
for (const row of CONTRACT) {
|
||||||
const values = [...row.hostile, ...(row.floorOnly || [])];
|
const values = [...row.hostile, ...(row.floorOnly || [])];
|
||||||
|
|
||||||
@@ -354,16 +538,22 @@ describe("the floor each row claims", () => {
|
|||||||
|
|
||||||
if (row.kind === KIND.LOOSE) {
|
if (row.kind === KIND.LOOSE) {
|
||||||
test(`${row.field}: is genuinely unfloored`, () => {
|
test(`${row.field}: is genuinely unfloored`, () => {
|
||||||
const survived = values.some((value) => {
|
// 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(
|
const out = normalizePersisted(
|
||||||
profileWith(row.field, value),
|
profileWith(row.field, value),
|
||||||
);
|
);
|
||||||
return (
|
expect({
|
||||||
JSON.stringify(out[row.field]) === JSON.stringify(value)
|
value: value,
|
||||||
);
|
survived: JSON.stringify(out[row.field]),
|
||||||
});
|
}).toEqual({
|
||||||
|
value: value,
|
||||||
expect(survived).toBe(true);
|
survived: JSON.stringify(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,9 +575,53 @@ async function bootHealth(profile) {
|
|||||||
|
|
||||||
const HEALTHY = { errors: [], blank: false };
|
const HEALTHY = { errors: [], blank: false };
|
||||||
|
|
||||||
describe("a hostile value for one field, through the real popup", () => {
|
// 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) {
|
for (const row of CONTRACT) {
|
||||||
for (const value of row.hostile) {
|
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 () => {
|
test(`${row.field} = ${JSON.stringify(value)}`, async () => {
|
||||||
await expect(
|
await expect(
|
||||||
bootHealth(profileWith(row.field, value)),
|
bootHealth(profileWith(row.field, value)),
|
||||||
@@ -409,72 +643,49 @@ describe("a row's extra proof against the real reader", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------- viewData, entry by entry
|
// ------------------------------------------------ driving the restore path
|
||||||
|
|
||||||
// The views that read viewData.
|
// Everything above lands on Home. Home is not where this class of defect
|
||||||
const DATA_VIEWS = [
|
// lives: all three of the false claims this file replaced were falsified by a
|
||||||
"confirm-tx",
|
// RESTORE, through the unguarded restoreView() in src/popup/index.js. So a
|
||||||
"transaction",
|
// swept row's hostile values are driven onto EVERY restorable view, one boot
|
||||||
"wait-tx",
|
// each.
|
||||||
"success-tx",
|
//
|
||||||
"error-tx",
|
// 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.
|
||||||
|
|
||||||
// Each record below PASSES the gate of the branch it names, and then carries a
|
// restoreWait() resumes from this, so it has to be a finite number and recent
|
||||||
// value that branch's renderer dereferences. `views` is where it is driven from
|
// enough that the resumed deadline has not already passed — a wait that has
|
||||||
// — the whole set for a value that is not a record at all, and otherwise the
|
// outlived its deadline resolves on the first poll instead of staying on
|
||||||
// branch it targets, since the cross-view case is covered by EVERY_GATE below.
|
// screen. Read once at module load, so every boot in one run shares it.
|
||||||
const HOSTILE_VIEW_DATA = [
|
const BROADCAST_TIME = Date.now();
|
||||||
{ data: 42, views: DATA_VIEWS },
|
|
||||||
{ data: "notarecord", views: DATA_VIEWS },
|
// A viewData well formed for every restorable branch at once, so the only
|
||||||
{ data: [1, 2], views: DATA_VIEWS },
|
// thing a swept boot can fail on is the field the row corrupts. "the base
|
||||||
// success-tx passes on `data.hash`, and renderSuccess() then calls
|
// profile the sweep corrupts" below proves this really does render each view
|
||||||
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
|
// rather than falling back — without that, a sweep could pass by never
|
||||||
{ data: { hash: "0x1" }, views: ["success-tx"] },
|
// reaching a renderer at all.
|
||||||
{ data: { hash: "0x1", to: 42 }, views: ["success-tx"] },
|
const WELL_FORMED_DATA = {
|
||||||
{
|
hash: "0x1",
|
||||||
data: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
|
message: "boom",
|
||||||
views: ["success-tx"],
|
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: {
|
||||||
data: {
|
hash: "0x1",
|
||||||
hash: "0x1",
|
txInfo: { to: ADDRESS, amount: "1" },
|
||||||
to: ADDRESS,
|
broadcastTime: BROADCAST_TIME,
|
||||||
decoded: { details: [{ address: 42 }] },
|
|
||||||
},
|
|
||||||
views: ["success-tx"],
|
|
||||||
},
|
},
|
||||||
// error-tx passes on `data.message`, same dereference.
|
};
|
||||||
{ data: { message: "boom" }, views: ["error-tx"] },
|
|
||||||
{ data: { message: "boom", to: 42 }, views: ["error-tx"] },
|
|
||||||
// transaction passes on `data.tx`.
|
|
||||||
{ data: { tx: { hash: "0x1" } }, views: ["transaction"] },
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
tx: {
|
|
||||||
hash: "0x1",
|
|
||||||
from: ADDRESS,
|
|
||||||
to: ADDRESS,
|
|
||||||
contractAddress: 42,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
views: ["transaction"],
|
|
||||||
},
|
|
||||||
// confirm-tx passes on `data.pendingTx`.
|
|
||||||
{ data: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] },
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
pendingTx: { token: 42, from: ADDRESS, to: ADDRESS, amount: "1" },
|
|
||||||
},
|
|
||||||
views: ["confirm-tx"],
|
|
||||||
},
|
|
||||||
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked the
|
|
||||||
// fields below it since it was written, and this is the regression guard.
|
|
||||||
{
|
|
||||||
data: { pendingWait: { hash: "0x1", txInfo: { to: 42, amount: "1" } } },
|
|
||||||
views: ["wait-tx"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function restoringOnto(view, extra) {
|
function restoringOnto(view, extra) {
|
||||||
return unversionedValidProfile({
|
return unversionedValidProfile({
|
||||||
@@ -483,88 +694,171 @@ function restoringOnto(view, extra) {
|
|||||||
selectedAddress: 0,
|
selectedAddress: 0,
|
||||||
selectedToken: TOKEN_ADDRESS,
|
selectedToken: TOKEN_ADDRESS,
|
||||||
viewStack: ["main"],
|
viewStack: ["main"],
|
||||||
|
viewData: WELL_FORMED_DATA,
|
||||||
...extra,
|
...extra,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("a malformed viewData restoring onto", () => {
|
// A boot that RESTORED is healthy and landed on the view it stored, rather
|
||||||
for (const { data, views } of HOSTILE_VIEW_DATA) {
|
// than falling back to Home — which a healthy boot also does, and which would
|
||||||
for (const view of views) {
|
// let a sweep pass by never running the renderer it is aimed at.
|
||||||
test(`${view}: ${JSON.stringify(data)}`, async () => {
|
async function restoredHealth(profile, view) {
|
||||||
await expect(
|
const env = await bootPopup(profile);
|
||||||
bootHealth(restoringOnto(view, { viewData: data })),
|
return {
|
||||||
).resolves.toEqual(HEALTHY);
|
errors: env.pageErrors,
|
||||||
});
|
restored: env.visibleViews().includes(view),
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every restorable view, against one record that passes every branch's
|
|
||||||
// gate at once: a branch a view does not read must stay one it does not
|
|
||||||
// read, and each renderer must survive the fields another branch left.
|
|
||||||
const EVERY_GATE = {
|
|
||||||
hash: "0x1",
|
|
||||||
message: "boom",
|
|
||||||
tx: { hash: "0x1" },
|
|
||||||
pendingTx: { amount: "1" },
|
|
||||||
pendingWait: { hash: "0x1" },
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESTORED = { errors: [], restored: true };
|
||||||
|
|
||||||
|
describe("the base profile the sweep corrupts", () => {
|
||||||
for (const view of RESTORABLE_VIEWS) {
|
for (const view of RESTORABLE_VIEWS) {
|
||||||
test(`${view}: a record passing every branch's gate at once`, async () => {
|
test(`renders ${view} rather than falling back`, async () => {
|
||||||
await expect(
|
await expect(
|
||||||
bootHealth(restoringOnto(view, { viewData: EVERY_GATE })),
|
restoredHealth(restoringOnto(view), view),
|
||||||
).resolves.toEqual(HEALTHY);
|
).resolves.toEqual(RESTORED);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --------------------------------------- selectedWallet / selectedAddress
|
// 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);
|
||||||
|
|
||||||
// `wallets` is a real Array, so a selectedWallet naming an Array.prototype or
|
// Every routing row × every hostile value × every restorable view. Profiles
|
||||||
// Object.prototype member is TRUTHY: hasValidAddress()'s `&&` does not
|
// are deduplicated because a hostile `currentView` REPLACES the view being
|
||||||
// short-circuit, `.addresses` is undefined, and the index access throws out of
|
// restored onto, which would otherwise be the same boot eleven times.
|
||||||
// restoreView(). A stale INTEGER is falsy-or-in-range and safe — the opposite
|
describe("a hostile routing value restoring onto", () => {
|
||||||
// way round from how this pair was described.
|
for (const row of CONTRACT) {
|
||||||
const HOSTILE_INDEX = [
|
if (!swept(row) || !routes(row)) continue;
|
||||||
{ selectedWallet: "map", selectedAddress: 0 },
|
const seen = new Set();
|
||||||
{ selectedWallet: "length", selectedAddress: 0 },
|
for (const value of sweptValues(row)) {
|
||||||
{ selectedWallet: "__proto__", selectedAddress: 0 },
|
for (const view of RESTORABLE_VIEWS) {
|
||||||
{ selectedWallet: "constructor", selectedAddress: 0 },
|
const profile = restoringOnto(view, { [row.field]: value });
|
||||||
{ selectedWallet: 0, selectedAddress: "map" },
|
const key = JSON.stringify(profile);
|
||||||
{ selectedWallet: 5, selectedAddress: 0 },
|
if (seen.has(key)) continue;
|
||||||
];
|
seen.add(key);
|
||||||
|
test(`${view}: ${row.field} = ${JSON.stringify(
|
||||||
|
value,
|
||||||
|
)}`, async () => {
|
||||||
|
await expect(bootHealth(profile)).resolves.toEqual(HEALTHY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const INDEX_VIEWS = [
|
// Every OTHER swept field, corrupted at once, one boot per view per hostile
|
||||||
"address",
|
// slot: twelve fields on one boot rather than twelve boots. A field is only in
|
||||||
"address-token",
|
// here because it is not one the router reads — and that is ASSERTED, not
|
||||||
"receive",
|
// argued, because the boot has to land on `view`. A field that does move the
|
||||||
"transaction",
|
// routing turns this red and has to declare `routes` and take the individual
|
||||||
"confirm-tx",
|
// 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),
|
||||||
|
);
|
||||||
|
|
||||||
describe("a malformed wallet or address index restoring onto", () => {
|
function unroutedValues(slot) {
|
||||||
const WELL_FORMED_DATA = {
|
const fields = {};
|
||||||
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS },
|
for (const row of UNROUTED) {
|
||||||
pendingTx: {
|
const values = sweptValues(row);
|
||||||
token: "ETH",
|
fields[row.field] = values[slot % values.length];
|
||||||
from: ADDRESS,
|
}
|
||||||
to: ADDRESS,
|
return fields;
|
||||||
amount: "1",
|
}
|
||||||
balance: "2",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const view of INDEX_VIEWS) {
|
describe("every field the router does not read, corrupted at once, onto", () => {
|
||||||
for (const indices of HOSTILE_INDEX) {
|
for (const view of RESTORABLE_VIEWS) {
|
||||||
test(`${view}: ${JSON.stringify(indices)}`, async () => {
|
for (let slot = 0; slot < HOSTILE_SLOTS; slot++) {
|
||||||
await expect(
|
test(`${view}: hostile value ${slot + 1} in all ${
|
||||||
bootHealth(
|
UNROUTED.length
|
||||||
restoringOnto(view, {
|
} of them`, async () => {
|
||||||
...indices,
|
const fields = unroutedValues(slot);
|
||||||
viewData: WELL_FORMED_DATA,
|
const together = await restoredHealth(
|
||||||
}),
|
restoringOnto(view, fields),
|
||||||
),
|
view,
|
||||||
).resolves.toEqual(HEALTHY);
|
);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -120,6 +120,20 @@ function makeElement(id, className) {
|
|||||||
el.clicked += 1;
|
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;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
185
tests/unknownScaleDisplay.test.js
Normal file
185
tests/unknownScaleDisplay.test.js
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
// What the screens that READ a stored token balance do with a holding whose
|
||||||
|
// scale nothing knows.
|
||||||
|
//
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/349 stopped `fetchTokenBalances()`
|
||||||
|
// fabricating a scale of 18, so a row it cannot state a quantity for is now
|
||||||
|
// stored with `balance: null`. Every reader of that field therefore has two
|
||||||
|
// distinct inputs where it used to have one, and the property that has to hold
|
||||||
|
// at each of them is the same one this codebase keeps losing:
|
||||||
|
//
|
||||||
|
// null (unknown) and 0 (genuinely zero) must produce DIFFERENT output.
|
||||||
|
//
|
||||||
|
// Losing it is what https://git.eeqj.de/sneak/AutistMask/issues/246,
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/306,
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/322,
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/359 and
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/364 each were. So every case
|
||||||
|
// below asserts the pair, not just that the null branch does something
|
||||||
|
// reasonable: an assertion on the null alone still passes on a build that
|
||||||
|
// renders both as zero, which is precisely the build being guarded against.
|
||||||
|
//
|
||||||
|
// The writer half — that the fetcher stores null rather than 18 — is in
|
||||||
|
// tests/fabricatedDecimals.test.js, and the Send and confirmation screens are
|
||||||
|
// in tests/unknownScaleSend.test.js.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// helpers.js reaches for both at module scope through the modules it pulls in.
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: () => Promise.resolve({}),
|
||||||
|
set: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtime: { sendMessage: () => {} },
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: () => null,
|
||||||
|
createElement: () => ({ style: {}, classList: { toggle() {} } }),
|
||||||
|
body: { prepend: () => {} },
|
||||||
|
addEventListener: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
balanceLine,
|
||||||
|
balanceLinesForAddress,
|
||||||
|
addressHoldsFunds,
|
||||||
|
} = require("../src/popup/views/helpers");
|
||||||
|
const {
|
||||||
|
prices,
|
||||||
|
clearPrices,
|
||||||
|
getAddressValue,
|
||||||
|
} = require("../src/shared/prices");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
|
||||||
|
const NOVEL = "0x1111111111111111111111111111111111111111";
|
||||||
|
|
||||||
|
// One stored tokenBalances row. `balance: null` is what balances.js writes for
|
||||||
|
// a holding whose scale nothing knows; "0.0" is a quantity that was actually
|
||||||
|
// established and is zero.
|
||||||
|
function holding(balance) {
|
||||||
|
return {
|
||||||
|
address: NOVEL,
|
||||||
|
symbol: "NOVEL",
|
||||||
|
decimals: balance === null ? null : 18,
|
||||||
|
balance,
|
||||||
|
holders: 50000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function address(balance) {
|
||||||
|
return {
|
||||||
|
address: "0x" + "a".repeat(40),
|
||||||
|
balance: "0",
|
||||||
|
tokenBalances: [holding(balance)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The quantity cell of a rendered row, which is the second of the two spans
|
||||||
|
// inside the fixed-width span.
|
||||||
|
function quantities(html) {
|
||||||
|
return [...html.matchAll(/<span>([^<]*)<\/span>/g)].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearPrices();
|
||||||
|
state.wallets = [];
|
||||||
|
state.trackedTokens = [];
|
||||||
|
state.activeAddress = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearPrices();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("balanceLine", () => {
|
||||||
|
test("an unknown quantity and a zero one render differently", () => {
|
||||||
|
const unknown = balanceLine("NOVEL", null, null, NOVEL);
|
||||||
|
const zero = balanceLine("NOVEL", 0, null, NOVEL);
|
||||||
|
expect(unknown).not.toBe(zero);
|
||||||
|
expect(quantities(unknown)).toEqual(["NOVEL", "quantity unknown"]);
|
||||||
|
expect(quantities(zero)).toEqual(["NOVEL", "0.0000"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown quantity produces no fiat figure, a zero one does", () => {
|
||||||
|
prices.NOVEL = 3;
|
||||||
|
const unknown = balanceLine("NOVEL", null, 3, NOVEL);
|
||||||
|
const zero = balanceLine("NOVEL", 0, 3, NOVEL);
|
||||||
|
// A price times an unknown quantity is not $0.00: that is the same
|
||||||
|
// claim of "nothing here" the quantity cell just refused to make.
|
||||||
|
expect(unknown).toContain(
|
||||||
|
'<span class="text-right text-muted flex-1"> </span>',
|
||||||
|
);
|
||||||
|
expect(zero).toContain(
|
||||||
|
'<span class="text-right text-muted flex-1">$0.00</span>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("balanceLinesForAddress", () => {
|
||||||
|
// The show-zero setting is a statement about zeroes. An unknown quantity
|
||||||
|
// is not one, so hiding the row would assert the zero nobody established
|
||||||
|
// and the holding would vanish from the list entirely.
|
||||||
|
test("hiding zero balances hides the zero row and keeps the unknown one", () => {
|
||||||
|
const unknown = balanceLinesForAddress(address(null), [], false);
|
||||||
|
const zero = balanceLinesForAddress(address("0.0"), [], false);
|
||||||
|
expect(unknown).not.toBe(zero);
|
||||||
|
expect(unknown).toContain("quantity unknown");
|
||||||
|
expect(unknown).toContain("NOVEL");
|
||||||
|
expect(zero).not.toContain("NOVEL");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("showing zero balances still tells the two apart", () => {
|
||||||
|
const unknown = balanceLinesForAddress(address(null), [], true);
|
||||||
|
const zero = balanceLinesForAddress(address("0.0"), [], true);
|
||||||
|
expect(unknown).not.toBe(zero);
|
||||||
|
expect(quantities(unknown)).toEqual([
|
||||||
|
"ETH",
|
||||||
|
"0.0000",
|
||||||
|
"NOVEL",
|
||||||
|
"quantity unknown",
|
||||||
|
]);
|
||||||
|
expect(quantities(zero)).toEqual(["ETH", "0.0000", "NOVEL", "0.0000"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addressHoldsFunds", () => {
|
||||||
|
// Read by deleteAddress.js to decide whether removing the address is
|
||||||
|
// warned about. balances.js drops a row of zero base units before any
|
||||||
|
// scale is consulted, so a row that survived with no quantity is holding
|
||||||
|
// something, and the warning must err towards warning.
|
||||||
|
test("an unknown balance holds funds, a zero balance does not", () => {
|
||||||
|
expect(addressHoldsFunds(address(null))).toBe(true);
|
||||||
|
expect(addressHoldsFunds(address("0.0"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAddressValue", () => {
|
||||||
|
// `usd` is the value of what could be priced and `partial` says it is a
|
||||||
|
// floor rather than the total. An unpriceable holding is exactly what
|
||||||
|
// `partial` exists for; a holding of zero can neither add to the total nor
|
||||||
|
// make it incomplete.
|
||||||
|
test("an unknown balance makes the total partial, a zero balance does not", () => {
|
||||||
|
prices.ETH = 2000;
|
||||||
|
prices.NOVEL = 3;
|
||||||
|
const unknown = getAddressValue(address(null));
|
||||||
|
const zero = getAddressValue(address("0.0"));
|
||||||
|
expect(unknown).not.toEqual(zero);
|
||||||
|
expect(unknown).toEqual({ usd: 0, partial: true });
|
||||||
|
expect(zero).toEqual({ usd: 0, partial: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown balance is not priced as zero of the token", () => {
|
||||||
|
prices.ETH = 2000;
|
||||||
|
prices.NOVEL = 3;
|
||||||
|
// The same row with a real quantity of 10 is worth $30. Neither that
|
||||||
|
// figure nor a confident $0.00 may be stated for the unknown one.
|
||||||
|
expect(getAddressValue(address("10.0"))).toEqual({
|
||||||
|
usd: 30,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
expect(getAddressValue(address(null)).usd).toBe(0);
|
||||||
|
expect(getAddressValue(address(null)).partial).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
455
tests/unknownScaleSend.test.js
Normal file
455
tests/unknownScaleSend.test.js
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
// The Send and confirmation screens for a token whose explorer row carries no
|
||||||
|
// decimals.
|
||||||
|
//
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/349 made `fetchTokenBalances()`
|
||||||
|
// store the explorer's own answer — `null` when it reported none — while the
|
||||||
|
// scale a balance is DISPLAYED at is resolved separately: bundled list, then
|
||||||
|
// the user's tracked tokens, then the explorer. The two are different
|
||||||
|
// questions, and `tokenBalances[].decimals` only answers the second one.
|
||||||
|
//
|
||||||
|
// A reader that takes the stored field for the display scale therefore gets
|
||||||
|
// `null` for a token the wallet does know the scale of. On the Send path that
|
||||||
|
// null reaches `displayedDecimals()` inside `estimateGas()`, which throws, is
|
||||||
|
// caught as an unavailable fee, and disables Send behind "The network fee could
|
||||||
|
// not be estimated" — untrue, unactionable, and for a bundled token like WETH
|
||||||
|
// or DAI whose scale was never in doubt. So the Send screen resolves the scale
|
||||||
|
// the same way the balance list did, and only carries a null forward when that
|
||||||
|
// resolution genuinely answers null.
|
||||||
|
//
|
||||||
|
// Driven through the real `fetchTokenBalances()`, the real Send review handler
|
||||||
|
// and the real confirmation screen: a test that hand-wrote `decimals: null`
|
||||||
|
// onto state would not show which of the two questions each screen is asking.
|
||||||
|
//
|
||||||
|
// The reader sites that are pure display are in tests/unknownScaleDisplay.test.js,
|
||||||
|
// and what the fetcher stores is in tests/fabricatedDecimals.test.js.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
jest.mock("../src/shared/log", () => ({
|
||||||
|
log: {
|
||||||
|
debugf: () => {},
|
||||||
|
infof: () => {},
|
||||||
|
warnf: () => {},
|
||||||
|
errorf: () => {},
|
||||||
|
},
|
||||||
|
debugFetch: jest.fn(),
|
||||||
|
setRuntimeDebug: () => {},
|
||||||
|
isDebug: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Everything the confirmation screen would reach the network for. The gas
|
||||||
|
// estimate is the point: with a usable scale it must succeed, so that a failure
|
||||||
|
// in these tests is a failure of the scale and not of the stub.
|
||||||
|
const mockProvider = {
|
||||||
|
getFeeData: async () => ({
|
||||||
|
maxFeePerGas: 2000000000n,
|
||||||
|
gasPrice: 1000000000n,
|
||||||
|
}),
|
||||||
|
estimateGas: async () => 21000n,
|
||||||
|
getCode: async () => "0x",
|
||||||
|
getTransactionCount: async () => 1,
|
||||||
|
getBalance: async () => 0n,
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("../src/shared/balances", () => {
|
||||||
|
const actual = jest.requireActual("../src/shared/balances");
|
||||||
|
return { ...actual, getProvider: () => mockProvider };
|
||||||
|
});
|
||||||
|
|
||||||
|
// The confirmation screen's best-effort Etherscan label lookup is the one
|
||||||
|
// thing here that reaches for fetch(). It is stubbed to fail, which is the
|
||||||
|
// path it already takes offline; the assertion at the bottom of this file
|
||||||
|
// pins that it is the ONLY fetch these screens make.
|
||||||
|
global.fetch = jest.fn(() => {
|
||||||
|
throw new Error("tests must not perform network requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
||||||
|
|
||||||
|
// A stub DOM. Every id in index.html that these two views touch resolves to a
|
||||||
|
// fresh recording element; nothing here depends on layout, only on what the
|
||||||
|
// views write into the elements and which handlers they register.
|
||||||
|
const elements = new Map();
|
||||||
|
|
||||||
|
function makeEl(id) {
|
||||||
|
const handlers = new Map();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
innerHTML: "",
|
||||||
|
value: "",
|
||||||
|
disabled: false,
|
||||||
|
onclick: null,
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
classList: {
|
||||||
|
add() {},
|
||||||
|
remove() {},
|
||||||
|
toggle() {},
|
||||||
|
contains: () => false,
|
||||||
|
},
|
||||||
|
handlers,
|
||||||
|
addEventListener(name, fn) {
|
||||||
|
handlers.set(name, fn);
|
||||||
|
},
|
||||||
|
appendChild(child) {
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
querySelector: () => null,
|
||||||
|
remove() {},
|
||||||
|
focus() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
global.document = {
|
||||||
|
getElementById(id) {
|
||||||
|
if (!elements.has(id)) elements.set(id, makeEl(id));
|
||||||
|
return elements.get(id);
|
||||||
|
},
|
||||||
|
createElement: (tag) => makeEl(tag),
|
||||||
|
body: { prepend() {}, appendChild() {} },
|
||||||
|
addEventListener() {},
|
||||||
|
};
|
||||||
|
global.navigator = { clipboard: { writeText() {} } };
|
||||||
|
|
||||||
|
const { parseUnits } = require("ethers");
|
||||||
|
const { fetchTokenBalances } = require("../src/shared/balances");
|
||||||
|
const { debugFetch } = require("../src/shared/log");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const {
|
||||||
|
displayedDecimals,
|
||||||
|
transferAmountUnits,
|
||||||
|
} = require("../src/shared/transferAmount");
|
||||||
|
const send = require("../src/popup/views/send");
|
||||||
|
const confirmTx = require("../src/popup/views/confirmTx");
|
||||||
|
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
|
||||||
|
|
||||||
|
const HOLDER = "0x" + "a".repeat(40);
|
||||||
|
const SECOND_HOLDER = "0x" + "b".repeat(40);
|
||||||
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||||
|
const BLOCKSCOUT = "https://blockscout.example/api/v2";
|
||||||
|
// Bundled, 18 decimals. The wallet knows this token's scale without asking
|
||||||
|
// anyone, which is what makes an unsendable WETH a regression rather than a
|
||||||
|
// refusal.
|
||||||
|
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||||
|
// Neither bundled nor tracked, so the explorer is the only possible source and
|
||||||
|
// an omission there really is an unknown scale.
|
||||||
|
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
|
||||||
|
|
||||||
|
const FIVE_WETH = 5000000000000000000n;
|
||||||
|
|
||||||
|
function row(token = {}, value = FIVE_WETH) {
|
||||||
|
return {
|
||||||
|
value: String(value),
|
||||||
|
token: {
|
||||||
|
type: "ERC-20",
|
||||||
|
address_hash: WETH,
|
||||||
|
symbol: "WETH",
|
||||||
|
name: "Wrapped Ether",
|
||||||
|
holders_count: "50000",
|
||||||
|
...token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the explorer's rows through the real fetcher and put them exactly where
|
||||||
|
// refreshBalances() puts them.
|
||||||
|
async function fetchOnto(items) {
|
||||||
|
debugFetch.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
json: async () => items,
|
||||||
|
}));
|
||||||
|
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
addresses: [
|
||||||
|
{ address: HOLDER, balance: "1.0", tokenBalances: balances },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = 0;
|
||||||
|
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) {
|
||||||
|
return global.document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Press Review on the Send screen and return the txInfo it hands the
|
||||||
|
// confirmation screen.
|
||||||
|
async function reviewSend(tokenAddress, amount) {
|
||||||
|
let handed = null;
|
||||||
|
send.init({ showConfirmTx: (info) => (handed = info) });
|
||||||
|
state.selectedToken = tokenAddress;
|
||||||
|
el("send-token").value = tokenAddress;
|
||||||
|
el("send-to").value = RECIPIENT;
|
||||||
|
el("send-amount").value = amount;
|
||||||
|
await el("btn-send-review").handlers.get("click")();
|
||||||
|
return handed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// show() kicks off the gas estimate without awaiting it; this lets it settle.
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(id) {
|
||||||
|
return el(id).textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errors() {
|
||||||
|
return el("confirm-errors").innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendDisabled() {
|
||||||
|
return el("btn-confirm-send").disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
elements.clear();
|
||||||
|
debugFetch.mockReset();
|
||||||
|
state.wallets = [];
|
||||||
|
state.trackedTokens = [];
|
||||||
|
state.selectedToken = null;
|
||||||
|
state.fraudContracts = [];
|
||||||
|
state.hideLowHolderTokens = false;
|
||||||
|
state.currentView = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the Send screen resolves the scale rather than reading the stored one", () => {
|
||||||
|
test("the bundled list knows WETH, and the explorer row does not report a scale", async () => {
|
||||||
|
expect(TOKEN_BY_ADDRESS.get(WETH.toLowerCase()).decimals).toBe(18);
|
||||||
|
const balances = await fetchOnto([row()]);
|
||||||
|
// Stored: the explorer's own answer, which is nothing. Reading THIS is
|
||||||
|
// what carried a null into the fee estimate.
|
||||||
|
expect(balances[0].decimals).toBeNull();
|
||||||
|
// Displayed: the bundled scale, so the quantity on screen is real.
|
||||||
|
expect(balances[0].balance).toBe("5.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the review hands the confirmation screen the resolved scale, not the stored null", async () => {
|
||||||
|
const balances = await fetchOnto([row()]);
|
||||||
|
const txInfo = await reviewSend(WETH, "1.5");
|
||||||
|
expect(txInfo.tokenDecimals).toBe(18);
|
||||||
|
expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals);
|
||||||
|
expect(txInfo.tokenBalance).toBe("5.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("that scale estimates a fee and leaves Send enabled", async () => {
|
||||||
|
await fetchOnto([row()]);
|
||||||
|
const txInfo = await reviewSend(WETH, "1.5");
|
||||||
|
confirmTx.show(txInfo);
|
||||||
|
await settle();
|
||||||
|
// The regression: displayedDecimals(null) threw in estimateGas(), the
|
||||||
|
// catch reported the fee as unknown, and Send stayed disabled behind a
|
||||||
|
// message about the network fee that no retry could clear.
|
||||||
|
expect(text("confirm-fee-amount")).not.toBe("Unable to estimate");
|
||||||
|
expect(text("confirm-fee-amount")).toContain("ETH");
|
||||||
|
expect(errors()).toBe("");
|
||||||
|
expect(sendDisabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and the transfer encodes at the scale that was displayed", async () => {
|
||||||
|
await fetchOnto([row()]);
|
||||||
|
const txInfo = await reviewSend(WETH, "1.5");
|
||||||
|
// The two calls confirmTx makes with this field: the gas estimate's
|
||||||
|
// scale, and the encode, which compares it against the contract's own
|
||||||
|
// decimals() before parsing.
|
||||||
|
expect(displayedDecimals(txInfo.tokenDecimals)).toBe(18);
|
||||||
|
expect(
|
||||||
|
transferAmountUnits(txInfo.amount, txInfo.tokenDecimals, 18n),
|
||||||
|
).toBe(parseUnits("1.5", 18));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a token nothing knows the scale of is still refused, and says why", async () => {
|
||||||
|
await fetchOnto([
|
||||||
|
row({ address_hash: NOVEL, symbol: "NOVEL", name: "Novel Token" }),
|
||||||
|
]);
|
||||||
|
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||||
|
// No fallback was introduced: resolution answers null here, and the
|
||||||
|
// null is what goes forward.
|
||||||
|
expect(txInfo.tokenDecimals).toBeNull();
|
||||||
|
expect(txInfo.tokenBalance).toBeNull();
|
||||||
|
confirmTx.show(txInfo);
|
||||||
|
await settle();
|
||||||
|
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
|
||||||
|
expect(errors()).toContain("This token's balance is unknown");
|
||||||
|
expect(sendDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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", () => {
|
||||||
|
function txInfo(tokenBalance) {
|
||||||
|
return {
|
||||||
|
from: HOLDER,
|
||||||
|
to: RECIPIENT,
|
||||||
|
ensName: null,
|
||||||
|
amount: "1.5",
|
||||||
|
token: NOVEL,
|
||||||
|
balance: "1.0",
|
||||||
|
tokenSymbol: "NOVEL",
|
||||||
|
tokenBalance,
|
||||||
|
tokenDecimals: tokenBalance === null ? null : 18,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(tokenBalance) {
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
addresses: [
|
||||||
|
{ address: HOLDER, balance: "1.0", tokenBalances: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = 0;
|
||||||
|
confirmTx.show(txInfo(tokenBalance));
|
||||||
|
await settle();
|
||||||
|
return { balance: text("confirm-balance"), errors: errors() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the balance line states unknown rather than a quantity of zero", async () => {
|
||||||
|
const unknown = await render(null);
|
||||||
|
const zero = await render("0.0");
|
||||||
|
expect(unknown.balance).not.toBe(zero.balance);
|
||||||
|
expect(unknown.balance).toBe("unknown (NOVEL)");
|
||||||
|
expect(zero.balance).toBe("0.0 NOVEL");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
|
||||||
|
// spend from, which is the fail-closed side — but "you have 0.0" is a claim
|
||||||
|
// about the holding, and this one has no established quantity to claim.
|
||||||
|
test("the insufficient-balance message names the reason, not a figure", async () => {
|
||||||
|
const unknown = await render(null);
|
||||||
|
const zero = await render("0.0");
|
||||||
|
expect(unknown.errors).not.toBe(zero.errors);
|
||||||
|
expect(unknown.errors).toContain("This token's balance is unknown");
|
||||||
|
expect(unknown.errors).not.toContain("You have");
|
||||||
|
expect(zero.errors).toContain("You have 0.0 NOVEL");
|
||||||
|
expect(zero.errors).not.toContain("balance is unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the only network these screens reached for is the Etherscan label lookup", () => {
|
||||||
|
for (const [url] of global.fetch.mock.calls) {
|
||||||
|
expect(String(url)).toMatch(/^https:\/\/etherscan\.io\/address\//);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user