Compare commits

..
Author SHA1 Message Date
sneak 65d4dd8e0f docs: name every approval-screen amount string, fix stale zero claim (closes #369)
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
check / check (push) Successful in 1m18s
The README's amount-display section claimed a genuine zero always renders
`0.0000`. That is corrected and scoped: it holds for the ERC-20 amount and for
the swap's `Amount` line on a literal-zero `amountIn` in a V2/V3 exact-in swap
or a zero `WRAP_ETH`. Only two zeros are stated in words before the floor — the
swap's `Min. received` (any zero minimum) and its V4 exact-in `Amount` (an
`amountIn` of zero, V4's open delta).

A new list names every string an amount slot can show: a formatted quantity,
`Unlimited`, `All available (V4 open delta)`, `None (no minimum guaranteed)`,
base units with decimals unknown, and `Unknown (not named in the calldata)`. It
also records that a zero `minBalance` on a `BALANCE_CHECK_ERC20` step now reads
the no-minimum wording where it once read `0.0000`. Each claim checked against
the tree. Docs only.

Model: opus-4-8
2026-09-21 23:30:10 +00:00
clawbot 598de3ff1a fix: re-enable Confirm Delete after a delete, so a second one needs no reopen (closes #335)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
The password route disabled its Confirm Delete button before the decrypt
and never re-enabled it on success, so a second delete in the same popup
session found a dead button until the popup was closed and reopened. The
lost-password route re-enabled its own button in its leave hook, so the
two screens on the one screen behaved differently.

Both routes now reset the button through the shared finishDelete(), the
one path they both take, and the lost-password leave hook no longer
handles it separately. Tests drive a password-route delete and a second
delete in the same session; they fail against the prior head, where the
button stays disabled after the first delete.

Model: opus-4-8
2026-09-22 01:28:02 +02:00
clawbot ae61792aee chore: keep the internal view id out of the release banner (closes #375)
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
check / check (push) Successful in 1m10s
The debug/testnet banner appended the active view's internal id, so the
user saw text like "[TESTNET] (approve-tx)" — developer vocabulary, and on
the approval screen it sat directly above the carefully worded line stating
what is being authorized. The view id is now gated on the compile-time
DEBUG constant instead of isDebug(), so it survives only in a debug build.
A testnet or the runtime debug toggle still raises the banner, but without
the view id, which is what a release build shows.

Model: opus-4-8
2026-09-22 00:45:06 +02:00
clawbot a1f082d686 docs: a release procedure from a green main to tagged, packaged artifacts (closes #387)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
Add docs/RELEASE.md, linked from README.md's Release Artifacts section, giving
the release procedure as a numbered list: confirm main is green in CI, confirm
the one version in package.json and the two manifests matches the intended tag,
make package from a clean checkout, verify SHA256SUMS, create the annotated tag
vX.Y.Z, then distribute per browser. Each step names who performs it, marks the
owner-only ones, and states the check that it worked. Every repo command cited
(make setup, make check, make package) exists on next; tagging and verification
use standard git and coreutils, and the CRX pack line is README's own.

The per-browser distribution step is written as pending the owner's choice on
issue 386, with the Firefox and Chrome options named but none presented as
settled. Docs only: no code or test changes.

Model: opus-4-8
2026-09-21 22:00:18 +02:00
clawbot 33fa25adca harden: bound the total network fee by gasLimit × fee, on both send paths (closes #399)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
The two per-field ceilings in approvalVerify.js were checked independently,
but the fee a validator is paid is gasLimit × fee per gas: a gas limit and a
fee each under their own ceiling still multiply to thousands of ETH, which a
gas-consuming contract really collects. assertWithinCeilings now also bounds
that product against MAX_TOTAL_FEE (1 ETH), so both callers — populating the
dApp transaction and verifying the signed artifact — refuse it with a full
sentence naming the fee and the limit.

The wallet's own send in confirmTx.js pinned no fee fields, so ethers filled
them from the node with no bound; it now populates the transaction and runs the
same check before signing, showing the same error in the confirmation screen's
reserved errors box so nothing on screen moves.

Model: opus-4-8
2026-09-21 21:45:34 +02:00
clawbot 2fe6447625 fix: name a tracked or explorer-known token instead of "Unknown token" (closes #323)
check / check (push) Failing after 0s
e2e / e2e-firefox (push) Failing after 0s
e2e / e2e-chrome (push) Failing after 1m27s
The approval and transaction-status screens read a token's scale from the
bundled list, the tokens the user tracks, then the block explorer, but read
its symbol from the bundled list alone. A token the user added by hand was
scaled correctly yet labelled "Unknown token", and a non-bundled ERC-20 was
carried onto the wait screen as ETH.

resolveTokenSymbol() now draws the symbol through the same sources and
precedence as the scale, and the ERC-20 and Uniswap swap lines both use it. A
tracked or explorer-reported name stays subject to the spoof rule, so it
cannot claim a bundled or native ticker.

Folds in #354.

Model: opus-4-8
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-09-21 21:28:06 +02:00
clawbot 2da790fbe9 fix: say a second wallet's password is separate when one is chosen (closes #374)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
The add-wallet screen offered only "Choose a password" while each wallet
keeps its own encrypted secret, so a second wallet silently accepted a
password different from the first with nothing marking it as separate. A
note now appears on that screen when the profile already holds a wallet,
saying each wallet has its own password and this one need not match any
already in use. It is shown only then — the first wallet has no other
password to differ from — and is decided on screen entry, so it does not
move the password fields. It promises no recovery or reset, staying
consistent with the no-password-reset design.

Model: opus-4-8
2026-09-21 21:11:09 +02:00
clawbot 9ac7df0128 harden: keep the test recovery phrase out of release bundles, match committed keys by content (closes #351)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
The 12-word BIP-39 test phrase survived in every release bundle as dead
text: module.exports keeps DEBUG_MNEMONIC live even though wallet.js's only
use of it folds away in a release build, so it could not be tree-shaken.
Putting the value itself behind the __BUILD_DEBUG__ define makes esbuild fold
it to null, so no distributed bundle carries it. script/verify-build now
fails a release build if the phrase appears in any emitted file, so the fold
cannot silently regress; test-verify-build covers both the release failure
and the debug allowance.

tests/extensionId.test.js now scans the content of every tracked file for a
PEM private-key header instead of matching filename extensions alone, so a
key committed under an unexpected name is caught.

Model: opus-4-8
2026-09-21 18:29:39 +02:00
clawbot 99292b9188 fix: honour a transaction response only for a transaction approval (closes #262)
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
check / check (push) Successful in 1m42s
The liveness fix this issue describes — settle 4001 on release when the window
a retry would use is gone — already landed with
#271. This completes the rest.

AUTISTMASK_TX_RESPONSE now refuses any approval that is not a transaction
approval, so a reject can no longer retire a sign or connection approval, and a
signed artifact never runs the broadcast path against one — which before only
failed closed by throwing deeper in. Tests pin the site-connection port's
approve, reject and disconnect paths against a transaction approval broadcasting
behind them: each is declined and the dApp still receives its broadcast result.

Model: opus-4-8
2026-09-21 09:54:40 +02:00
clawbot 1197d2171b fix: give every address a row of its own, so none wraps or is shortened (closes #380) (#381)
check / check (push) Successful in 56s
e2e / e2e-chrome (push) Successful in 1m51s
e2e / e2e-firefox (push) Successful in 40s
2026-08-30 05:25:00 +02:00
clawbot a098bb0c32 fix: floor malformed allowedSites, fraudContracts and selectedToken entries (closes #362)
check / check (push) Successful in 42s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s
A stored allowedSites whose value was not a list rendered a working popup and then made every subsequent save fail silently, so the user operated a wallet that persisted nothing -- worse than a blank popup, which is at least visibly broken. fraudContracts and selectedToken had the same shape: a container floored by truthiness or not at all, while its entries were dereferenced. Entries are now floored as well as containers, following the idiom #311 established, and a failed save raises a persistent banner instead of vanishing into a swallowed rejection.

The per-field justifications that used to live in a hand-written header are replaced by a contract test that drives each field's hostile and falsy values through a real popup boot, so a claim about a field answers to the code rather than to prose. Its guarantee is stated narrowly and deliberately: no structural dereference on the code paths a wholly-corrupted profile takes, which is not every path a stored record takes. The paths it does not drive are named where the claim is made, and are tracked in #379.
2026-08-23 23:06:17 +02:00
39 changed files with 1771 additions and 221 deletions
+81 -22
View File
@@ -64,7 +64,9 @@ release/SHA256SUMS
``` ```
Nothing is published by this. Tagging, CRX packing and any upload are Nothing is published by this. Tagging, CRX packing and any upload are
outward-facing acts and are the owner's alone. outward-facing acts and are the owner's alone. The full procedure that turns a
green `main` into a tagged, packaged release — the order of steps, who performs
each, and how to check it worked — is in [docs/RELEASE.md](docs/RELEASE.md).
The archives are deterministic — entries sorted, timestamps fixed, compression The archives are deterministic — entries sorted, timestamps fixed, compression
level fixed — so two builds of one commit produce byte-identical files and the level fixed — so two builds of one commit produce byte-identical files and the
@@ -800,7 +802,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 +856,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
@@ -869,9 +882,18 @@ On those screens, when the truncated string would contain no digit from 1 to 9
and the value does, the amount is extended to its first significant digit and the value does, the amount is extended to its first significant digit
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
truncated string, integer part included, so `1.00005` still shows as `1.0000` — truncated string, integer part included, so `1.00005` still shows as `1.0000` —
the exception only fires where the entire displayed figure would read as zero. A the exception only fires where the entire displayed figure would read as zero.
genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999` Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. A
shows as `0.9999`, never rounded up. genuine zero reaching this rule renders `0.0000`. The ERC-20
`approve`/`transfer` amount does exactly that, and so does the swap's `Amount`
line for a literal-zero `amountIn` on a V2 or V3 exact-in swap, or a zero
`WRAP_ETH` (`0.0000 ETH`). Only two zeros are stated in words before the floor:
the swap's `Min. received` line reads `None (no minimum guaranteed)` for any
zero minimum, and its `Amount` line reads `All available (V4 open delta)` for a
V4 exact-in `amountIn` of zero, which V4 treats as the whole open credit rather
than a quantity. So the guarantee that a zero is never shown as `0.0000` covers
the `Min. received` line and the V4 exact-in `Amount`; a V2/V3 or `WRAP_ETH`
`Amount` still renders it (see the list of amount-slot strings below).
The rule and its exception live in `src/shared/amountDisplay.js` as The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and `truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
@@ -923,6 +945,38 @@ 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 for a bundled or tracked token the explorer merely omitted, which is not a
refusal the wallet has any reason to make. refusal the wallet has any reason to make.
**Every string an amount slot can show:** taken together, the exceptions above
mean an amount line on the dApp approval screen (and the wait/success/error
screens that carry a figure forward) shows one of a fixed set of strings, not
always a number:
- A formatted quantity, e.g. `17.1900 USDT`: the token's scale is known and the
figure is at or above the floor, or below it and extended to its first
significant digit. This is `truncateAmountNeverZero()`
(`src/shared/amountDisplay.js`).
- `Unlimited`: an unbounded allowance or permit, which needs no scale to
describe — a `uint256`-max ERC-20 `approve` (`src/popup/views/approval.js`) or
a Permit2 amount at the `uint160` max on a swap's `Amount`
(`src/shared/uniswap.js`).
- `All available (V4 open delta)`: a V4 exact-in swap whose `amountIn` is zero.
V4 reads that zero as "use the whole open delta", not as a literal zero, so
the calldata states no quantity at all. Swap `Amount` line only
(`src/shared/uniswap.js`).
- `None (no minimum guaranteed)`: a zero minimum — the swap guarantees nothing
back. It is a literal zero slippage floor on a V2/V3/V4 swap, and it also
reaches a `BALANCE_CHECK_ERC20` step: a zero `minBalance`, which once rendered
`0.0000` beside the token symbol, now reads this. Swap `Min. received` line
(`src/shared/uniswap.js`).
- `<amount> base units (decimals unknown)`: the token's scale could not be
resolved, so the base-unit integer is shown with that caveat rather than
formatted (see Unknown token scale above). Reaches both the ERC-20 amount line
and the swap's `Amount` and `Min. received` (`unknownDecimalsAmount()` in
`src/shared/approvalAmount.js`).
- `Unknown (not named in the calldata)`: not an amount but the currency itself —
the `Token In` or `Token Out` line when nothing in the calldata established
which token, shown beside the amount and, like the strings above, a sentence
rather than a value (`src/shared/uniswap.js`).
#### 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
@@ -1052,17 +1106,18 @@ driving the real code with hostile values — and, for every field whose only
defence is that nothing dereferences it, by booting the real popup entry point defence is that nothing dereferences it, by booting the real popup entry point
over that value onto every view the popup can reopen onto. That last part is over that value onto every view the popup can reopen onto. That last part is
what makes the claim falsifiable, because this defect class lives on the restore what makes the claim falsifiable, because this defect class lives on the restore
path rather than on the home screen: a field one of those boots corrupts and a path rather than on the home screen. Read the claim narrowly, as that file
restorable view dereferences on its render fails `make check`, at either states it: what those boots prove is no structural dereference on the code paths
polarity — a value nothing writes is a wrong-typed one and therefore truthy, so a WHOLLY-CORRUPTED PROFILE takes, which is not every path a stored record takes.
each such field is also driven falsy, or proven unable to be falsy after the Not driven: any pairing of values the four slots do not produce, a view only
floor. So does a field that gains a floor while its row still claims it has forward navigation opens, anything behind a click, and everything a healthy
none, and so does a field added to `PERSISTED_FIELDS` with no row at all. Two profile reaches. Within that boundary the verdict is unconditional — if one of
things the boots do not drive: a MIX of polarities, since one boot puts every those boots leaves the popup unhealthy or off the view it stored, `make check`
corrupted field on the same slot, so a branch reached only when one is truthy fails, including when it takes two corrupted fields at once, because the verdict
and another falsy is not entered; and whatever no stored record reaches by is the combined boot and the per-field re-boot that names a culprit can only
itself — a view only forward navigation opens, and anything behind a click. The decorate the message. So does a field that gains a floor while its row still
per-field justification that used to live in the header of 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, `src/shared/stateSchema.js` shipped a false claim in three consecutive changes,
each caught only by a reviewer re-deriving thirty fields by hand. each caught only by a reviewer re-deriving thirty fields by hand.
@@ -1181,13 +1236,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
+138 -3
View File
@@ -45,6 +45,135 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-09-21: The network fee a transaction can commit is bounded by the product
of the gas limit and the fee per gas, not by each field alone, and the
wallet's own send is bounded the same way
([#399](https://git.eeqj.de/sneak/AutistMask/issues/399)). The two per-field
ceilings in `src/shared/approvalVerify.js` were checked independently, so a
gas limit and a fee that were each under their own ceiling still multiplied to
thousands of ETH — a fee a gas-consuming contract really collects — while the
comment claimed the ceiling caught exactly that. `assertWithinCeilings` now
also refuses a transaction whose gas limit times its fee per gas
(`maxFeePerGas` for a type-2 transaction, `gasPrice` for a legacy or type-1
one) exceeds `MAX_TOTAL_FEE`, a new constant of 1 ETH beside the existing
ceilings, so both callers — where the dApp transaction is populated and where
the signed artifact is verified — reject it with a full sentence naming the
fee and the limit. The wallet's own send in `src/popup/views/confirmTx.js`
pinned no fee fields, so ethers filled them from whatever the configured node
answered with nothing bounding them; it now populates the transaction and runs
the same check before signing, showing the same error in the confirmation
screen's reserved errors box so nothing on screen moves. Deliberately out of
scope: comparing a supplied fee against the node's own suggested fee, which
the absolute bound already makes unnecessary for the balance-draining case. 1
ETH is a plain constant, one line to change; the owner may prefer another
figure.
- 2026-09-21: The test recovery phrase no longer survives in a release bundle,
and the committed-key guard matches by content
([#351](https://git.eeqj.de/sneak/AutistMask/issues/351)). `DEBUG_MNEMONIC` in
`src/shared/constants.js` is now behind the `__BUILD_DEBUG__` define, so a
release build folds the phrase to `null` and no emitted bundle carries it; it
used to survive as dead text because `module.exports` keeps the const alive.
`script/verify-build` now fails a release build if the phrase appears in any
emitted file, so the fold cannot silently regress. `tests/extensionId.test.js`
scans the content of every tracked file for a PEM private-key header instead
of matching filename extensions alone.
- 2026-09-21: A transaction response is honoured only for a transaction
approval, and the three remaining approval-settlement paths are pinned
([#262](https://git.eeqj.de/sneak/AutistMask/issues/262)). The liveness fix
the issue asks for — settle `4001` on release when the window it would be
retried in is gone — already landed with
[#271](https://git.eeqj.de/sneak/AutistMask/issues/271); this closes the rest.
`AUTISTMASK_TX_RESPONSE` now refuses any approval that is not a transaction
approval, so a reject no longer retires a sign or connection approval and a
signed artifact never runs the broadcast path against one, which before only
failed closed by throwing deeper in. Tests pin the site-connection port's
approve, reject and disconnect paths against a transaction approval
broadcasting behind them: each is declined and the dApp still receives its
broadcast result.
- 2026-09-21: `docs/RELEASE.md`, linked from `README.md`, states the release
procedure as a numbered list a newcomer can follow: confirm `main` is green in
CI, confirm the one version in the three files matches the intended tag,
`make package` from a clean checkout, verify `SHA256SUMS`, tag `vX.Y.Z`, then
distribute per browser. Each step names who performs it (owner-only steps
marked) and the check that it worked. The distribution step is written as
pending the owner's choice on
[#386](https://git.eeqj.de/sneak/AutistMask/issues/386), with the Firefox and
Chrome options named but none settled. Docs only
([#387](https://git.eeqj.de/sneak/AutistMask/issues/387)).
- 2026-09-21: Adding a second wallet no longer accepts a different password with
nothing saying it is a separate one
([#374](https://git.eeqj.de/sneak/AutistMask/issues/374)). Each wallet has its
own encrypted secret, so per-wallet passwords are by design; the add-wallet
screen said only "Choose a password". A note now appears on that screen when
the profile already holds a wallet, stating that each wallet has its own
password and this one need not match any already in use. It is shown only
then, since the first wallet has no other password to differ from, and it
stays consistent with the no-reset reality of
[#312](https://git.eeqj.de/sneak/AutistMask/issues/312) by promising no
recovery or reset.
- 2026-09-21: The dApp approval and transaction-status screens resolve a token's
symbol from the bundled list, then the tokens the user tracks, then the block
explorer's report — the same sources and precedence the amount line already
used for the token's scale
([#323](https://git.eeqj.de/sneak/AutistMask/issues/323), folding in
[#354](https://git.eeqj.de/sneak/AutistMask/issues/354)). A token the user
added by hand, or holds a balance of, is now named rather than labelled
`Unknown token`, and a non-bundled ERC-20 is no longer carried onto the wait
screen as `ETH`. A tracked or explorer-reported name stays subject to the
spoof rule, so resolving a symbol is not a new way to wear a known ticker.
- 2026-09-21: The debug/testnet banner no longer shows the internal view id to
the user in a release build
([#375](https://git.eeqj.de/sneak/AutistMask/issues/375)). The banner appended
the active view's id (e.g. `[TESTNET] (approve-tx)`), which is developer
vocabulary sitting directly above the approval screen's carefully worded
authorization text. The suffix is now gated on the compile-time `DEBUG`
constant rather than `isDebug()`, so it survives only in a debug build; a
testnet or the runtime debug toggle still raises the banner but without the
view id.
- 2026-09-21: The Confirm Delete button on the delete-wallet screen no longer
stays dead after a successful delete
([#335](https://git.eeqj.de/sneak/AutistMask/issues/335)). The password route
disabled the button before the decrypt and never re-enabled it, so a second
delete in the same popup session needed a reopen; the lost-password route
re-enabled its own button in its leave hook, so the two screens behaved
differently. Both now reset through the shared `finishDelete()`, the one path
both routes take, so they behave the same and the button is live for the next
delete.
- 2026-09-21: `README.md` now documents the approval screen's amount-slot
vocabulary and no longer contradicts itself
([#369](https://git.eeqj.de/sneak/AutistMask/issues/369)). The stale claim
that a genuine zero still renders `0.0000` is corrected: it holds for the
ERC-20 amount, and also for the swap's `Amount` line on a literal-zero
`amountIn` in a V2/V3 exact-in swap or a zero `WRAP_ETH`. Only two zeros are
stated in words upstream — the swap's `Min. received` (any zero minimum) and
its V4 exact-in `Amount` (an `amountIn` of zero, V4's open delta). The
amount-display section now names every string a slot can show — a formatted
quantity, `Unlimited`, `All available (V4 open delta)`,
`None (no minimum guaranteed)`, base units with decimals unknown, and
`Unknown (not named in the calldata)` — and records that a zero `minBalance`
on a `BALANCE_CHECK_ERC20` step now reads `None (no minimum guaranteed)` where
it once read `0.0000`. Docs only; each claim checked against the tree.
- 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 - 2026-08-23: Both manifests declare toolbar icons, and real PNGs at
16/32/48/128 ship inside both archives 16/32/48/128 ship inside both archives
([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest ([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest
@@ -85,9 +214,15 @@ but the review is broader than any of them.
the path this whole class of defect lives on. Each such field is driven at 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 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 falsy slot is driven too, or the field is proven unable to be falsy after the
floor. A field with no row, a field that gains a floor while its row still floor. The claim is narrow and stated as such: no structural dereference on
claims it has none, and a field one of those boots corrupts and a restorable the code paths a wholly-corrupted profile takes, which is not every path a
view dereferences on its render now all fail `make check`. 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
+87
View File
@@ -0,0 +1,87 @@
# Releasing AutistMask
This is the procedure that turns a green `main` into a tagged, packaged release.
It gathers into one place what is otherwise spread across the `Makefile` and
three `README.md` sections, so the person cutting a release does not have to
reconstruct the order from them.
There is one version, declared in three files (`package.json`,
`manifest/chrome.json`, `manifest/firefox.json`), and `make package` builds and
packages but publishes nothing. `make build` and `make package` can be run by
anyone; tagging, signing, packing a CRX and any upload need credentials only the
owner ([@sneak](https://sneak.berlin)) holds and are marked **owner-only**
below. Releases are tagged from `main` (see the Workflow section of `TODO.md`),
so the "release commit" throughout is the `main` commit the milestone PR merged.
## Procedure
1. **Confirm `main` is green in CI.** The `check` workflow
(`.gitea/workflows/check.yml`) runs `script/cibuild`, i.e. `docker build .`,
and the `Dockerfile` runs `make check` as a build step, so a green `check`
run is a green `make check`. Find the run for the exact release commit on the
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
`make check` on a clean checkout of the commit reproduces it and exits 0.
2. **Confirm the version matches the intended tag.** `package.json`,
`manifest/chrome.json` and `manifest/firefox.json` must all declare the same
`X.Y.Z`. `make build` fails when they disagree, but nothing checks that they
equal the tag you mean to create — that is this manual step. _Check:_ all
three files read the same `X.Y.Z`, and it is the version you intend to tag
`vX.Y.Z`.
3. **Build and package from a clean checkout of that commit.** From a fresh
clone, or a working tree with no local modifications (`git status` clean),
checked out at the release commit: run `make setup`, then `make package`.
`make package` runs `make build` first, so the archives can only be made from
a `dist/` verified against that build's own receipt as a release (not debug)
build. It writes three files into `release/`:
`autistmask-chrome-<version>.zip`, `autistmask-firefox-<version>.xpi`, and
`SHA256SUMS`. _Check:_ those three files exist and `<version>` in the archive
names is the version confirmed in step 2. The Firefox `.xpi` is **unsigned**
(see step 6 and "Installing on Firefox" in `README.md`).
4. **Verify `SHA256SUMS`.** The archives are deterministic — sorted entries,
fixed timestamps, fixed compression — so a second `make package` from another
clean checkout of the same commit produces byte-identical files. Verify the
recorded digests against the files with `sha256sum -c SHA256SUMS`, run from
`release/`. To confirm reproducibility, run `make package` again on a
separate clean checkout and compare the digests. _Check:_ `sha256sum -c`
reports `OK` for every file, and an independent build's digests match.
5. **Tag the release commit.** _(owner-only)_ Create an annotated tag `vX.Y.Z`
on the release commit and push it: `git tag -a vX.Y.Z` (with a message), then
`git push origin vX.Y.Z`. _Check:_ `git tag` lists `vX.Y.Z`, and
`git rev-parse vX.Y.Z^{commit}` resolves to the release commit.
6. **Distribute per browser.** _(owner-only; pending the owner's choice on
https://git.eeqj.de/sneak/AutistMask/issues/386)_ How 1.0.0 is distributed on
each browser is not yet decided; it is the open question on that issue, and
the concrete steps cannot be written until the owner records a choice there.
These steps need credentials only the owner holds. The options under
consideration are:
- **Firefox** — the packaged `.xpi` is unsigned, and release Firefox and ESR
refuse an unsigned add-on:
- (a) AMO self-distribution signing (unlisted): submit the `.xpi` to AMO
with the owner's credentials; AMO returns a signed `.xpi` installable
on every Firefox, with nothing listed publicly.
- (b) AMO listed: as (a), plus a public AMO listing and review.
- (c) Ship the unsigned `.xpi` and state that Firefox support means
Developer Edition, Nightly, or an Unbranded build with
`xpinstall.signatures.required` set to `false`.
- **Chrome** — the repo packs no CRX and publishes nothing; the extension id
is fixed by the `key` in `manifest/chrome.json`:
- (a) Chrome Web Store (unlisted): upload the `.zip` with the owner's
developer account; the store delivers installs and updates.
- (b) Self-hosted CRX signed with the private key the owner holds
(`chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>`),
installable only via enterprise policy on Windows and macOS, so
realistically Linux-only.
- (c) "Load unpacked" from `dist/chrome/` only, as today.
Once the owner decides, the chosen steps — including which credentials they
need and who holds them — are written into this section and `README.md`'s
installation sections are updated to match, which is part of the definition
of done of https://git.eeqj.de/sneak/AutistMask/issues/386. _Check:_ for a
store or AMO route, the artifact installs from the store or AMO on a clean
browser profile; for the CRX or unpacked route, the documented load succeeds
and Chrome reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`.
+29
View File
@@ -37,6 +37,10 @@ DISCARD_DIST="$ROOT/script/discard-dist-on-failure"
MARKER_ON="autistmask-build-debug=on" MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off" MARKER_OFF="autistmask-build-debug=off"
# The same test recovery phrase verify-build searches release bundles for. Held
# here too, the way the markers above are, so a case can plant it in a bundle.
TEST_MNEMONIC="cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
RECEIPT_HEADER="autistmask-build-receipt v1" RECEIPT_HEADER="autistmask-build-receipt v1"
NEWLINE=' NEWLINE='
@@ -516,6 +520,24 @@ c_debug_build() {
write_receipt write_receipt
} }
# A release bundle that still carries the test recovery phrase — the regression
# verify-build guards against, and the reason DEBUG_MNEMONIC is behind the
# __BUILD_DEBUG__ define in src/shared/constants.js. The receipt is regenerated
# so the phrase is caught as bundle content, not incidentally as a stale digest.
c_release_bundle_with_mnemonic() {
printf '/* %s */\n' "$TEST_MNEMONIC" >>dist/chrome/src/popup/index.js
write_receipt
}
# The same phrase in a debug build is expected: make build-debug ships it on
# purpose, so the phrase check must stay quiet under --expect debug.
c_debug_bundle_with_mnemonic() {
write_bundle dist/chrome/src/popup/index.js "$MARKER_ON"
write_bundle dist/firefox/src/popup/index.js "$MARKER_ON"
printf '/* %s */\n' "$TEST_MNEMONIC" >>dist/chrome/src/popup/index.js
write_receipt
}
c_no_dist() { rm -rf dist; } c_no_dist() { rm -rf dist; }
# --- dist discard ----------------------------------------------------------- # --- dist discard -----------------------------------------------------------
@@ -753,6 +775,13 @@ run_cases() {
check_case "debug bundles under --expect debug pass" \ check_case "debug bundles under --expect debug pass" \
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_build no debug 0 "2 bundle(s) $MARKER_ON" c_debug_build
check_case "release bundle carrying the test recovery phrase fails" \
no release 1 \
"carries the BIP-39 test recovery phrase" c_release_bundle_with_mnemonic
check_case "debug bundle carrying the test recovery phrase passes" \
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_bundle_with_mnemonic
check_case "no --expect argument" \ check_case "no --expect argument" \
no no-expect 1 "no --expect argument." c_control no no-expect 1 "no --expect argument." c_control
+27
View File
@@ -13,6 +13,12 @@
# fallback branch; the property only exists in the emitted output, so it has to # fallback branch; the property only exists in the emitted output, so it has to
# be asserted against the emitted output. # be asserted against the emitted output.
# #
# The DEBUG half also checks the phrase directly: a release build must not carry
# the test recovery phrase in any emitted file. The phrase is behind the
# __BUILD_DEBUG__ define in src/shared/constants.js and folds away in a release
# build, but the marker only proves DEBUG compiled off, not that the fold
# removed the string; the phrase grep is the assertion that it did.
#
# Which mode to expect is an ARGUMENT (--expect release|debug) and is never # Which mode to expect is an ARGUMENT (--expect release|debug) and is never
# taken from this script's environment. It used to be read from # taken from this script's environment. It used to be read from
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1 # AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1
@@ -67,6 +73,15 @@ TAB=' '
MARKER_ON="autistmask-build-debug=on" MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off" MARKER_OFF="autistmask-build-debug=off"
# The 12-word BIP-39 test recovery phrase from src/shared/constants.js. It is
# behind the __BUILD_DEBUG__ define there, so a release build folds it out of
# every bundle; this is the assertion that it stayed out. The phrase is a
# publicly committed test value rather than a secret, but a BIP-39 phrase in a
# distributed wallet artifact is exactly the string a scanner or auditor has to
# stop and reason about, so a release build must not ship it. A debug build
# ships it on purpose, so this is checked only when release is expected.
TEST_MNEMONIC="cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
RECEIPT_HEADER="autistmask-build-receipt v1" RECEIPT_HEADER="autistmask-build-receipt v1"
# Set by the arguments. # Set by the arguments.
@@ -283,6 +298,18 @@ check_entry() {
receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote
to dist/ after the build, so this artifact is not the one that was built." to dist/ after the build, so this artifact is not the one that was built."
# No emitted file of a release build may carry the test recovery phrase.
# Checked on every file, not only the audited bundles, so a copy that
# reached some other emitted file fails here too. A debug build ships the
# phrase deliberately, so this runs only when release was expected.
if [ "$EXPECT" = "$MARKER_OFF" ] && has_marker "$TEST_MNEMONIC" "$ENTRY_PATH"; then
fail "$ENTRY_PATH carries the BIP-39 test recovery phrase, which a
release build must fold out. The __BUILD_DEBUG__ define in build.js is what
drops it from src/shared/constants.js; check that DEBUG_MNEMONIC is still
behind that flag. A recovery phrase in a distributed bundle is exactly the
string an auditor or scanner has to stop on, so this is a hard failure."
fi
if [ "$ENTRY_FLAG" = A ]; then if [ "$ENTRY_FLAG" = A ]; then
read_marker "$ENTRY_PATH" read_marker "$ENTRY_PATH"
[ "$MARKER" = "$EXPECT" ] || [ "$MARKER" = "$EXPECT" ] ||
+9
View File
@@ -1344,6 +1344,15 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
const approval = pendingApprovals[msg.id]; const approval = pendingApprovals[msg.id];
if (!approval) return false; if (!approval) return false;
// This message signs and broadcasts a transaction, so it is honoured
// only for a transaction approval. A sign or connection approval
// carries no approvedTx, and reaching the broadcast path with one used
// to fail closed by throwing deeper in; refusing here keeps a future
// refactor from turning that incidental throw into a live path, and
// keeps a reject on this message from retiring an approval of another
// kind.
if (approval.type !== "tx") return false;
// A reject arriving while an attempt holds the approval is refused, // A reject arriving while an attempt holds the approval is refused,
// not honoured: the attempt is on its way to broadcasting the // not honoured: the attempt is on its way to broadcasting the
// transaction, and resolving 4001 here would tell the page the request // transaction, and resolving 4001 here would tell the page the request
+35 -29
View File
@@ -152,6 +152,21 @@
<!-- Shared password fields --> <!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section"> <div class="mb-2" id="add-wallet-password-section">
<!-- Shown only when the profile already holds a wallet:
each wallet has its own password (its own
encryptedSecret), so a second wallet does not reuse
the first one's. addWallet.js toggles this on screen
entry from state.wallets.length, so it is constant
while the screen is up and moves nothing. -->
<p
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
id="add-wallet-separate-password-note"
>
You already have a wallet. Each wallet has its own
password: the one you choose here is only for this new
wallet, and it need not match any password you already
use.
</p>
<label class="block mb-1">Choose a password</label> <label class="block mb-1">Choose a password</label>
<!-- The hint is swapped in place when the import tab <!-- The hint is swapped in place when the import tab
changes, and it sits directly above the password changes, and it sits directly above the password
@@ -213,10 +228,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 +304,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 +392,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 +452,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 +585,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 +737,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 +756,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 +783,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 +820,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 +1248,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 +1438,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 +1479,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 +1573,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 +1679,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">
+20
View File
@@ -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;
}
+15
View File
@@ -100,9 +100,24 @@ function clear() {
$("add-wallet-phrase-warning").style.visibility = "hidden"; $("add-wallet-phrase-warning").style.visibility = "hidden";
} }
// Each wallet has its own password (its own encryptedSecret), so adding a
// second wallet does not reuse the first one's. The note that says so is
// only meaningful once a wallet exists — on the first wallet there is no
// other password to be separate from — so it is shown only then. This is
// decided on entry and stays put while the screen is up, so it does not
// move the password fields the way a per-tab hint would.
function updateSeparatePasswordNote() {
const hasExistingWallet = state.wallets.length > 0;
$("add-wallet-separate-password-note").classList.toggle(
"hidden",
!hasExistingWallet,
);
}
function show() { function show() {
clear(); clear();
switchMode("mnemonic"); switchMode("mnemonic");
updateSeparatePasswordNote();
showView("add-wallet"); showView("add-wallet");
} }
+8 -6
View File
@@ -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++;
} }
+8 -6
View File
@@ -10,7 +10,6 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
truncateMiddle,
balanceLine, balanceLine,
unknownableAmount, unknownableAmount,
renderAddressHtml, renderAddressHtml,
@@ -305,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;" : "";
@@ -316,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++;
} }
+32 -21
View File
@@ -20,9 +20,9 @@ const {
} = require("ethers"); } = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices"); const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants"); const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { const {
resolveTokenDecimals, resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount, unknownDecimalsAmount,
} = require("../../shared/approvalAmount"); } = require("../../shared/approvalAmount");
// Four decimals, with the nonzero floor these screens hold: every amount this // Four decimals, with the nonzero floor these screens hold: every amount this
@@ -63,9 +63,15 @@ function tokenAmountText(rawAmount, decimals, symbol) {
}; };
} }
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) { function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase()); return resolveTokenSymbol(address, {
return t ? t.symbol : null; trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
} }
// Try to decode calldata using known ABIs. // Try to decode calldata using known ABIs.
@@ -85,8 +91,7 @@ function decodeCalldata(data, toAddress) {
try { try {
const parsed = erc20Iface.parseTransaction({ data }); const parsed = erc20Iface.parseTransaction({ data });
if (parsed) { if (parsed) {
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase()); const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
const tokenSymbol = token ? token.symbol : null;
// null when no source knows this token's scale. It is not // null when no source knows this token's scale. It is not
// defaulted to 18: an amount formatted with a guessed scale is // defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the // the wrong number, and for a token with fewer decimals than the
@@ -242,8 +247,11 @@ function showTxApproval(details) {
const approvedTx = details.approvedTx; const approvedTx = details.approvedTx;
const toAddr = approvedTx.to; const toAddr = approvedTx.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(approvedTx.value || "0"); const ethValue = formatEther(approvedTx.value || "0");
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Build txInfo for status screens // Build txInfo for status screens
pendingTxDetails = { pendingTxDetails = {
@@ -251,14 +259,17 @@ function showTxApproval(details) {
to: toAddr || "", to: toAddr || "",
amount: formatTxValue(ethValue), amount: formatTxValue(ethValue),
token: "ETH", token: "ETH",
tokenSymbol: token ? token.symbol : null, tokenSymbol: null,
}; };
// If this is an ERC-20 call, try to extract the real recipient and amount // If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(approvedTx.data, toAddr || ""); const decoded = decodeCalldata(approvedTx.data, toAddr || "");
if (decoded && decoded.details) { if (decoded && decoded.details) {
let decodedTokenAddr = null; // The asset the status summary is counted in: an ERC-20 call's Token
let decodedTokenSymbol = null; // contract, or a swap's input token. Its symbol is resolved from the
// same sources as the approval screen, so a non-bundled token the
// wallet knows is not carried onto the wait and success screens as ETH.
let assetAddr = null;
for (const d of decoded.details) { for (const d of decoded.details) {
if (d.label === "Recipient" && d.address) { if (d.label === "Recipient" && d.address) {
pendingTxDetails.to = d.address; pendingTxDetails.to = d.address;
@@ -266,20 +277,20 @@ function showTxApproval(details) {
if (d.label === "Amount") { if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value; pendingTxDetails.amount = d.rawValue || d.value;
} }
if (d.label === "Token In" && d.isToken && d.address) { if (
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase()); (d.label === "Token" || d.label === "Token In") &&
if (t) { d.isToken &&
decodedTokenAddr = d.address; d.address
decodedTokenSymbol = t.symbol; ) {
} assetAddr = d.address;
} }
} }
if (token) { if (assetAddr) {
pendingTxDetails.token = toAddr; pendingTxDetails.token = assetAddr;
pendingTxDetails.tokenSymbol = token.symbol; pendingTxDetails.tokenSymbol = resolveTokenSymbol(
} else if (decodedTokenAddr) { assetAddr,
pendingTxDetails.token = decodedTokenAddr; sources,
pendingTxDetails.tokenSymbol = decodedTokenSymbol; );
} }
} }
+51 -24
View File
@@ -30,6 +30,7 @@ const {
displayedDecimals, displayedDecimals,
transferAmountUnits, transferAmountUnits,
} = require("../../shared/transferAmount"); } = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
const { const {
CODES, CODES,
FEE_PENDING, FEE_PENDING,
@@ -394,6 +395,46 @@ async function estimateGas(txInfo) {
} }
} }
// Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. The send
// pins no fee fields, so ethers fills maxFeePerGas and the gas limit from what
// the configured RPC node answers, with nothing otherwise bounding what a
// hostile node can set — the dApp path's ceilings never reached this one.
// Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending.
async function populateVerifyAndSend(connectedSigner, tx) {
let request;
if (tx.token === "ETH") {
request = { to: tx.to, value: parseEther(tx.amount) };
} else {
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
// The contract's decimals() is read to be COMPARED with the scale the
// screen rendered this amount at, not to encode with: encoding from it
// signs whatever the contract answers now, which is not what the user
// read. A disagreement throws. See transferAmount.js.
const amount = transferAmountUnits(
tx.amount,
tx.tokenDecimals,
await contract.decimals(),
);
request = await contract.transfer.populateTransaction(tx.to, amount);
}
const populated = await connectedSigner.populateTransaction(request);
assertWithinCeilings(populated);
return connectedSigner.sendTransaction(populated);
}
// Show a full-sentence send failure in the reserved errors box, the same
// element and markup renderValidation() uses for messages carrying the user's
// own numbers, so it never moves anything on the screen.
function showSendError(message) {
const el = $("confirm-errors");
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
el.style.visibility = "visible";
}
async function checkRecipientHistory(txInfo) { async function checkRecipientHistory(txInfo) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
@@ -467,29 +508,7 @@ function init(_ctx) {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider); const connectedSigner = signer.connect(provider);
if (pendingTx.token === "ETH") { tx = await populateVerifyAndSend(connectedSigner, pendingTx);
tx = await connectedSigner.sendTransaction({
to: pendingTx.to,
value: parseEther(pendingTx.amount),
});
} else {
const contract = new Contract(
pendingTx.token,
ERC20_ABI,
connectedSigner,
);
// The contract's decimals() is read to be COMPARED with the
// scale the screen rendered this amount at, not to encode with:
// encoding from it signs whatever the contract answers now,
// which is not what the user read. A disagreement throws and is
// reported on the error screen. See transferAmount.js.
const amount = transferAmountUnits(
pendingTx.amount,
pendingTx.tokenDecimals,
await contract.decimals(),
);
tx = await contract.transfer(pendingTx.to, amount);
}
// Best-effort: clear decrypted secret after use. // Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but // Note: JS strings are immutable; this nulls the reference but
@@ -498,6 +517,14 @@ function init(_ctx) {
txStatus.showWait(pendingTx, tx.hash); txStatus.showWait(pendingTx, tx.hash);
} catch (e) { } catch (e) {
decryptedSecret = null; decryptedSecret = null;
// A fee over the bound is refused before anything is broadcast, so
// there is no transaction that may have reached the network to warn
// about: the message stays on the confirmation screen where the
// user can go back, rather than routing to the sent/failed screen.
if (e && e.approvalMismatch) {
showSendError(e.message);
return;
}
const hash = tx ? tx.hash : null; const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message); txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
} finally { } finally {
@@ -511,4 +538,4 @@ function init(_ctx) {
}); });
} }
module.exports = { init, show, restore }; module.exports = { init, show, restore, populateVerifyAndSend };
+14 -7
View File
@@ -51,16 +51,12 @@ function clear() {
// The lost-password screen holds no secret — a wallet name is not one — // The lost-password screen holds no secret — a wallet name is not one —
// but it is wiped on leave for the neighbouring reason: a typed // but it is wiped on leave for the neighbouring reason: a typed
// confirmation left standing in a hidden view is one click away from // confirmation left standing in a hidden view is one click away from
// destroying a wallet the user has since navigated off. The button is // destroying a wallet the user has since navigated off.
// re-enabled here too, so a screen left mid-delete is usable on re-entry.
function clearLostPassword() { function clearLostPassword() {
lostPasswordIndex = null; lostPasswordIndex = null;
$("delete-wallet-lost-name-input").value = ""; $("delete-wallet-lost-name-input").value = "";
$("delete-wallet-lost-flash").textContent = ""; $("delete-wallet-lost-flash").textContent = "";
$("delete-wallet-lost-flash").style.visibility = "hidden"; $("delete-wallet-lost-flash").style.visibility = "hidden";
const btn = $("btn-delete-wallet-lost-confirm");
btn.disabled = false;
btn.classList.remove("text-muted");
} }
function show(walletIdx) { function show(walletIdx) {
@@ -98,6 +94,17 @@ function showLostPassword() {
// cleanup and the accountsChanged broadcast cannot drift apart between // cleanup and the accountsChanged broadcast cannot drift apart between
// them. // them.
async function finishDelete(walletIdx) { async function finishDelete(walletIdx) {
// Each route's confirm button was disabled by its own click handler
// before the delete ran. Re-enable both here, on the one path they
// share, so the two routes reset the same way and a second delete in
// the same popup session finds a live button instead of a dead one.
const passwordBtn = $("btn-delete-wallet-confirm");
passwordBtn.disabled = false;
passwordBtn.classList.remove("text-muted");
const lostPasswordBtn = $("btn-delete-wallet-lost-confirm");
lostPasswordBtn.disabled = false;
lostPasswordBtn.classList.remove("text-muted");
const { activeAddressChanged } = removeWalletFromState(state, walletIdx); const { activeAddressChanged } = removeWalletFromState(state, walletIdx);
deleteWalletIndex = null; deleteWalletIndex = null;
@@ -187,8 +194,8 @@ function init(_ctx) {
btn.disabled = true; btn.disabled = true;
btn.classList.add("text-muted"); btn.classList.add("text-muted");
// finishDelete() navigates, and the leave hook re-enables the // finishDelete() re-enables the button; navigating away then runs
// button and wipes the typed name on the way out. // the leave hook that wipes the typed name.
await finishDelete(lostPasswordIndex); await finishDelete(lostPasswordIndex);
}); });
+39 -16
View File
@@ -12,6 +12,7 @@
// escapeHtml lives in src/shared/html.js, where the escape and the // escapeHtml lives in src/shared/html.js, where the escape and the
// reasoning behind it are; it is re-exported below so views keep importing // reasoning behind it are; it is re-exported below so views keep importing
// it from here. // it from here.
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
@@ -119,7 +120,11 @@ function updateDebugBanner(viewName) {
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;"; "background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
document.body.prepend(banner); document.body.prepend(banner);
} }
const suffix = viewName ? " (" + viewName + ")" : ""; // The view id is internal vocabulary; it helps while developing but
// means nothing to a user. Only a debug build appends it, gated on the
// compile-time DEBUG constant so a release build never shows it — not
// isDebug(), which is also true for a testnet or the runtime toggle.
const suffix = DEBUG && viewName ? " (" + viewName + ")" : "";
if (debug && net.isTestnet) { if (debug && net.isTestnet) {
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix; banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
} else if (net.isTestnet) { } else if (net.isTestnet) {
@@ -331,6 +336,12 @@ function addressHoldsFunds(addr) {
return false; return false;
} }
// The fewest characters of an address any caller may ask to display. The
// 10-character cap inside truncateMiddle() is the other half of the same
// guarantee; this is the half that used to be spelled out at each call
// site, and is now enforced once in renderAddressHtml().
const ADDRESS_MIN_DISPLAY_LEN = 32;
// Truncate the middle of a string, replacing removed characters with "…". // Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum // Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in // that still prevents address spoofing attacks (see Display Consistency in
@@ -518,17 +529,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
@@ -536,22 +559,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;
} }
+19 -11
View File
@@ -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 || "&nbsp;"}</div>`; html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress( html += balanceLinesForAddress(
+7 -1
View File
@@ -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 {
+9 -3
View File
@@ -13,7 +13,7 @@ const {
displaySymbol, displaySymbol,
clearViewStack, clearViewStack,
} = require("./helpers"); } = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -232,9 +232,15 @@ function showSuccess(txInfo, txHash, blockNumber) {
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} }
// The symbol shown for a decoded token line, resolved from the bundled list,
// the tokens the user tracks, and the explorer's report — the same chain the
// approval screen uses. Null when no source names one, so the line keeps
// saying `Unknown token`.
function tokenLabel(address) { function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase()); return resolveTokenSymbol(address, {
return t ? t.symbol : null; trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
} }
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded) {
+55
View File
@@ -30,6 +30,7 @@
// enumerated rather than coerced. // enumerated rather than coerced.
const { toDecimals } = require("./transferAmount"); const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof");
// 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
@@ -74,6 +75,59 @@ function resolveTokenDecimals(tokenAddress, sources) {
return explorerDecimals(lower, sources && sources.wallets); return explorerDecimals(lower, sources && sources.wallets);
} }
// Every symbol the explorer reported for this contract, across the addresses
// whose balances have been fetched. The counterpart to explorerDecimals(): one
// contract, so the reports should agree, and a set that does not agree is a
// name this screen has no way to choose between.
function explorerSymbol(lower, wallets) {
let found = null;
for (const wallet of wallets || []) {
for (const addr of wallet.addresses || []) {
for (const tb of addr.tokenBalances || []) {
if ((tb.address || "").toLowerCase() !== lower) continue;
if (!tb.symbol) continue;
if (found !== null && found !== tb.symbol) return null;
found = tb.symbol;
}
}
}
return found;
}
// The symbol to label a token with, or null when no source the wallet trusts
// names one — in which case the screen keeps saying `Unknown token` rather than
// guessing. The bundled list, then the tokens the user tracks, then what the
// explorer reported: the same sources and the same precedence
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from
// the same place and the two can no longer disagree about which sources they
// trust. `sources` is { trackedTokens, wallets }, shaped as on `state`.
//
// A tracked or explorer-reported symbol is attacker-influenced text, so it is
// held to the spoof rule (symbolSpoof.js): a candidate that wears a bundled or
// native ticker from a contract not entitled to it is refused and the next
// source tried, so resolving a symbol never becomes a new way to claim a known
// ticker. The bundled list is the wallet's own data and is trusted as it is.
function resolveTokenSymbol(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = TOKEN_BY_ADDRESS.get(lower);
if (bundled && bundled.symbol) return bundled.symbol;
const tracked = ((sources && sources.trackedTokens) || []).find(
(t) => (t.address || "").toLowerCase() === lower,
);
const candidates = [];
if (tracked && tracked.symbol) candidates.push(tracked.symbol);
const reported = explorerSymbol(lower, sources && sources.wallets);
if (reported) candidates.push(reported);
for (const symbol of candidates) {
if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
}
return null;
}
// What the amount line reads when the scale is unknown. The base units are // What the amount line reads when the scale is unknown. The base units are
// exact and the caveat is part of the same string, so the number on the screen // exact and the caveat is part of the same string, so the number on the screen
// cannot be mistaken for a token quantity, and it can never read as zero for a // cannot be mistaken for a token quantity, and it can never read as zero for a
@@ -84,5 +138,6 @@ function unknownDecimalsAmount(rawAmount) {
module.exports = { module.exports = {
resolveTokenDecimals, resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount, unknownDecimalsAmount,
}; };
+35 -2
View File
@@ -51,6 +51,7 @@
const { const {
Transaction, Transaction,
accessListify, accessListify,
formatEther,
getAddress, getAddress,
getBytes, getBytes,
verifyMessage, verifyMessage,
@@ -134,10 +135,19 @@ const FORBIDDEN_FIELDS = [
const MAX_GAS_LIMIT = 100000000n; const MAX_GAS_LIMIT = 100000000n;
// 100,000 gwei per gas: orders of magnitude above the highest fee either // 100,000 gwei per gas: orders of magnitude above the highest fee either
// supported network has produced, and low enough to catch a fee that would // supported network has produced.
// hand the validator the balance.
const MAX_FEE_PER_GAS = 100000000000000n; const MAX_FEE_PER_GAS = 100000000000000n;
// The largest total fee this wallet will sign, in wei. The two ceilings above
// bound the gas limit and the price per gas each on its own, but the fee a
// validator is actually paid is their product, and a gas limit and a price
// that are each under their own ceiling still multiply to thousands of ETH —
// 30,000,000 gas at 100,000 gwei is about 3,000 ETH. Bounding the product is
// what catches a fee that would hand the validator the balance; the per-field
// ceilings alone do not. A full 30,000,000-gas block at 33 gwei reaches this,
// which no ordinary wallet transaction approaches.
const MAX_TOTAL_FEE = 1000000000000000000n; // 1 ETH
// A refusal to act on an artifact: it is not the thing that was approved, so // A refusal to act on an artifact: it is not the thing that was approved, so
// the approval it was offered against is spent and must not be retried. Every // the approval it was offered against is spent and must not be retried. Every
// throw in this module is one of these; the background distinguishes them from // throw in this module is one of these; the background distinguishes them from
@@ -384,6 +394,28 @@ function assertWithinCeilings(tx) {
); );
} }
} }
// The product: gasLimit × the most this transaction could pay per gas —
// maxFeePerGas for a type-2 transaction, gasPrice for a legacy or type-1
// one. This is the fee a gas-consuming contract can really extract, and it
// is the bound the two per-field ceilings above cannot express.
if (present(tx.gasLimit)) {
const gasLimit = normalizeQuantity(tx.gasLimit, "gas limit");
let price = null;
if (present(tx.maxFeePerGas)) {
price = normalizeQuantity(tx.maxFeePerGas, "maximum fee per gas");
} else if (present(tx.gasPrice)) {
price = normalizeQuantity(tx.gasPrice, "gas price");
}
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
throw refuse(
"This transaction would allow a network fee of up to " +
formatEther(gasLimit * price) +
" ETH, which is more than the " +
formatEther(MAX_TOTAL_FEE) +
" ETH this wallet will sign for.",
);
}
}
} }
// Refuse a field only a transaction type this wallet does not sign can carry. // Refuse a field only a transaction type this wallet does not sign can carry.
@@ -775,4 +807,5 @@ module.exports = {
TX_STAGE_NONCE, TX_STAGE_NONCE,
MAX_GAS_LIMIT, MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
}; };
+9 -2
View File
@@ -22,8 +22,15 @@ const BUILD_DEBUG_MARKER = DEBUG
? "autistmask-build-debug=on" ? "autistmask-build-debug=on"
: "autistmask-build-debug=off"; : "autistmask-build-debug=off";
const DEBUG_MNEMONIC = // Behind DEBUG for the same reason BUILD_DEBUG_MARKER is above: in a release
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck"; // build __BUILD_DEBUG__ is a compile-time false, esbuild drops this branch, and
// the phrase never reaches a distributed bundle. The literal used to survive as
// dead text because module.exports keeps this const live even though wallet.js's
// only use of it is folded away; making the value itself fold to null removes
// it. script/verify-build fails a release build if the phrase appears anyway.
const DEBUG_MNEMONIC = DEBUG
? "cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
: null;
const ETHEREUM_MAINNET_CHAIN_ID = "0x1"; const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7"; const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
+10 -9
View File
@@ -38,15 +38,16 @@
// real popup entry point onto EVERY view the popup can reopen onto. // real popup entry point onto EVERY view the popup can reopen onto.
// //
// That last part is the whole point, because this defect class lives on the // That last part is the whole point, because this defect class lives on the
// RESTORE path and not on Home: a field one of those boots corrupts and a // RESTORE path and not on Home. Take the claim NARROWLY, exactly as that file
// restorable view dereferences on its render turns that suite red, at either // states it: what those boots prove is no structural dereference on the code
// polarity — a value nothing writes is wrong-typed and so truthy, so each such // paths a WHOLLY-CORRUPTED PROFILE takes — which is not every path a stored
// field is also driven falsy, or proven unable to be falsy after the floor. So // record takes. Not driven: any pairing of values the four slots do not
// does a field that gains a floor while its row still claims it has none. Two // produce, a view only forward navigation opens, anything behind a click, and
// things the boots do not drive: a MIX of polarities, since one boot puts every // everything a healthy profile reaches. Within that boundary the verdict is
// corrupted field on the same slot; and whatever no stored record reaches by // unconditional, including a dereference that takes two corrupted fields at
// itself — a view only forward navigation opens, and anything behind a click. A // once. That suite also goes red on a field that gains a floor while its row
// field added to PERSISTED_FIELDS with no row fails the suite too. // 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
+2 -3
View File
@@ -2,10 +2,10 @@
// swap details. Designed to be extended with other DEX decoders later. // swap details. Designed to be extended with other DEX decoders later.
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers"); const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { truncateAmountNeverZero } = require("./amountDisplay"); const { truncateAmountNeverZero } = require("./amountDisplay");
const { const {
resolveTokenDecimals, resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount, unknownDecimalsAmount,
} = require("./approvalAmount"); } = require("./approvalAmount");
@@ -123,9 +123,8 @@ function tokenInfo(address, sources) {
if (address === "0x0000000000000000000000000000000000000000") { if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: "ETH", decimals: 18, address: null }; return { symbol: "ETH", decimals: 18, address: null };
} }
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return { return {
symbol: t ? t.symbol : null, symbol: resolveTokenSymbol(address, sources),
decimals: resolveTokenDecimals(address, sources), decimals: resolveTokenDecimals(address, sources),
address, address,
}; };
+58
View File
@@ -0,0 +1,58 @@
// Adding a second wallet accepts a password different from the first one's
// with nothing on screen saying the two are separate — each wallet has its
// own encryptedSecret, so per-wallet passwords are by design, but the add
// screen said only "Choose a password"
// (https://git.eeqj.de/sneak/AutistMask/issues/374).
//
// The fix is copy: a note on the password screen that says each wallet has
// its own password and this one need not match. It is only meaningful once
// a wallet exists — on the very first wallet there is no other password to
// be separate from — so it is shown then and hidden otherwise. These boot
// the real popup and reach the add-wallet screen through the same button a
// user presses, so the note's visibility is decided by the real show().
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
POPUP_HTML,
} = require("./support/popupBoot");
const NOTE = "add-wallet-separate-password-note";
afterEach(() => {
cleanupPopup();
});
describe("second-wallet password note", () => {
test("hidden while onboarding the first wallet", async () => {
const page = await bootPopup(undefined);
expect(page.pageErrors).toEqual([]);
await page.click("btn-welcome-add");
expect(page.visibleViews()).toContain("add-wallet");
expect(page.hidden(NOTE)).toBe(true);
});
test("shown when a wallet already exists", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
await page.click("btn-main-add-wallet");
expect(page.visibleViews()).toContain("add-wallet");
expect(page.hidden(NOTE)).toBe(false);
});
// The copy states the two facts the definition of done asks for — each
// wallet has its own password, and this one need not match — and stays
// consistent with the no-password-reset reality of
// https://git.eeqj.de/sneak/AutistMask/issues/312 by not promising any
// recovery or reset here.
test("the note says the password is per-wallet and need not match", () => {
const note = /id="add-wallet-separate-password-note"[^>]*>([^]*?)<\/p>/
.exec(POPUP_HTML)[1]
.replace(/\s+/g, " ")
.trim();
expect(note).toContain("its own");
expect(note).toContain("need not match");
expect(note).not.toMatch(/recover|reset/i);
});
});
+5 -3
View File
@@ -143,16 +143,18 @@ describe("decodeCalldata amount", () => {
state.trackedTokens = [ state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 }, { address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
]; ];
// The tracked entry supplies both: the scale (5000.0000) and, since
// issue #323, the symbol that the scale is counted in.
expect( expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN), amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000"); ).toBe("5000.0000 NOVEL");
}); });
test("transfer priced off the explorer's decimals shows the true quantity", () => { test("transfer priced off the explorer's decimals shows the true quantity", () => {
state.wallets = walletsHolding(NOVEL_TOKEN, "6"); state.wallets = walletsHolding(NOVEL_TOKEN, "6");
expect( expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN), amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000"); ).toBe("5000.0000 NOVEL");
}); });
test("transfer of an unknown-decimals token shows base units, not a number", () => { test("transfer of an unknown-decimals token shows base units, not a number", () => {
@@ -172,7 +174,7 @@ describe("decodeCalldata amount", () => {
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 }, { address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
]; ];
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe( expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
"5000.0000", "5000.0000 NOVEL",
); );
}); });
+18
View File
@@ -212,6 +212,24 @@ describe("prepareApprovalTx", () => {
).rejects.toThrow(/gas limit no network this wallet supports/); ).rejects.toThrow(/gas limit no network this wallet supports/);
}); });
// The combined bound at population: a gas limit and a fee that are each
// under their own ceiling but multiply to thousands of ETH is refused
// before the approval window opens, so the user is never shown a
// balance-draining fee to click past.
test("refuses a fee whose product with the gas limit is over the bound", async () => {
const gouging = providerWith({
estimateGas: async () => 30000000n,
getFeeData: async () => ({
gasPrice: MAX_FEE_PER_GAS,
maxFeePerGas: MAX_FEE_PER_GAS,
maxPriorityFeePerGas: 1000000000n,
}),
});
await expect(
prepareApprovalTx(gouging, signer.address, TX_PARAMS),
).rejects.toThrow(/network fee of up to/);
});
// No approval and no window: the failure goes back to the page the click // No approval and no window: the failure goes back to the page the click
// came from, in a sentence. // came from, in a sentence.
test("reports a failed estimate as a full sentence", async () => { test("reports a failed estimate as a full sentence", async () => {
+114
View File
@@ -28,6 +28,7 @@ const {
TX_STAGE_NONCE, TX_STAGE_NONCE,
MAX_GAS_LIMIT, MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify"); } = require("../src/shared/approvalVerify");
const { prepareApprovalTx } = require("../src/shared/approvalTx"); const { prepareApprovalTx } = require("../src/shared/approvalTx");
const { getSignerForAddress } = require("../src/shared/wallet"); const { getSignerForAddress } = require("../src/shared/wallet");
@@ -475,18 +476,131 @@ describe("verifySignedTx field comparison", () => {
assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }), assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }),
).toThrow(/fee per gas far above any plausible value/); ).toThrow(/fee per gas far above any plausible value/);
} }
// Each field at its own ceiling multiplies to about 10,000 ETH, which
// is exactly the combination the per-field ceilings cannot see and the
// product bound is for: it is refused, not accepted.
expect(() => expect(() =>
assertWithinCeilings({ assertWithinCeilings({
gasLimit: MAX_GAS_LIMIT, gasLimit: MAX_GAS_LIMIT,
maxFeePerGas: MAX_FEE_PER_GAS, maxFeePerGas: MAX_FEE_PER_GAS,
maxPriorityFeePerGas: MAX_FEE_PER_GAS, maxPriorityFeePerGas: MAX_FEE_PER_GAS,
}), }),
).toThrow(/network fee of up to/);
// An ordinary transaction — a modest gas limit and a modest fee, each
// far under its ceiling and their product far under the bound — passes.
expect(() =>
assertWithinCeilings({
gasLimit: 21000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
).not.toThrow(); ).not.toThrow();
// Nothing to bound is not a failure: a type 2 approval carries no gas // Nothing to bound is not a failure: a type 2 approval carries no gas
// price, and a bare object must not be refused for lacking one. // price, and a bare object must not be refused for lacking one.
expect(() => assertWithinCeilings({})).not.toThrow(); expect(() => assertWithinCeilings({})).not.toThrow();
}); });
// The defect this issue closes: gasLimit and maxFeePerGas each under their
// own ceiling, but their product — the fee a gas-consuming contract can
// really extract — thousands of ETH. The per-field ceilings accept it; the
// product bound refuses it, on either side of the screen.
describe("the combined fee bound", () => {
// A gas limit and a fee that are each comfortably under their own
// ceiling but multiply to well over 1 ETH: 30,000,000 gas at 100,000
// gwei is about 3,000 ETH.
const OVER = { gasLimit: 30000000n, maxFeePerGas: 100000000000000n };
test("each field is under its own ceiling", () => {
expect(OVER.gasLimit).toBeLessThan(MAX_GAS_LIMIT);
expect(OVER.maxFeePerGas).toBeLessThanOrEqual(MAX_FEE_PER_GAS);
expect(OVER.gasLimit * OVER.maxFeePerGas).toBeGreaterThan(
MAX_TOTAL_FEE,
);
});
test("assertWithinCeilings refuses the product over the bound", () => {
expect(() =>
assertWithinCeilings({
...OVER,
maxPriorityFeePerGas: 1000000000n,
}),
).toThrow(/network fee of up to 3000\.0 ETH/);
});
test("assertWithinCeilings bounds a legacy gasPrice the same way", () => {
expect(() =>
assertWithinCeilings({
gasLimit: OVER.gasLimit,
gasPrice: OVER.maxFeePerGas,
}),
).toThrow(/network fee of up to/);
});
// The boundary itself, pinned rather than only some value well past
// it. Both fields stay under their own ceilings, so it is the product
// and nothing else that decides these two cases: a gas limit of 10,000
// at the per-gas ceiling is exactly 1 ETH.
test("assertWithinCeilings accepts a product exactly at the bound and refuses one wei over", () => {
expect(MAX_FEE_PER_GAS * 10000n).toBe(MAX_TOTAL_FEE);
expect(() =>
assertWithinCeilings({
gasLimit: 10000n,
maxFeePerGas: MAX_FEE_PER_GAS,
}),
).not.toThrow();
expect(() =>
assertWithinCeilings({
gasLimit: 10001n,
maxFeePerGas: MAX_FEE_PER_GAS,
}),
).toThrow(/network fee of up to/);
});
// The dApp path: an artifact whose fee is within each field's ceiling
// but over the product bound, both displayed and signed, is refused at
// verification just as it is at population.
test("verifySignedTx refuses an over-bound product even when displayed", async () => {
const raw = await signedWith(OVER);
expect(() =>
verifySignedTx(
raw,
approvedFor(TX_PARAMS, OVER),
signer.address,
SELECTED,
),
).toThrow(/network fee of up to/);
});
test("verifySignedTx accepts a product just under the bound", async () => {
// 21,000 gas at 40 gwei is 0.00084 ETH — an ordinary send.
const under = { gasLimit: 21000n, maxFeePerGas: 40000000000n };
expect(under.gasLimit * under.maxFeePerGas).toBeLessThan(
MAX_TOTAL_FEE,
);
const raw = await signedWith(under);
expect(() =>
verifySignedTx(
raw,
approvedFor(TX_PARAMS, under),
signer.address,
SELECTED,
),
).not.toThrow();
});
test("the refusal names the fee and the limit in a full sentence", () => {
try {
assertWithinCeilings(OVER);
throw new Error("expected a rejection");
} catch (e) {
expect(e.approvalMismatch).toBe(true);
expect(e.message).toMatch(/^[A-Z].*\.$/);
expect(e.message).toContain("3000.0 ETH");
expect(e.message).toContain("1.0 ETH");
}
});
});
test("every field mismatch is a refusal, not a warning", async () => { test("every field mismatch is a refusal, not a warning", async () => {
const raw = await signedWith({ nonce: 8 }); const raw = await signedWith({ nonce: 8 });
try { try {
+143
View File
@@ -1541,6 +1541,149 @@ describe("a claimed approval outlives every other retirement path", () => {
}); });
}); });
// The approval popup connects a port named for its approval whatever the
// approval's kind, so a decision or a disconnect on that port can reach a
// transaction approval. Both must be declined: the port decides only
// site-connection approvals, and settling a transaction approval it does not
// own — while an attempt is broadcasting behind it — is the round-3 fund-loss
// bug, where the page is told the request was rejected as the transaction goes
// out. These three paths route through settleApproval() and, before this
// suite, were exercised only against site approvals.
describe("the site-connection port never retires a transaction approval", () => {
async function txMidBroadcast() {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
return { bg, pending, id, inFlight, first };
}
test("an approve on the port does not settle it", async () => {
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
bg.connectApproval(id).decide(true, false);
await settle();
expect(pending.result()).toBeNull();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
});
test("a reject on the port does not settle it", async () => {
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
bg.connectApproval(id).decide(false, false);
await settle();
expect(pending.result()).toBeNull();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
});
test("a port disconnect does not settle it", async () => {
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
bg.connectApproval(id).disconnect();
await settle();
expect(pending.result()).toBeNull();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
});
});
// AUTISTMASK_TX_RESPONSE signs and broadcasts a transaction, so it is honoured
// only for a transaction approval. A reject shaped as this message used to
// retire a sign or connection approval outright, and an approve carrying a
// signed artifact used to run the broadcast path against an approval that names
// no transaction, failing closed only by throwing deeper in.
describe("a transaction response is honoured only for a transaction approval", () => {
test("a reject does not retire a sign approval", async () => {
const bg = loadBackground();
const pending = bg.requestSign();
await settle();
const id = pending.id();
bg.send(
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toBeNull();
// Still live: its own reject settles it.
bg.send(
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
test("a reject does not retire a connection approval", async () => {
const bg = loadBackground({ actionPopup: true });
const pending = bg.requestSite();
await settle();
const id = pending.id();
bg.send(
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toBeNull();
// Still live: the port that owns it connects the site.
const port = bg.connectApproval(id);
port.decide(true, false);
port.disconnect();
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
});
test("an approve carrying a signed transaction never broadcasts against a sign approval", async () => {
const bg = loadBackground();
const pending = bg.requestSign();
await settle();
const id = pending.id();
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(pending.result()).toBeNull();
});
});
// A handler that throws must still answer. `sendResponse` is the only thing // A handler that throws must still answer. `sendResponse` is the only thing
// that settles the page's window.ethereum.request() promise, so a throw that // that settles the page's window.ethereum.request() promise, so a throw that
// escapes a handler leaves that promise pending forever — no error, no // escapes a handler leaves that promise pending forever — no error, no
+100
View File
@@ -0,0 +1,100 @@
// The wallet's OWN send path enforces the same combined fee bound the dApp
// path does (https://git.eeqj.de/sneak/AutistMask/issues/399).
//
// The send in src/popup/views/confirmTx.js pins no fee fields, so ethers fills
// maxFeePerGas and the gas limit from whatever the configured RPC node
// answers. Nothing bounded that: a hostile node could report a fee whose
// product with the gas limit is thousands of ETH, and it would be both
// displayed and signed. populateVerifyAndSend() populates the transaction and
// runs assertWithinCeilings() on the populated fees before signing, so an
// over-bound send is refused before anything is broadcast.
//
// The check is driven here with a fake connected signer rather than a real
// one: populateTransaction() returns the fees the node would have produced,
// and sendTransaction() records whether the send actually happened. The real
// DOM path around it — reading the fee error into the reserved errors box — is
// covered by the Chrome e2e suite.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
const {
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify");
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A signer whose populateTransaction() fills in the fees a node quoted and
// whose sendTransaction() records the call, so a test can assert whether the
// send was reached at all.
function fakeSigner(fees) {
const sent = [];
return {
sent,
populateTransaction: async (request) => ({
...request,
from: RECIPIENT,
nonce: 0,
type: 2,
chainId: 1n,
gasLimit: fees.gasLimit,
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: 1000000000n,
}),
sendTransaction: async (tx) => {
sent.push(tx);
return { hash: "0xabc" };
},
};
}
const ETH_SEND = { token: "ETH", to: RECIPIENT, amount: "1.0" };
describe("populateVerifyAndSend enforces the combined fee bound", () => {
// A gas limit and a fee that are each under their own field ceiling, but
// multiply to about 3,000 ETH — the combination the per-field ceilings
// cannot see.
const OVER = { gasLimit: 30000000n, maxFeePerGas: MAX_FEE_PER_GAS };
test("each field is under its ceiling but the product is over the bound", () => {
expect(OVER.maxFeePerGas).toBeLessThanOrEqual(MAX_FEE_PER_GAS);
expect(OVER.gasLimit * OVER.maxFeePerGas).toBeGreaterThan(
MAX_TOTAL_FEE,
);
});
test("refuses an over-bound send without broadcasting it", async () => {
const signer = fakeSigner(OVER);
let thrown;
try {
await populateVerifyAndSend(signer, ETH_SEND);
} catch (e) {
thrown = e;
}
expect(thrown).toBeDefined();
expect(thrown.approvalMismatch).toBe(true);
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
expect(thrown.message).toContain("3000.0 ETH");
expect(thrown.message).toContain("1.0 ETH");
// The one guarantee that matters: nothing was signed or sent.
expect(signer.sent).toHaveLength(0);
});
test("broadcasts a send whose product is just under the bound", async () => {
// 21,000 gas at 40 gwei is 0.00084 ETH — an ordinary send.
const under = { gasLimit: 21000n, maxFeePerGas: 40000000000n };
expect(under.gasLimit * under.maxFeePerGas).toBeLessThan(MAX_TOTAL_FEE);
const signer = fakeSigner(under);
const tx = await populateVerifyAndSend(signer, ETH_SEND);
expect(tx.hash).toBe("0xabc");
expect(signer.sent).toHaveLength(1);
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
});
});
+59
View File
@@ -0,0 +1,59 @@
// Tests for the debug/testnet banner (issue #375).
//
// On a testnet the banner is raised even in a release build, but it must not
// append the active view's internal id: the user should see "[TESTNET]", never
// "[TESTNET] (approve-tx)". The suffix is gated on the compile-time DEBUG
// constant, which is false in a plain test load, so this drives exactly the
// text a shipped build renders. Revert the gate to the old unconditional
// suffix and this fails.
//
// The banner is created on demand by updateDebugBanner(); the document stub
// records what it prepends so the assertion can read the resulting text.
function makeBanner() {
return {
id: "",
textContent: "",
style: { cssText: "" },
remove() {},
};
}
function makeDocument() {
let banner = null;
return {
getElementById(id) {
return id === "debug-banner" ? banner : null;
},
createElement: () => makeBanner(),
body: {
prepend(node) {
banner = node;
},
},
};
}
function load() {
jest.resetModules();
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
globalThis.document = makeDocument();
const helpers = require("../src/popup/views/helpers");
const { state } = require("../src/shared/state");
return { helpers, state };
}
describe("the release banner on a testnet", () => {
test("carries no internal view id", () => {
const { helpers, state } = load();
state.networkId = "sepolia";
helpers.updateDebugBanner("approve-tx");
expect(
globalThis.document.getElementById("debug-banner").textContent,
).toBe("[TESTNET]");
});
});
+65 -5
View File
@@ -172,6 +172,15 @@ async function openLostPassword(deleteWallet, walletIdx) {
await click("btn-delete-wallet-lost-password"); await click("btn-delete-wallet-lost-password");
} }
// Delete a wallet through the password route: open its confirm screen,
// enter the password, and confirm. The vault is mocked, so the password
// text itself is irrelevant — decryptWithPassword decides pass or fail.
async function deleteWithPassword(deleteWallet, walletIdx) {
deleteWallet.show(walletIdx);
node("delete-wallet-password").value = "any password";
await click("btn-delete-wallet-confirm");
}
// ------------------------------------------------------------ tests // ------------------------------------------------------------ tests
// The stub is what every persistence assertion below rests on, so its one // The stub is what every persistence assertion below rests on, so its one
@@ -456,15 +465,21 @@ describe("what the screen leaves behind", () => {
); );
}); });
// Left mid-delete, the screen has to come back usable. // Both routes now re-enable through finishDelete(), not their leave
test("the confirm button is re-enabled on the way out", async () => { // hooks, so the button comes back live once a delete completes.
const { helpers, deleteWallet } = load(); test("the confirm button is re-enabled after a delete", async () => {
const { deleteWallet } = load();
await openLostPassword(deleteWallet, 1); await openLostPassword(deleteWallet, 1);
node("btn-delete-wallet-lost-confirm").disabled = true; node("delete-wallet-lost-name-input").value = "Wallet 2";
helpers.showView("settings"); await click("btn-delete-wallet-lost-confirm");
expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false); expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false);
expect(
node("btn-delete-wallet-lost-confirm").classList.contains(
"text-muted",
),
).toBe(false);
}); });
// A wallet name is not a secret, so the screen is excluded for the // A wallet name is not a secret, so the screen is excluded for the
@@ -475,3 +490,48 @@ describe("what the screen leaves behind", () => {
expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false); expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false);
}); });
}); });
// The password route is the pre-existing bug this file's fix addresses:
// its Confirm Delete button was disabled before the decrypt and never
// re-enabled on success, so a second delete in the same popup session
// found a dead button. Now both routes re-enable through finishDelete().
//
// Against head these tests fail: with the re-enable absent, the button
// stays disabled after the first delete, so the disabled assertions read
// true where they expect false.
describe("the password route's confirm button", () => {
test("is re-enabled after a successful delete", async () => {
const { deleteWallet, vault } = load();
vault.decryptWithPassword.mockResolvedValue();
await deleteWithPassword(deleteWallet, 1);
expect(node("btn-delete-wallet-confirm").disabled).toBe(false);
expect(
node("btn-delete-wallet-confirm").classList.contains("text-muted"),
).toBe(false);
});
// The reported symptom: delete one wallet, then open Delete Wallet for
// a second one without reopening the popup. The button must be live on
// that second visit, and the second delete must actually persist.
test("a second delete works in the same popup session", async () => {
const { deleteWallet, vault, storage } = load();
vault.decryptWithPassword.mockResolvedValue();
await deleteWithPassword(deleteWallet, 1);
// Wallet 2 is gone; the list is now [Wallet 1, Wallet 3]. Opening
// the confirm screen for the wallet now at index 1 (Wallet 3) must
// find its button live, not the dead one the first delete left.
deleteWallet.show(1);
expect(node("btn-delete-wallet-confirm").disabled).toBe(false);
node("delete-wallet-password").value = "any password";
await click("btn-delete-wallet-confirm");
expect((await persistedWallets(storage)).map((w) => w.name)).toEqual([
"Wallet 1",
]);
});
});
+203
View File
@@ -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() {
+17 -8
View File
@@ -89,19 +89,28 @@ describe("chrome extension identity", () => {
// The private half is a credential. It has never been in this repo and no // The private half is a credential. It has never been in this repo and no
// target generates one into the working tree; this fails loudly if that // target generates one into the working tree; this fails loudly if that
// ever changes, because a committed .pem is a key anyone can sign a CRX // ever changes, because a committed private key is one anyone can sign a
// with under this extension's id. // CRX with under this extension's id.
//
// Matched by CONTENT, not by filename: a key committed as notes.txt or with
// no extension carries the same risk as one named key.pem, and a
// filename-only check waves it through. The PEM header a private key opens
// with is the signature searched for. The pattern does not trip on its own
// source: the bracket-expression characters between the two anchors are not
// in the character class, so this file is not a match for it.
const PRIVATE_KEY_HEADER = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
test("no private key is committed anywhere in the tree", () => { test("no private key is committed anywhere in the tree", () => {
const root = path.join(__dirname, "..");
const tracked = require("child_process") const tracked = require("child_process")
.execSync("git ls-files", { .execSync("git ls-files", { cwd: root, encoding: "utf8" })
cwd: path.join(__dirname, ".."),
encoding: "utf8",
})
.split("\n") .split("\n")
.filter(Boolean); .filter(Boolean);
expect(tracked.filter((f) => /\.(pem|key|p12|pfx)$/i.test(f))).toEqual( const offenders = tracked.filter((f) =>
[], PRIVATE_KEY_HEADER.test(
fs.readFileSync(path.join(root, f), "latin1"),
),
); );
expect(offenders).toEqual([]);
}); });
}); });
+3 -2
View File
@@ -253,8 +253,9 @@ describe("the ERC-20 approval line reaches its refusal", () => {
test("a scale the explorer did report still formats", async () => { test("a scale the explorer did report still formats", async () => {
await fetchOnto([row({ decimals: "6" })]); await fetchOnto([row({ decimals: "6" })]);
// The same explorer entry now also names the token (issue #323).
expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe( expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
"1000.0000", "1000.0000 NOVEL",
); );
}); });
}); });
@@ -273,7 +274,7 @@ describe("the swap approval line reaches its refusal", () => {
await fetchOnto([row({ decimals: "6" })]); await fetchOnto([row({ decimals: "6" })]);
expect( expect(
swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)), swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
).toBe("1000.0000"); ).toBe("1000.0000 NOVEL");
}); });
}); });
+71 -32
View File
@@ -44,12 +44,32 @@
// reason, and a field that cannot be falsy after the floor says so in its row // reason, and a field that cannot be falsy after the floor says so in its row
// and is proven so. // and is proven so.
// //
// What that buys: a field any restorable view dereferences on that view's // READ THE CLAIM NARROWLY. What this file proves is: NO STRUCTURAL
// render turns this file red, at either polarity. What it does not buy is a // DEREFERENCE ON THE CODE PATHS A WHOLLY-CORRUPTED PROFILE TAKES. That is not
// MIX of polarities — one boot puts every swept field on the same slot, so a // every path a stored record takes, and the difference is the whole of what
// branch reached only when one swept field is truthy and another falsy is not // this file does not cover:
// entered. Booting every field separately at every value would be several //
// hundred boots and most of the suite's budget; this is forty-four. // - 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
@@ -738,17 +758,23 @@ describe("a hostile routing value restoring onto", () => {
// routing turns this red and has to declare `routes` and take the individual // routing turns this red and has to declare `routes` and take the individual
// sweep above. // sweep above.
// //
// Combining does hide one thing, and the last slot is what stops it. A hostile // 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 // 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 // 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 // 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 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 // is also the state an ordinary install boots in for three of them, while the
// fields that cannot be falsy stay hostile. Beyond that, a throw fails the boot // fields that cannot be falsy stay hostile-truthy. That makes it a MIX, and a
// whichever field threw, and a renderer that never ran is what `restored` // deliberate one — the interaction between a falsy flag and a still-hostile
// forbids. When it does go red, the same view is re-booted one field at a time // theme is a shape a stored record really produces.
// so the failure names the fields rather than leaving a reader to bisect twelve //
// of them. // 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 UNROUTED = CONTRACT.filter((row) => swept(row) && !routes(row));
const HOSTILE_SLOTS = Math.max( const HOSTILE_SLOTS = Math.max(
...UNROUTED.map((row) => sweptValues(row).length), ...UNROUTED.map((row) => sweptValues(row).length),
@@ -774,29 +800,42 @@ describe("every field the router does not read, corrupted at once, onto", () =>
restoringOnto(view, fields), restoringOnto(view, fields),
view, view,
); );
if (together.errors.length === 0 && together.restored) {
expect(together).toEqual(RESTORED); // The per-field re-boot only DECORATES the message. The
return; // 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)}`,
);
}
} }
const named = []; expect({
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}`),
);
}
expect({ view: view, fields: named }).toEqual({
view: view, view: view,
fields: [], together: together,
}); fields: named,
}).toEqual({ view: view, together: RESTORED, fields: [] });
}); });
} }
} }
+156
View File
@@ -0,0 +1,156 @@
// The symbol the dApp approval and status screens label a token with.
//
// Issue #323: the approval screen labelled anything outside the bundled list
// `Unknown token`, even a token the user tracks or holds a balance of, while
// the amount line already read that token's *scale* from those same sources
// (issue #306). The name and the scale disagreed about which sources they
// trust. resolveTokenSymbol() closes that gap: it draws the symbol from the
// bundled list, then the tracked tokens, then the explorer's report — the
// precedence resolveTokenDecimals() uses — and returns null, not a guess,
// when nothing names it, so the screens keep saying `Unknown token`.
//
// A tracked or explorer-reported symbol is attacker-influenced text, so it
// stays subject to the spoof rule (src/shared/symbolSpoof.js): resolving a
// symbol must not become a new way for a stray contract to wear a bundled or
// native ticker.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const { resolveTokenSymbol } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const iface = new Interface(ERC20_ABI);
// Outside the bundled list, as the great majority of ERC-20s are.
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list: USDC at 6 decimals, DAI at 18.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const FIVE_THOUSAND_AT_SIX = 5000000000n;
function transferData(amount) {
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}
// A wallet whose block-explorer balance for `token` reports `symbol`, shaped
// as balances.js writes it onto state.
function walletsReporting(token, symbol) {
return [
{
name: "Wallet 1",
addresses: [
{
address: "0x" + "a".repeat(40),
balance: "1.0",
tokenBalances: [
{
address: token,
symbol,
decimals: 6,
balance: "5000.0",
},
],
},
],
},
];
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("resolveTokenSymbol", () => {
test("reads the bundled list", () => {
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
});
test("prefers the bundled list over a tracked entry", () => {
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
});
test("reads a token the user tracks", () => {
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "NOVEL" }];
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBe("NOVEL");
});
test("reads the symbol the explorer reported", () => {
state.wallets = walletsReporting(NOVEL_TOKEN, "NOVEL");
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBe("NOVEL");
});
test("is null when no source names the token", () => {
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
test("refuses a name the explorer's own entries disagree about", () => {
const wallets = walletsReporting(NOVEL_TOKEN, "NOVEL");
wallets[0].addresses.push({
address: "0x" + "b".repeat(40),
balance: "0.0",
tokenBalances: [{ address: NOVEL_TOKEN, symbol: "OTHER" }],
});
state.wallets = wallets;
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
test("rejects a tracked entry claiming a bundled ticker it is not", () => {
// NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC.
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
test("rejects an explorer entry claiming the native ETH ticker", () => {
state.wallets = walletsReporting(NOVEL_TOKEN, "ETH");
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
});
describe("decodeCalldata symbol", () => {
test("a tracked token is named, not called Unknown", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(decoded.description).toBe("Transfer NOVEL");
const amount = decoded.details.find((d) => d.label === "Amount");
expect(amount.value).toBe("5000.0000 NOVEL");
});
test("a token nothing knows keeps a symbol-less label", () => {
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(decoded.description).toBe("Transfer ERC-20 token");
const token = decoded.details.find((d) => d.label === "Token");
// The Token line carries the address and is flagged for the screen's
// symbol lookup, which resolves to nothing here — so `Unknown token`.
expect(token.isToken).toBe(true);
expect(token.address).toBe(NOVEL_TOKEN);
expect(resolveTokenSymbol(token.address, state)).toBeNull();
});
test("a tracked token spoofing a bundled ticker is not named by it", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "USDC", decimals: 6 },
];
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(decoded.description).toBe("Transfer ERC-20 token");
const amount = decoded.details.find((d) => d.label === "Amount");
expect(amount.value).not.toMatch(/USDC/);
});
});
+8 -3
View File
@@ -78,15 +78,20 @@ describe("a swap to a token absent from the bundled list", () => {
); );
}); });
test("names the address when the scale is known but the symbol is not", () => { test("names the tracked symbol alongside the address (issue #323)", () => {
// The tracked entry supplies both halves now: the scale, and the
// symbol the output line is named by. Before #323 the symbol was read
// from the bundled list alone, so this line fell back to the address.
const sources = { const sources = {
trackedTokens: [ trackedTokens: [
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 }, { address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
], ],
}; };
expect(detail(data(), "Token Out", sources).value).toBe(NOVEL_OUT); expect(detail(data(), "Token Out", sources).value).toBe(
"NOVEL (" + NOVEL_OUT + ")",
);
expect(detail(data(), "Min. received", sources).value).toBe( expect(detail(data(), "Min. received", sources).value).toBe(
"1000.0000", "1000.0000 NOVEL",
); );
}); });
}); });
+4 -3
View File
@@ -98,12 +98,13 @@ describe("a swap of a token outside the bundled list", () => {
state.trackedTokens = [ state.trackedTokens = [
{ address: NOVEL, symbol: "NOVEL", decimals: 6 }, { address: NOVEL, symbol: "NOVEL", decimals: 6 },
]; ];
expect(swapDetail(data(), "Amount").value).toBe("1000.0000"); // The tracked entry names the token as well as scaling it (issue #323).
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
}); });
test("shows the true quantity from the explorer's decimals", () => { test("shows the true quantity from the explorer's decimals", () => {
state.wallets = walletsHolding(NOVEL, "6"); state.wallets = walletsHolding(NOVEL, "6");
expect(swapDetail(data(), "Amount").value).toBe("1000.0000"); expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
}); });
test("refuses to format when nothing knows the scale", () => { test("refuses to format when nothing knows the scale", () => {
@@ -140,7 +141,7 @@ describe("the Min. received line takes the same rule", () => {
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 }, { address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
]; ];
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX); const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
expect(swapDetail(data, "Min. received").value).toBe("1000.0000"); expect(swapDetail(data, "Min. received").value).toBe("1000.0000 NOVEL");
}); });
}); });
+8
View File
@@ -27,6 +27,14 @@ describe("generateMnemonic in a release build", () => {
expect(constants.DEBUG).toBe(false); expect(constants.DEBUG).toBe(false);
}); });
test("the test phrase folds away when DEBUG is false", () => {
// The release bundle is what must not carry the phrase; here, with the
// define absent, DEBUG_MNEMONIC is the null branch the bundler keeps,
// and the literal only exists in the branch it drops.
const { constants } = loadWallet();
expect(constants.DEBUG_MNEMONIC).toBeNull();
});
test("returns fresh, valid 12-word phrases that are not the test phrase", () => { test("returns fresh, valid 12-word phrases that are not the test phrase", () => {
const { constants, wallet } = loadWallet(); const { constants, wallet } = loadWallet();