Compare commits

...
Author SHA1 Message Date
sneak 5aa4010f6c fix: say a contract creation has no recipient instead of a blank line (closes #250)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A transaction with no `to` showed a blank recipient line on the wait,
success and error screens and the transaction detail view: an empty
address, with a colour dot whose colour was `undefined`. The approval
screen showed "(contract creation)".

All five now say "This transaction creates a new contract. It has no
recipient." A transaction with a real `to` is unchanged. The new test
drives each screen both ways.

Model: opus-5-5
2026-10-04 07:38:00 +00:00
clawbot 4b62e31e80 fix: refuse an unsupported method with EIP-1193 code 4200 (closes #279)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
handleRpc() answered a method it does not implement with
"Unsupported method: <method>" and no code, so a site probing for an
optional method could not tell "not implemented" from "the call failed"
and fall back. It now carries code 4200, which EIP-1193 defines for this
case; the message is unchanged. The provider already passes any code
through to the page.

The new test hands the real background's reply to the provider and
checks the page sees 4200.

Model: opus-5-5
2026-10-04 09:24:41 +02:00
clawbot 49a7da87e8 harden: list and end site connections made without Remember in Settings (closes #406)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A site allowed without "Remember" lives only in the background's
in-memory connectedSites map. Settings never listed it, and
AUTISTMASK_REMOVE_SITE, sent on every remove, did nothing, so the user
could not end such a connection.

Settings now asks the background for those sites and lists them under
Connected Sites. Removing a site from Allowed Sites or Connected Sites
drops its remembered entry under every address and sends
AUTISTMASK_REMOVE_SITE with the hostname; the background deletes every
matching connectedSites entry and sends accountsChanged with an empty
list to the site's tabs. Only the extension's own pages may send either
message.

Model: opus-5-5
2026-10-04 06:41:39 +02:00
clawbot 5f54fcbb24 harden: end a site's unremembered connection when its address or wallet is removed (closes #245)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A site connected without "Remember" lives only in the background's
in-memory connectedSites map. Removing an address or deleting a wallet
dropped the remembered permissions but never told the background; the
entry went only as a side effect of the accountsChanged broadcast, which
empties the whole map when the active address changes.

dropSitePermissions(), shared by both removal paths, now sends
AUTISTMASK_ADDRESSES_REMOVED with the removed addresses, and the
background deletes their entries. Only the extension's own pages may
send it.

Model: opus-5-5
2026-10-04 04:58:38 +02:00
clawbot 00d6193ee7 docs: fix stale zero claim, list what decoded approval amount lines read (closes #369)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The README said a genuine zero always renders `0.0000`, beside a
`Min. received` example, though a zero minimum there now reads
`None (no minimum guaranteed)`. It now says the amount rule renders a
zero as `0.0000` and that two swap lines say a zero in words instead:
`Min. received` for a zero minimum, and `Amount` when it shows a V4
exact-in `amountIn` of zero.

A new list says what the decoded ERC-20 and swap amount lines on the
transaction approval screen can read, including that a zero
`BALANCE_CHECK_ERC20` `minBalance` now reads the no-minimum wording.
The token permission warning on the signature screen is left to the
SignApproval section.

Model: opus-5-5
2026-10-04 04:41:38 +02:00
clawbot 6c70a82de8 harden: warn for token-permission typed data and show the primary type ethers signs (closes #400)
check / check (push) Successful in 3m10s
e2e / e2e-chrome (push) Successful in 4m3s
e2e / e2e-firefox (push) Successful in 3m28s
The typed-data screen listed a Permit or Permit2 signature as plain key/value lines, like a sign-in message. It now shows a red warning naming the spender and each token and amount, read only from the fields the signed type declares (a Permit's token is the domain's verifyingContract); anything those fields do not give reads Unknown.

The screen printed the page's primaryType, but ethers signs the type it derives from types. It now shows that type and refuses typed data whose stated type is missing or differs: Sign disabled, checked again where signing starts.

Deviation: the warning names no deadline or expiry; see the issue.
Judgement call: DAI's older permit and Permit2's batch and witness transfer types are recognised too.

Model: opus-5-5
2026-10-04 02:24:50 +02:00
clawbot add11e57de security: announce a fresh EIP-6963 provider UUID per page load (closes #398)
check / check (push) Successful in 1m4s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 33s
The provider UUID was generated once, kept in extension storage and
announced to every page on every load, so any site could read it as a
stable identifier for the install across sites and browser restarts.

inpage.js now generates one UUID per page load, shared by every
announcement in that load, and stores nothing. The eip6963Uuid storage
key and the AUTISTMASK_PROVIDER_UUID content-script message are removed.
The key was never part of the versioned autistmask profile, so the state
schema is untouched. Two inpage.js message listeners lose the leftover
name onUuid.

The test posts each load the same stored UUID the way the old content
script did, and asserts that no load announces it and that two loads
announce different UUIDs.

Model: opus-5-5
2026-10-03 16:26:39 +02:00
clawbot 598de3ff1a fix: re-enable Confirm Delete after a delete, so a second one needs no reopen (closes #335)
check / check (push) Successful in 3m12s
e2e / e2e-chrome (push) Successful in 4m40s
e2e / e2e-firefox (push) Successful in 3m27s
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
46 changed files with 3449 additions and 291 deletions
+124 -26
View File
@@ -64,7 +64,9 @@ release/SHA256SUMS
```
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
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.
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
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
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
@@ -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
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
is the authoritative record of a specific transaction and shows the exact,
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
@@ -869,9 +882,12 @@ 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
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` —
the exception only fires where the entire displayed figure would read as zero. A
genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999`
shows as `0.9999`, never rounded up.
the exception only fires where the entire displayed figure would read as zero.
Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
shows is a V4 exact-in `amountIn` of zero.
The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
@@ -898,9 +914,10 @@ approximation but a different number — 1,000 units of a 6-decimal token
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
to state what is being authorized. Both amount paths of that screen take this
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). An
unbounded allowance or permit needs no scale to describe and is still shown as
`Unlimited`.
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
token permission warning on the signature screen takes the same rule for its
amounts. An unbounded allowance or permit needs no scale to describe and is
still shown as `Unlimited`.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be
@@ -923,6 +940,41 @@ and compare against. Reading the stored field directly instead answers `null`
for a bundled or tracked token the explorer merely omitted, which is not a
refusal the wallet has any reason to make.
**Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below), do not always read as a number. They can
read:
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
truncated to four decimals, with the floor and the zero cases described above.
- `<amount> base units (decimals unknown)` when the scale is unknown: the
base-unit integer, rather than a figure at a guessed scale (see Unknown token
scale above).
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in
or `WRAP_ETH` amount that large, which is not an allowance. The router's
whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such amount.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the
amount of one step that names an input token or amount: the last
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
`WRAP_ETH` or V4 swap step, a V4 swap step giving the `amountIn` of its first
readable exact-in action.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
read `0.0000` when the token's scale was known.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side. The token permission warning on the signature screen has its own
amount wording, including `Unknown`; the SignApproval section below describes
it.
#### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the
@@ -1182,13 +1234,17 @@ view would leave a wallet one click from deletion.
- Send / Receive quick-action buttons, both acting on the active address
- ETH/USD price display
- 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
N" (bold when active), the ENS name if resolved, the full address, an
`[info]` button, an `[x]` button (only on HD and xprv wallets holding more
than one address), the address USD total, and a balance line for ETH and
for each token shown for that address
button for HD and xprv wallets, then one block per address. The block
opens with a row carrying the colour dot, "Address N" (bold when active),
an `[info]` button and an `[x]` button (only on HD and xprv wallets
holding more than one address); the ENS name, if resolved, is below it;
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
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
- **Transitions**:
- Tap address row → sets the active address and broadcasts
@@ -1381,7 +1437,9 @@ view would leave a wallet one click from deletion.
- **Elements**:
- "Transaction Broadcast" heading (no back button — tx is irreversible)
- Amount + symbol
- To: color dot + full address + etherscan link
- To: color dot + full address + etherscan link; for a contract creation,
which has no recipient, "This transaction creates a new contract. It has
no recipient." instead
- Transaction hash: full hash (tap to copy) + etherscan link
- Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
@@ -1410,7 +1468,8 @@ view would leave a wallet one click from deletion.
- Decoded action well (shown when the transaction carried recognized
calldata; the top-level Amount and To are hidden in that case)
- Amount + symbol
- To: color dot + full address + etherscan link
- To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx
- Block number
- Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button
@@ -1425,7 +1484,8 @@ view would leave a wallet one click from deletion.
- **Elements**:
- "Transaction Failed" heading
- Amount + symbol
- To: color dot + full address + etherscan link
- To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx
- Error message (dashed border box)
- Transaction hash section (hidden if broadcast failed before getting hash):
full hash (tap to copy) + etherscan link
@@ -1463,7 +1523,7 @@ view would leave a wallet one click from deletion.
- From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
name if available
name if available. For a contract creation, the same sentence as on WaitTx
- Time: ISO datetime + relative age in parentheses
- Block: block number (tap to copy) + etherscan block link
- Amount: value + symbol (bold)
@@ -1526,8 +1586,14 @@ view would leave a wallet one click from deletion.
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
is refused with a flash message and the field snaps back to the stored
threshold, so a number the user did not type is never stored.
- Allowed Sites: list with remove buttons
- Denied Sites: list with remove buttons
- Allowed Sites: the hostnames remembered as allowed, under any address,
with remove buttons
- Connected Sites: the hostnames of the sites allowed without "Remember my
choice" that are still connected, with remove buttons. Only the background
holds these, in memory, and Settings asks it for them with
`AUTISTMASK_GET_CONNECTED_SITES`
- Denied Sites: the hostnames remembered as denied, under any address, with
remove buttons
- About: project link, license, author, version, release date, and the
commit, which links to the commit in the repository
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
@@ -1538,8 +1604,15 @@ view would leave a wallet one click from deletion.
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
- `[x]` on a wallet → **DeleteWallet**
- Tap wallet name → inline rename field (no screen change)
- `[x]` on a tracked token or a site → removes it in place (no screen
change)
- `[x]` on a tracked token → removes it in place (no screen change)
- `[x]` on an allowed or connected site → disconnects that site, in place:
its hostname is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
without "Remember" from an origin with that hostname, under any address,
and send `accountsChanged` with an empty list to the site's open tabs.
Only the extension's own pages may send either message
- `[x]` on a denied site → forgets the refusal, in place; it connects
nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change)
- "Back" (or Settings gear again) → previous screen (Home)
@@ -1590,6 +1663,10 @@ view would leave a wallet one click from deletion.
- Either way, the active address moves only if it belonged to the deleted
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
(`src/shared/walletDelete.js`)
- Either way, every address the wallet held loses its site permissions of
both kinds: the remembered ones in storage, and the connections approved
without "Remember", which only the background holds, in memory, and drops
on `AUTISTMASK_ADDRESSES_REMOVED`
- "Confirm Delete" (wrong password) → "That password is incorrect. Please
try again." on the error line, nothing deleted
- "I have lost my password" → **DeleteWalletLostPassword**
@@ -1686,6 +1763,10 @@ view would leave a wallet one click from deletion.
so a connected site stops being told about an address the user removed
(`src/shared/walletDelete.js`). A selection in any other wallet is left alone;
one in this wallet follows the splice.
- The address loses its site permissions of both kinds, whether or not it was
the active one: the remembered ones in storage, and any connection approved
without "Remember", which only the background holds, in memory, and drops on
`AUTISTMASK_ADDRESSES_REMOVED`.
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
fresh address rather than handing back the one just removed.
@@ -1726,7 +1807,8 @@ view would leave a wallet one click from deletion.
- **Transitions**:
- "Allow" / "Deny" → closes popup (returns result to background script; the
choice is persisted to the allowed or denied list when "Remember" is
checked)
checked, and an "Allow" without it is listed under Connected Sites in
**Settings**)
- Popup closed without answering → treated as a denial
#### TxApproval (`approve-tx`)
@@ -1752,8 +1834,8 @@ view would leave a wallet one click from deletion.
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding)
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link (or "contract
creation"), token symbol label if known
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
- Value: amount in ETH (4 decimal places, USD in parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
in parentheses), with the gas limit and the fee per gas in gwei below it
@@ -1784,10 +1866,26 @@ view would leave a wallet one click from deletion.
- Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
message fields (EIP-712 typed data)
message fields (EIP-712 typed data). The primary type shown is the one
ethers signs, derived from the typed data's `types`, not the type the site
states.
- Token permission warning, at the top of the message (typed data whose
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
tokens listed here from your address, without asking you again.", then the
spender's full address and, for each token, its symbol, full address and
amount (`Unlimited` for the largest amount the field holds). These are
read only from the fields the signed type declares, never from other keys
the site puts in the message, except a `Permit`'s token, which is the
domain's `verifyingContract`; any those fields do not give is shown as
`Unknown`, and the domain, type and message lines still follow. Only typed
data that cannot be read at all is shown as raw text.
- Password input and an error line
- "Sign" / "Reject" buttons
- **Transitions**:
- Typed data that states no primary type, or one other than the type it
would be signed as, or that cannot be read → shown with the error line
saying so and "Sign" disabled; only "Reject" remains
- "Sign" (correct password) → signs locally → closes popup (returns
signature)
- "Sign" (wrong password, or a signing failure) → error line, no screen
+196
View File
@@ -45,6 +45,202 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-04: A transaction with no `to` says "This transaction creates a new
contract. It has no recipient." on its recipient line
([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The wait, success
and error screens and the transaction detail view showed a blank line there,
with a colour dot whose colour was `undefined`; the approval screen showed
"(contract creation)". A transaction with a real `to` is unchanged.
- 2026-10-04: A method the wallet does not implement is refused with EIP-1193
code `4200` ([#279](https://git.eeqj.de/sneak/AutistMask/issues/279)). The
background's `Unsupported method: <method>` error carried no code, so a site
probing for an optional method could not tell "not implemented" from "the call
failed". The message is unchanged; the background's other errors with no code
are untouched.
- 2026-10-04: Settings lists the sites connected without "Remember", and
removing a site there disconnects it
([#406](https://git.eeqj.de/sneak/AutistMask/issues/406)). Such a connection
lives only in the background's in-memory `connectedSites` map, so Settings
never showed it and the user could not end it; `AUTISTMASK_REMOVE_SITE`, sent
on every remove, did nothing. Settings now asks the background for those sites
(`AUTISTMASK_GET_CONNECTED_SITES`) and lists them under Connected Sites.
Removing a site from Allowed Sites or Connected Sites drops its remembered
entry under every address and sends `AUTISTMASK_REMOVE_SITE` with the
hostname; the background deletes every `connectedSites` entry for that
hostname and sends `accountsChanged` with an empty list to its open tabs. Only
the extension's own pages may send either message. Removing a denied site no
longer sends it, since forgetting a refusal ends no connection.
- 2026-10-04: Removing an address or deleting a wallet ends every site
connection approved without "Remember" for the addresses removed
([#245](https://git.eeqj.de/sneak/AutistMask/issues/245)). Such a connection
lives only in the background's in-memory `connectedSites` map. Both removal
paths dropped the remembered `allowedSites`/`deniedSites` entries, but nothing
told the background, so its entry was cleared only as a side effect: every
change of active address empties the whole map, and removing the active
address changes it. `dropSitePermissions()` in `src/shared/walletDelete.js`,
shared by both paths, now also sends `AUTISTMASK_ADDRESSES_REMOVED` with the
removed addresses, and the background deletes their `connectedSites` entries;
only the extension's own pages may send it.
- 2026-10-03: The typed-data signing screen warns for a token permission, and
names the primary type ethers signs
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2
signature lets its spender take tokens from the signer's address, and the
screen listed it as plain key/value lines, exactly like a sign-in message. For
typed data signed as `Permit` (EIP-2612's, DAI's older one, or any other of
that name) or as one of Permit2's six signature types,
`src/popup/views/approval.js` now shows a red warning at the top of the
message naming the spender and each token and amount, read only from the
fields the signed type declares (a `Permit`'s token is the domain's
`verifyingContract`), with `Unlimited` for the largest amount the field holds,
the existing unknown-scale wording otherwise, and `Unknown` for whatever those
fields do not give. The screen printed the page's `primaryType`, but ethers
signs the type it derives from `types`; the screen now shows the derived type,
and typed data whose stated type is missing or differs, or that cannot be
read, is shown with an error line and Sign disabled, and is refused again
where signing starts. The warning names no deadline or expiry: those fields
mean different things across the shapes, and a date could read as the
permission ending when it does not.
- 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: The EIP-6963 provider UUID is generated fresh on each page load
and never persisted ([#398](https://git.eeqj.de/sneak/AutistMask/issues/398)).
It was created once and stored, then announced verbatim to every page on every
load and across restarts, so any site — connected or not — could read it as a
stable cross-site, cross-session identifier for the install, contradicting the
"no tracking" promise. inpage.js now announces a per-load
`crypto.randomUUID()` and the `eip6963Uuid` storage key and the
`AUTISTMASK_PROVIDER_UUID` content-script message are gone. That key was a
standalone storage entry, never part of the versioned `autistmask` profile, so
the state schema is untouched and no existing profile is affected.
- 2026-09-21: `README.md` no longer says a genuine zero always renders `0.0000`
([#369](https://git.eeqj.de/sneak/AutistMask/issues/369)). The amount rule
still renders one as `0.0000`, but two swap lines say a zero in words instead:
`Min. received` reads `None (no minimum guaranteed)` for a zero minimum, and
`Amount` reads `All available (V4 open delta)` when the amount it shows is a
V4 exact-in `amountIn` of zero. A new list says what the decoded ERC-20 and
swap amount lines on the transaction approval screen can read: a formatted
quantity, base units with decimals unknown, `Unlimited`,
`All available (V4 open delta)` and `None (no minimum guaranteed)`, which a
zero `minBalance` on a `BALANCE_CHECK_ERC20` step now reads instead of
`0.0000` at a known scale. The README also names
`Unknown (not named in the calldata)` on the swap's token lines, and points to
SignApproval for the token permission warning's own wording. Docs only.
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
wallet list was the reported case: the address shared one row with the
`[info]` and `[x]` controls and folded onto a second line, which turns one
42-character string the user is meant to compare into two shorter ones — the
shape an address-poisoning attack wants. The fix is layout, not CSS: every
address in the popup now sits alone on a full-width row, with the colour dot,
the wallet title, the ENS name and the explorer link moved onto a strip above
it, and the transaction rows carry the counterparty's whole address instead of
a `truncateMiddle()`d one squeezed in beside the amount. `truncateMiddle()`
keeps its 10-character cap and its 32-character floor moved into
`renderAddressHtml()`, so the guarantee outlives having no callers. The e2e
suite measures every rendered address in a real Chromium — whole, one line
box, inside its row and inside the popup — across Home, the address, token,
receive, send and transaction detail screens, the confirmation screen and the
dApp transaction prompt.
- 2026-08-23: Both manifests declare toolbar icons, and real PNGs at
16/32/48/128 ship inside both archives
([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest
+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_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"
NEWLINE='
@@ -516,6 +520,24 @@ c_debug_build() {
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; }
# --- dist discard -----------------------------------------------------------
@@ -753,6 +775,13 @@ run_cases() {
check_case "debug bundles under --expect debug pass" \
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" \
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
# 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
# taken from this script's environment. It used to be read from
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1
@@ -67,6 +73,15 @@ TAB=' '
MARKER_ON="autistmask-build-debug=on"
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"
# Set by the arguments.
@@ -283,6 +298,18 @@ check_entry() {
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."
# 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
read_marker "$ENTRY_PATH"
[ "$MARKER" = "$EXPECT" ] ||
+68 -2
View File
@@ -932,7 +932,9 @@ async function handleRpc(method, params, origin) {
}
}
return { error: { message: "Unsupported method: " + method } };
// EIP-1193 4200 lets a site tell "this wallet does not implement that"
// from "that call failed", and fall back.
return { error: { code: 4200, message: "Unsupported method: " + method } };
}
// The body of eth_sendTransaction, from the connection check through to the
@@ -1110,6 +1112,24 @@ async function broadcastAccountsChanged() {
}
}
// Tell every open tab of a site Settings removed that it has no account.
async function broadcastSiteRemoved(hostname) {
let tabs;
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) {
if (!tab.url || extractHostname(tab.url) !== hostname) continue;
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
}).catch(() => {});
}
}
// Background balance refresh: every 60 seconds when the popup isn't open.
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
// fresh, so this naturally skips.
@@ -1305,6 +1325,9 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
"AUTISTMASK_GET_APPROVAL",
"AUTISTMASK_TX_RESPONSE",
"AUTISTMASK_SIGN_RESPONSE",
"AUTISTMASK_ADDRESSES_REMOVED",
"AUTISTMASK_GET_CONNECTED_SITES",
"AUTISTMASK_REMOVE_SITE",
];
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
@@ -1344,6 +1367,15 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
const approval = pendingApprovals[msg.id];
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,
// not honoured: the attempt is on its way to broadcasting the
// transaction, and resolving 4001 here would tell the page the request
@@ -1638,8 +1670,42 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return false;
}
// The popup removed these addresses, so no site stays connected to them.
// A connectedSites key is origin + ":" + address, and an origin can carry
// a port, so the address is what follows the last colon.
if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") {
const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
for (const key of Object.keys(connectedSites)) {
const address = key.slice(key.lastIndexOf(":") + 1);
if (removed.some((a) => sameAddress(a, address))) {
delete connectedSites[key];
}
}
return false;
}
// Settings lists the sites connected without "Remember", which only this
// worker knows. The origin is what precedes the key's last colon.
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse(
Object.keys(connectedSites).map((key) =>
extractHostname(key.slice(0, key.lastIndexOf(":"))),
),
);
return false;
}
// Settings removed this site and has already dropped its remembered
// entries. Its connections approved without "Remember" end here, under
// every address, and its open tabs are told it has no account.
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
// Popup already saved state; nothing else needed
for (const key of Object.keys(connectedSites)) {
const origin = key.slice(0, key.lastIndexOf(":"));
if (extractHostname(origin) === msg.hostname) {
delete connectedSites[key];
}
}
broadcastSiteRemoved(msg.hostname);
return false;
}
});
-26
View File
@@ -5,8 +5,6 @@ const {
hasBrowserNamespace,
runtimeApi,
sendMessage,
storageGet,
storageSet,
} = require("../shared/browserApi");
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
@@ -21,30 +19,6 @@ if (hasBrowserNamespace()) {
(document.head || document.documentElement).appendChild(script);
}
// Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in extension storage.
(async function sendProviderUuid() {
let uuid = null;
try {
const items = await storageGet("eip6963Uuid");
uuid = items?.eip6963Uuid;
if (!uuid) {
uuid = crypto.randomUUID();
await storageSet({ eip6963Uuid: uuid });
}
} catch {
// Storage was unavailable or refused the write. The announcement
// still has to go out — a provider that never announces is invisible
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
// page load will not outlive.
if (!uuid) uuid = crypto.randomUUID();
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
})();
// Relay requests from the page to the background script
window.addEventListener("message", (event) => {
if (event.source !== window) return;
+15 -18
View File
@@ -31,11 +31,12 @@
// an error instead of accepting the refusal.
//
// Whatever code arrived is passed through verbatim rather than being
// matched against a list: the extension emits 4001, 4100 and 4902 today,
// and a code this file has never heard of is still the truth about what
// happened. An error reported with no code at all stays a plain Error —
// a ProviderRpcError whose `code` is undefined would advertise a
// conformance it does not have. `message` is untouched in every case.
// matched against a list: the extension emits codes such as 4001, 4100,
// 4200 and 4902, and a code this file has never heard of is still the
// truth about what happened. An error reported with no code at all stays
// a plain Error — a ProviderRpcError whose `code` is undefined would
// advertise a conformance it does not have. `message` is untouched in
// every case.
function toPageError(error) {
const message = (error && error.message) || "Request failed";
if (error && error.code !== undefined && error.code !== null) {
@@ -45,7 +46,7 @@
}
// Listen for responses from the content script
window.addEventListener("message", function onUuid(event) {
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
const { id, result, error } = event.data;
@@ -60,7 +61,7 @@
});
// Listen for events pushed from the extension
window.addEventListener("message", function onUuid(event) {
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data;
@@ -204,7 +205,13 @@
"</svg>",
);
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
// EIP-6963 asks for one UUIDv4 per provider for the life of the page: one
// per page load, shared by every announcement in that load. It is
// generated here and never stored: announcing one persisted value to every
// site, on every load and across restarts, turned it into a stable
// cross-site, cross-session tracking identifier any page could read
// (https://git.eeqj.de/sneak/AutistMask/issues/398).
const providerUuid = crypto.randomUUID();
function buildProviderInfo() {
return {
@@ -226,16 +233,6 @@
);
}
// Listen for the persisted UUID from the content script
function onProviderUuid(event) {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_PROVIDER_UUID") return;
window.removeEventListener("message", onProviderUuid);
providerUuid = event.data.uuid;
announceProvider();
}
window.addEventListener("message", onProviderUuid);
window.addEventListener("eip6963:requestProvider", announceProvider);
announceProvider();
+44 -29
View File
@@ -152,6 +152,21 @@
<!-- Shared password fields -->
<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>
<!-- The hint is swapped in place when the import tab
changes, and it sits directly above the password
@@ -213,10 +228,7 @@
</div>
<!-- active address display -->
<div
id="active-address-display"
class="text-xs break-all mb-3"
></div>
<div id="active-address-display" class="text-xs mb-3"></div>
<!-- quick actions for active address -->
<div class="flex gap-2 mb-2">
@@ -292,7 +304,7 @@
class="font-bold mb-1 hidden flex items-center"
></div>
<div
class="text-xs mb-1 cursor-pointer break-all"
class="text-xs mb-1 cursor-pointer"
title="Click to copy"
id="address-line"
>
@@ -380,14 +392,14 @@
></div>
<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-3">
<div class="text-xs mb-3">
<span id="export-privkey-dot"></span>
<span
id="export-privkey-address"
class="cursor-pointer"
title="Click to copy"
></span>
</p>
</div>
<p class="text-xs mb-3 text-muted">
Warning: anyone with this private key can access and
transfer all funds from this address. Never share it.
@@ -440,7 +452,7 @@
</div>
<div
class="text-xs mb-1 cursor-pointer break-all"
class="text-xs mb-1 cursor-pointer"
title="Click to copy"
id="address-token-line"
>
@@ -573,19 +585,16 @@
<!-- ERC-20 token contract (hidden for ETH) -->
<div id="confirm-token-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Token contract</div>
<div
id="confirm-token-contract"
class="text-xs break-all"
></div>
<div id="confirm-token-contract" class="text-xs"></div>
</div>
<div class="mb-3">
<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 class="mb-3">
<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
id="confirm-to-ens"
class="text-xs text-muted hidden"
@@ -728,7 +737,7 @@
</div>
<div class="mb-3">
<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 class="mb-3">
<div class="text-xs text-muted mb-1">Transaction hash</div>
@@ -747,7 +756,7 @@
</div>
<div class="mb-3">
<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 class="mb-3">
<div class="text-xs text-muted mb-1">Block</div>
@@ -774,7 +783,7 @@
</div>
<div class="mb-3">
<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 class="mb-3">
<div
@@ -811,9 +820,9 @@
<canvas id="receive-qr"></canvas>
</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>
</div>
<button
@@ -1055,6 +1064,15 @@
<div id="settings-allowed-sites"></div>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Connected Sites</h3>
<p class="text-xs text-muted mb-2">
Sites you allowed without "Remember my choice".
Switching address disconnects them.
</p>
<div id="settings-connected-sites"></div>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Denied Sites</h3>
<p class="text-xs text-muted mb-2">
@@ -1239,7 +1257,7 @@
</p>
<div
id="delete-address-value"
class="text-xs mb-2 break-all min-h-[1rem]"
class="text-xs mb-2 min-h-[1rem]"
></div>
<div
class="text-xs mb-2 border border-border border-dashed p-2"
@@ -1429,14 +1447,11 @@
</div>
<div class="mb-2">
<div class="text-xs text-muted mb-1">From</div>
<div
id="tx-detail-from"
class="text-xs break-all"
></div>
<div id="tx-detail-from" class="text-xs"></div>
</div>
<div class="mb-2">
<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>
@@ -1473,7 +1488,7 @@
</div>
<div
id="tx-detail-token-contract"
class="text-xs break-all"
class="text-xs"
></div>
</div>
</div>
@@ -1567,11 +1582,11 @@
<div class="mb-3">
<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 class="mb-3">
<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 class="mb-3">
<div class="text-xs text-muted mb-1">Value</div>
@@ -1673,7 +1688,7 @@
<div class="mb-3">
<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 class="mb-3">
+20
View File
@@ -44,3 +44,23 @@ body {
background-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";
}
// 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() {
clear();
switchMode("mnemonic");
updateSeparatePasswordNote();
showView("add-wallet");
}
+8 -6
View File
@@ -7,7 +7,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -229,10 +228,12 @@ function renderTransactions(txs) {
const amountStr = tx.value
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
// 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 or ENS name, where there is one, names
// it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
@@ -240,7 +241,8 @@ function renderTransactions(txs) {
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="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>`;
i++;
}
+8 -6
View File
@@ -10,7 +10,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
balanceLine,
unknownableAmount,
renderAddressHtml,
@@ -305,10 +304,12 @@ function renderTransactions(txs) {
const amountStr = tx.value
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
// 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 or ENS name, where there is one, names
// it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
@@ -316,7 +317,8 @@ function renderTransactions(txs) {
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="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>`;
i++;
}
+291 -45
View File
@@ -1,6 +1,7 @@
const {
$,
addressTitle,
CONTRACT_CREATION_TEXT,
escapeHtml,
showView,
showError,
@@ -14,15 +15,19 @@ const { networkByChainId } = require("../../shared/networks");
const {
formatEther,
formatUnits,
getAddress,
getBigInt,
getBytes,
Interface,
MaxUint256,
toUtf8String,
TypedDataEncoder,
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const {
resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("../../shared/approvalAmount");
// Four decimals, with the nonzero floor these screens hold: every amount this
@@ -63,9 +68,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) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// Try to decode calldata using known ABIs.
@@ -85,8 +96,7 @@ function decodeCalldata(data, toAddress) {
try {
const parsed = erc20Iface.parseTransaction({ data });
if (parsed) {
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null;
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
// null when no source knows this token's scale. It is not
// defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the
@@ -242,8 +252,11 @@ function showTxApproval(details) {
const approvedTx = details.approvedTx;
const toAddr = approvedTx.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(approvedTx.value || "0");
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Build txInfo for status screens
pendingTxDetails = {
@@ -251,14 +264,17 @@ function showTxApproval(details) {
to: toAddr || "",
amount: formatTxValue(ethValue),
token: "ETH",
tokenSymbol: token ? token.symbol : null,
tokenSymbol: null,
};
// If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(approvedTx.data, toAddr || "");
if (decoded && decoded.details) {
let decodedTokenAddr = null;
let decodedTokenSymbol = null;
// The asset the status summary is counted in: an ERC-20 call's Token
// 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) {
if (d.label === "Recipient" && d.address) {
pendingTxDetails.to = d.address;
@@ -266,20 +282,20 @@ function showTxApproval(details) {
if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value;
}
if (d.label === "Token In" && d.isToken && d.address) {
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
if (t) {
decodedTokenAddr = d.address;
decodedTokenSymbol = t.symbol;
}
if (
(d.label === "Token" || d.label === "Token In") &&
d.isToken &&
d.address
) {
assetAddr = d.address;
}
}
if (token) {
pendingTxDetails.token = toAddr;
pendingTxDetails.tokenSymbol = token.symbol;
} else if (decodedTokenAddr) {
pendingTxDetails.token = decodedTokenAddr;
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
if (assetAddr) {
pendingTxDetails.token = assetAddr;
pendingTxDetails.tokenSymbol = resolveTokenSymbol(
assetAddr,
sources,
);
}
}
@@ -305,7 +321,7 @@ function showTxApproval(details) {
toHtml += approvalAddressHtml(toAddr);
$("approve-tx-to").innerHTML = toHtml;
} else {
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
const ethValueFormatted = formatTxValue(
@@ -380,38 +396,245 @@ function decodeHexMessage(hex) {
}
}
function formatTypedDataHtml(jsonStr) {
// The type ethers will sign typed data as. ethers does not read the page's
// `primaryType`: it takes the one struct in `types` that no other struct
// refers to. Throws when the types name no such single struct, which ethers
// would refuse to sign as well.
function signedPrimaryType(types) {
const structs = { ...types };
// ethers derives EIP712Domain itself and rejects it as an input.
delete structs.EIP712Domain;
return TypedDataEncoder.getPrimaryType(structs);
}
// Why a signature request cannot be signed, as a sentence for the error line,
// or null when it can. Only typed data is refused: the `primaryType` the page
// states has to be the type ethers will sign, or this screen would name one
// message while another is signed.
function typedDataRefusal(sp) {
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
let data;
let signed;
try {
const data = JSON.parse(jsonStr);
let html = "";
data = JSON.parse(sp.typedData);
signed = signedPrimaryType(data.types);
} catch {
return "This typed data cannot be read, so it cannot be signed.";
}
if (!data.primaryType) {
return "This typed data does not name its primary type, so it cannot be signed.";
}
if (data.primaryType !== signed) {
return (
"This typed data names its primary type as " +
data.primaryType +
", but it would be signed as " +
signed +
", so it cannot be signed."
);
}
return null;
}
if (data.domain) {
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
for (const [key, val] of Object.entries(data.domain)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(String(val))}</div>`;
}
html += `</div>`;
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
// it as an allowance that is never used up.
const MAX_UINT160 = (1n << 160n) - 1n;
// One field of a struct as typed data signs it: the field's declared type and
// the struct's value for it, or null when the struct's type declares no field
// of that name. ethers signs only the fields a type declares and drops every
// other key, so a key the page adds beside them is never read here.
function declaredField(types, typeName, struct, name) {
const field = (types[typeName] || []).find((f) => f.name === name);
if (!field || !struct || typeof struct !== "object") return null;
return { type: field.type, value: struct[name] };
}
// The tokens and amounts a Permit2 message grants, from its `details` or
// `permitted` field: one struct of `token` and `amount`, or a list of them,
// as the field's declared type says. When the field cannot be read the one
// grant returned has no token or amount, so the warning still lists it.
function permit2Grants(types, primaryType, message, name, max) {
const field = declaredField(types, primaryType, message, name);
if (!field) return [{ max }];
// `PermitDetails` is one grant, `PermitDetails[]` a list of them.
const itemType = field.type.replace(/\[\d*\]$/, "");
const items = itemType === field.type ? [field.value] : field.value;
if (!Array.isArray(items)) return [{ max }];
return items.map((item) => ({
token: declaredField(types, itemType, item, "token")?.value,
amount: declaredField(types, itemType, item, "amount")?.value,
max,
}));
}
// The address or number a permission field holds, or null when it holds
// none; the warning then shows `Unknown` rather than failing.
function addressOrNull(value) {
try {
return getAddress(value);
} catch {
return null;
}
}
function amountOrNull(value) {
try {
return getBigInt(value);
} catch {
return null;
}
}
// A warning for typed data that lets a spender take your tokens, naming the
// spender and each token and amount, or "" for any other typed data. These
// signatures are how most wallet drains are done, and as plain key/value
// lines they read exactly like a sign-in message. They are recognised by the
// type ethers signs, `Permit` or one of Permit2's six signature types: a
// contract checks the type's exact name, so a renamed copy of one of these
// would not be honoured. Everything named is read only from the fields that
// type declares, except a `Permit`'s token, which is the domain's
// `verifyingContract`; whatever they do not give is shown as `Unknown`.
function permitWarningHtml(types, primaryType, domain, message) {
// Each entry is a token, the amount, and the largest value its amount
// field holds, which the contract treats as unlimited.
let grants;
switch (primaryType) {
case "Permit": {
// EIP-2612 declares a `value`. DAI's older permit, signed under
// the same name, declares only `allowed`: unlimited, or nothing.
// Others, such as the permit for a Uniswap v3 position, declare
// neither, and their amount is unknown.
const value = declaredField(types, primaryType, message, "value");
const allowed = declaredField(
types,
primaryType,
message,
"allowed",
);
let amount;
if (value) amount = value.value;
else if (allowed) amount = allowed.value ? MaxUint256 : 0n;
grants = [
{ token: domain?.verifyingContract, amount, max: MaxUint256 },
];
break;
}
case "PermitSingle":
case "PermitBatch":
grants = permit2Grants(
types,
primaryType,
message,
"details",
MAX_UINT160,
);
break;
case "PermitTransferFrom":
case "PermitWitnessTransferFrom":
case "PermitBatchTransferFrom":
case "PermitBatchWitnessTransferFrom":
grants = permit2Grants(
types,
primaryType,
message,
"permitted",
MaxUint256,
);
break;
default:
return "";
}
if (data.primaryType) {
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
html += `<div class="font-bold">${escapeHtml(data.primaryType)}</div></div>`;
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
const spender = addressOrNull(
declaredField(types, primaryType, message, "spender")?.value,
);
let html = `<div class="mb-2 p-2 font-bold bg-red-100 text-red-800 border-2 border-red-600 rounded-md">`;
html += `<div class="mb-2">⚠️ TOKEN PERMISSION: Signing this lets the spender below take the tokens listed here from your address, without asking you again.</div>`;
html += `<div class="mb-2"><div>Spender</div>`;
html += spender ? approvalAddressHtml(spender) : `<div>Unknown</div>`;
html += `</div>`;
for (const grant of grants) {
const token = addressOrNull(grant.token);
const amount = amountOrNull(grant.amount);
// `Unlimited` as on the ERC-20 approve line; otherwise the quantity,
// or base units when nothing knows the token's scale.
let amountText = "Unknown";
if (amount === grant.max) {
amountText = "Unlimited";
} else if (amount !== null && token === null) {
amountText = unknownDecimalsAmount(amount);
} else if (amount !== null) {
amountText = tokenAmountText(
amount,
resolveTokenDecimals(token, sources),
tokenLabel(token),
).display;
}
if (data.message) {
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
for (const [key, val] of Object.entries(data.message)) {
const display =
typeof val === "object" ? JSON.stringify(val) : String(val);
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display)}</span></div>`;
}
html += `</div>`;
html += `<div class="mb-2"><div>Token</div>`;
if (token) {
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
html += approvalAddressHtml(token);
} else {
html += `<div>Unknown</div>`;
}
html += `</div>`;
html += `<div class="mb-2"><div>Amount</div><div>${escapeHtml(amountText)}</div></div>`;
}
html += `</div>`;
return html;
}
return html;
// The typed data as the screen shows it. The primary type shown is the one
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two
// from differing on anything that can be signed. Only typed data that cannot
// be read at all is shown as raw text, and typedDataRefusal() refuses it.
function formatTypedDataHtml(jsonStr) {
let data;
let primaryType;
try {
data = JSON.parse(jsonStr);
primaryType = signedPrimaryType(data.types);
} catch {
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
}
let html = permitWarningHtml(
data.types,
primaryType,
data.domain,
data.message,
);
// A value that is an object is shown as JSON: String() of it says
// nothing, and throws for some objects a page can send.
const display = (val) =>
typeof val === "object" ? JSON.stringify(val) : String(val);
if (data.domain) {
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
for (const [key, val] of Object.entries(data.domain)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(display(val))}</div>`;
}
html += `</div>`;
}
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
if (data.message) {
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
for (const [key, val] of Object.entries(data.message)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
}
html += `</div>`;
}
return html;
}
function showSignApproval(details) {
@@ -466,6 +689,13 @@ function showSignApproval(details) {
showView("approve-sign");
attachCopyHandlers("view-approve-sign");
const refusal = typedDataRefusal(sp);
if (refusal) {
showError("approve-sign-error", refusal);
$("btn-approve-sign").disabled = true;
$("btn-approve-sign").classList.add("text-muted");
return;
}
gateOnWalletDefect(
"approve-sign-error",
"btn-approve-sign",
@@ -771,6 +1001,16 @@ function init(_ctx) {
return;
}
// Checked again where the signing starts, not only when the screen
// was drawn, and the button stays disabled: this request can never be
// signed.
const refusal = typedDataRefusal(pendingSignParams);
if (refusal) {
password = null;
showError("approve-sign-error", refusal);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signature does.
let decryptedSecret;
@@ -862,4 +1102,10 @@ function init(_ctx) {
});
}
module.exports = { init, show, decodeCalldata };
module.exports = {
init,
show,
decodeCalldata,
formatTypedDataHtml,
typedDataRefusal,
};
+51 -24
View File
@@ -30,6 +30,7 @@ const {
displayedDecimals,
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
const {
CODES,
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) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
@@ -467,29 +508,7 @@ function init(_ctx) {
const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider);
if (pendingTx.token === "ETH") {
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);
}
tx = await populateVerifyAndSend(connectedSigner, pendingTx);
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
@@ -498,6 +517,14 @@ function init(_ctx) {
txStatus.showWait(pendingTx, tx.hash);
} catch (e) {
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;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
} 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 —
// but it is wiped on leave for the neighbouring reason: a typed
// confirmation left standing in a hidden view is one click away from
// destroying a wallet the user has since navigated off. The button is
// re-enabled here too, so a screen left mid-delete is usable on re-entry.
// destroying a wallet the user has since navigated off.
function clearLostPassword() {
lostPasswordIndex = null;
$("delete-wallet-lost-name-input").value = "";
$("delete-wallet-lost-flash").textContent = "";
$("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) {
@@ -98,6 +94,17 @@ function showLostPassword() {
// cleanup and the accountsChanged broadcast cannot drift apart between
// them.
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);
deleteWalletIndex = null;
@@ -187,8 +194,8 @@ function init(_ctx) {
btn.disabled = true;
btn.classList.add("text-muted");
// finishDelete() navigates, and the leave hook re-enables the
// button and wipes the typed name on the way out.
// finishDelete() re-enables the button; navigating away then runs
// the leave hook that wipes the typed name.
await finishDelete(lostPasswordIndex);
});
+46 -16
View File
@@ -12,6 +12,7 @@
// 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
// it from here.
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
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;";
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) {
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
} else if (net.isTestnet) {
@@ -331,6 +336,12 @@ function addressHoldsFunds(addr) {
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 "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in
@@ -393,6 +404,12 @@ function addressTitle(address, wallets) {
return null;
}
// What every recipient line says for a transaction with no `to`. Such a
// transaction creates a contract, so there is no address to show, and a blank
// line on these screens reads as a rendering fault.
const CONTRACT_CREATION_TEXT =
"This transaction creates a new contract. It has no recipient.";
// Render an address with color dot, optional ENS name, optional title,
// and optional truncation. Title and ENS are shown as bold labels above
// the full address.
@@ -518,17 +535,29 @@ function attachCopyHandlers(container) {
// Unified address rendering.
//
// Produces consistent HTML for any Ethereum address:
// • Color dot
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
// • Optional ENS name shown bold above address
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
// • Etherscan external link icon
// Two stacked rows, in this order:
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 —
// Address 2") and the explorer link icon. Optional ENS name below it.
// 2. The address itself, alone on a full-width row that never wraps
// (see .am-address in styles/main.css).
//
// 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:
// title — wallet title string (from addressTitle)
// 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
//
// After inserting the returned HTML into the DOM, call
@@ -536,22 +565,22 @@ function attachCopyHandlers(container) {
function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {};
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 extLink = noLink ? "" : etherscanLinkHtml(link);
let html = "";
html += `<div class="flex items-center">${dot}`;
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) {
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
}
if (title || ensName) {
html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
} else {
html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
html += `<div class="font-bold">${escapeHtml(ensName)}</div>`;
}
html += `<div class="am-address">${copyableHtml(displayAddr)}</div>`;
return html;
}
@@ -592,6 +621,7 @@ module.exports = {
escapeHtml,
displaySymbol,
addressTitle,
CONTRACT_CREATION_TEXT,
formatAddressHtml,
renderAddressHtml,
copyableHtml,
+19 -11
View File
@@ -9,7 +9,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
pushCurrentView,
@@ -117,10 +116,13 @@ function renderHomeTxList(ctx) {
const amountStr = tx.value
? escapeHtml(tx.value + " " + 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 maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const titleStr = title ? escapeHtml(title) : "";
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
@@ -128,7 +130,8 @@ function renderHomeTxList(ctx) {
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="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>`;
i++;
}
@@ -252,17 +255,22 @@ function walletListHtml() {
: "";
const dot = addressDotHtml(addr.address);
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) {
// An ENS reverse record is whatever the name owner set it
// to; renderAddressHtml() escapes its own copy of this and
// 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 += `<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>`;
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress(
+51 -23
View File
@@ -29,46 +29,63 @@ const {
GITEA_COMMIT_URL,
} = require("../../shared/buildInfo");
const { notify } = require("../../shared/browserApi");
const { notify, sendMessage } = require("../../shared/browserApi");
let versionClickCount = 0;
let versionClickTimer = null;
function renderSiteList(containerId, siteMap, stateKey) {
// One row per hostname, however many addresses or origins it appears under,
// each with an [x] that hands it to onRemove.
function renderSiteList(containerId, hostnames, onRemove) {
const container = $(containerId);
const hostnames = [...new Set(Object.values(siteMap).flat())];
if (hostnames.length === 0) {
const unique = [...new Set(hostnames)];
if (unique.length === 0) {
container.innerHTML = '<p class="text-xs text-muted">None</p>';
return;
}
let html = "";
hostnames.forEach((hostname) => {
unique.forEach((hostname) => {
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
// A hostname the URL parser produced cannot carry a delimiter, so
// this is escaped for the rule rather than for a known hole — the
// rule being that nothing reaches innerHTML unescaped.
html += `<span>${escapeHtml(hostname)}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${escapeHtml(stateKey)}" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
btn.addEventListener("click", async () => {
const key = btn.dataset.key;
const host = btn.dataset.hostname;
for (const addr of Object.keys(state[key])) {
state[key][addr] = state[key][addr].filter((h) => h !== host);
if (state[key][addr].length === 0) {
delete state[key][addr];
}
}
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE" });
renderSiteList(containerId, state[key], key);
});
btn.addEventListener("click", () => onRemove(btn.dataset.hostname));
});
}
// Drop a hostname from a remembered site list under every address.
function forgetHostname(siteMap, hostname) {
for (const addr of Object.keys(siteMap)) {
siteMap[addr] = siteMap[addr].filter((h) => h !== hostname);
if (siteMap[addr].length === 0) {
delete siteMap[addr];
}
}
}
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
// no longer allowed under any address, and the background ends its
// connections approved without "Remember" and tells its open tabs.
async function removeAllowedSite(hostname) {
forgetHostname(state.allowedSites, hostname);
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", hostname });
await renderSiteLists();
}
// Removing a denied site only forgets the refusal; it connects nothing.
async function removeDeniedSite(hostname) {
forgetHostname(state.deniedSites, hostname);
await saveState();
await renderSiteLists();
}
function renderTrackedTokens() {
const container = $("settings-tracked-tokens");
if (state.trackedTokens.length === 0) {
@@ -202,13 +219,24 @@ function show() {
showView("settings");
}
function renderSiteLists() {
async function renderSiteLists() {
renderSiteList(
"settings-allowed-sites",
state.allowedSites,
"allowedSites",
Object.values(state.allowedSites).flat(),
removeAllowedSite,
);
renderSiteList(
"settings-denied-sites",
Object.values(state.deniedSites).flat(),
removeDeniedSite,
);
// Sites allowed without "Remember" are held only by the background, in
// memory, so it is asked for them.
renderSiteList(
"settings-connected-sites",
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
removeAllowedSite,
);
renderSiteList("settings-denied-sites", state.deniedSites, "deniedSites");
}
function init(ctx) {
+15 -3
View File
@@ -7,6 +7,7 @@ const {
showFlash,
flashCopyFeedback,
addressTitle,
CONTRACT_CREATION_TEXT,
addressDotHtml,
escapeHtml,
isoDate,
@@ -94,13 +95,18 @@ function render() {
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
const fromTitle = addressTitle(tx.from, state.wallets);
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml(
tx.from,
tx.fromEns,
fromTitle,
);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
// A contract creation has no recipient: transactions.js gives it `to: ""`.
if (tx.to) {
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
} else {
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
// Exact amount (full precision, copyable)
const detailSym = displaySymbol(tx.symbol);
@@ -137,10 +143,16 @@ function render() {
if (tx.contractAddress) {
const dot = addressDotHtml(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 =
`<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") +
etherscanLinkHtml(link) +
`</div>` +
`<div class="am-address">` +
copyableHtml(tx.contractAddress) +
`</div>`;
tokenContractSection.classList.remove("hidden");
} else {
+16 -5
View File
@@ -4,6 +4,7 @@ const {
$,
showView,
addressTitle,
CONTRACT_CREATION_TEXT,
escapeHtml,
renderAddressHtml,
attachCopyHandlers,
@@ -13,7 +14,7 @@ const {
displaySymbol,
clearViewStack,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { state } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -58,7 +59,10 @@ function endWait() {
}
}
// A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`).
function toAddressHtml(address) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title });
}
@@ -202,8 +206,9 @@ function restoreWait() {
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
// A string is the whole requirement: the empty string is what a
// contract-deployment approval persists (approval.js writes `to: toAddr
// || ""`), and both fields render harmlessly when empty, so refusing it
// would abandon a wait the live path itself created.
// || ""`), an empty `to` renders as a contract creation and an empty
// amount renders harmlessly, so refusing it would abandon a wait the live
// path itself created.
if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
@@ -232,9 +237,15 @@ function showSuccess(txInfo, txHash, blockNumber) {
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) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
function decodedDetailsHtml(decoded) {
+55
View File
@@ -30,6 +30,7 @@
// enumerated rather than coerced.
const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof");
// Every decimals the explorer reported for this contract, across all the
// 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);
}
// 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
// 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
@@ -84,5 +138,6 @@ function unknownDecimalsAmount(rawAmount) {
module.exports = {
resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount,
};
+35 -2
View File
@@ -51,6 +51,7 @@
const {
Transaction,
accessListify,
formatEther,
getAddress,
getBytes,
verifyMessage,
@@ -134,10 +135,19 @@ const FORBIDDEN_FIELDS = [
const MAX_GAS_LIMIT = 100000000n;
// 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
// hand the validator the balance.
// supported network has produced.
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
// 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
@@ -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.
@@ -775,4 +807,5 @@ module.exports = {
TX_STAGE_NONCE,
MAX_GAS_LIMIT,
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=off";
const DEBUG_MNEMONIC =
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
// Behind DEBUG for the same reason BUILD_DEBUG_MARKER is above: in a release
// 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_SEPOLIA_CHAIN_ID = "0xaa36a7";
+2 -3
View File
@@ -2,10 +2,10 @@
// swap details. Designed to be extended with other DEX decoders later.
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { truncateAmountNeverZero } = require("./amountDisplay");
const {
resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("./approvalAmount");
@@ -123,9 +123,8 @@ function tokenInfo(address, sources) {
if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: "ETH", decimals: 18, address: null };
}
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return {
symbol: t ? t.symbol : null,
symbol: resolveTokenSymbol(address, sources),
decimals: resolveTokenDecimals(address, sources),
address,
};
+4 -1
View File
@@ -12,12 +12,15 @@ function sameAddress(a, b) {
return String(a).toLowerCase() === String(b).toLowerCase();
}
// Forget every site permission held against the given addresses.
// Forget every site permission held against the given addresses: the
// remembered ones in `state`, and the connections approved without
// "Remember", which only the background holds, in memory.
function dropSitePermissions(state, addresses) {
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
notify({ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses });
}
// Remove wallet `walletIdx` from `state` and repair the derived state.
+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 = [
{ 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(
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", () => {
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
expect(
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", () => {
@@ -172,7 +174,7 @@ describe("decodeCalldata amount", () => {
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
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/);
});
// 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
// came from, in a sentence.
test("reports a failed estimate as a full sentence", async () => {
+114
View File
@@ -28,6 +28,7 @@ const {
TX_STAGE_NONCE,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify");
const { prepareApprovalTx } = require("../src/shared/approvalTx");
const { getSignerForAddress } = require("../src/shared/wallet");
@@ -475,18 +476,131 @@ describe("verifySignedTx field comparison", () => {
assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }),
).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(() =>
assertWithinCeilings({
gasLimit: MAX_GAS_LIMIT,
maxFeePerGas: 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();
// 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.
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 () => {
const raw = await signedWith({ nonce: 8 });
try {
+404
View File
@@ -25,6 +25,10 @@ const { Network, Wallet } = require("ethers");
// what the user is actually shown.
const { describeSigningFailure } = require("../src/shared/approvalVerify");
const { makeStorageStub } = require("./support/storageStub");
const {
removeAddressFromState,
removeWalletFromState,
} = require("../src/shared/walletDelete");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
@@ -1541,6 +1545,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
// that settles the page's window.ethereum.request() promise, so a throw that
// escapes a handler leaves that promise pending forever — no error, no
@@ -1953,3 +2100,260 @@ describe("a site connection decided as the popup closes", () => {
});
});
});
// What a site is told when it asks which account it may use.
async function siteAccounts(bg, origin) {
const { sendResponse } = bg.send(
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
{ origin: origin || FRESH_ORIGIN },
);
await settle();
return sendResponse.mock.calls[0][0];
}
// A site connected without "Remember" is held only in the background's memory,
// keyed to the address it was connected to. Removing that address, or the
// wallet holding it, must end the connection as part of the removal itself.
// The accountsChanged broadcast the views send afterwards also clears it, but
// only when the active address moved, and nothing waits for it to arrive.
//
// Each test asks the background while storage still names the removed address
// as active, because the popup has not saved yet, so the answer turns on the
// connection alone.
describe("removing an address ends a site's connection to it", () => {
// FRESH_ORIGIN connected to the active address without "Remember", in a
// wallet holding a second address so that one can be removed at all. The
// popup's messages reach the background as they would from the popup.
async function connectedBackground() {
const bg = loadBackground({ actionPopup: true });
const stored = bg.storage.read("autistmask");
stored.wallets[0].addresses.push({
address: other.address,
balance: "0",
tokenBalances: [],
});
bg.storage.write("autistmask", stored);
const pending = bg.requestSite();
await settle();
bg.connectApproval(pending.id()).decide(true, false);
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, bg.fromPopup);
};
return bg;
}
test("removing the connected address ends the connection", async () => {
const bg = await connectedBackground();
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
const popupState = bg.storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true);
expect(await siteAccounts(bg)).toEqual({ result: [] });
});
test("deleting the wallet holding the connected address ends the connection", async () => {
const bg = await connectedBackground();
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
const popupState = bg.storage.read("autistmask");
removeWalletFromState(popupState, 0);
expect(await siteAccounts(bg)).toEqual({ result: [] });
});
test("removing a different address leaves the connection alone", async () => {
const bg = await connectedBackground();
const popupState = bg.storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true);
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
});
test("a page cannot end the connection", async () => {
const bg = await connectedBackground();
const spoof = bg.send(
{
type: "AUTISTMASK_ADDRESSES_REMOVED",
addresses: [signer.address],
},
{ url: FRESH_ORIGIN + "/index.html" },
);
expect(spoof.sendResponse).toHaveBeenCalledWith({
error: "Unauthorized sender",
});
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
});
});
// Settings lists the sites allowed without "Remember", which only the
// background holds, and removing a site there, from either list, disconnects
// it. These drive the real Settings view against the real background and
// click the [x] the user clicks.
describe("removing a site in Settings disconnects it", () => {
// FRESH_ORIGIN on another port, so its hostname is FRESH_ORIGIN's.
const FRESH_OTHER_PORT = "https://fresh.example:8443";
// A site list's container. Its [x] buttons, data attributes and all, are
// read back out of the rows the view wrote into it, so clicking one runs
// the handler the view attached to it.
function fakeSiteList() {
const list = {
innerHTML: "",
buttons: [],
querySelectorAll() {
const tags = list.innerHTML.match(/<button[^>]*>/g) || [];
list.buttons = tags.map((tag) => ({
dataset: Object.fromEntries(
[...tag.matchAll(/data-(\w+)="([^"]*)"/g)].map(
(match) => [match[1], match[2]],
),
),
addEventListener(event, handler) {
this[event] = handler;
},
}));
return list.buttons;
},
};
return list;
}
// The hostnames a site list shows.
function listed(list) {
return [...list.innerHTML.matchAll(/data-hostname="([^"]*)"/g)].map(
(match) => match[1],
);
}
// A site connected the way the user does it, in the approval popup.
async function connect(bg, origin, remember) {
const pending = bg.requestSite(origin);
await settle();
bg.connectApproval(pending.id()).decide(true, remember);
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
}
// Settings, opened over the background's storage and wired to it the way
// the popup is: what Settings sends reaches the background from the
// extension's own page, and the answer comes back.
async function openSettings(bg) {
const lists = {};
const element = (id) => (lists[id] ||= fakeSiteList());
global.document = { getElementById: element };
global.chrome.runtime.sendMessage = (msg, callback) => {
const { sendResponse } = bg.send(msg, bg.fromPopup);
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
};
await require("../src/shared/state").loadState();
await require("../src/popup/views/settings").renderSiteLists();
return {
allowed: element("settings-allowed-sites"),
connected: element("settings-connected-sites"),
// Click the [x] beside a site, and let what it sends run.
remove: async (list, hostname) => {
expect(listed(list)).toContain(hostname);
const button = list.buttons.find(
(b) => b.dataset.hostname === hostname,
);
await button.click();
await settle();
},
};
}
afterEach(() => {
delete global.document;
});
test("Settings lists a site connected without Remember", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
const settings = await openSettings(bg);
expect(listed(settings.connected)).toEqual(["fresh.example"]);
expect(listed(settings.allowed)).toEqual([HOSTNAME]);
});
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
const sentToTabs = [];
global.chrome.tabs = {
query: (q, cb) =>
cb([
{ id: 1, url: FRESH_ORIGIN + "/app" },
{ id: 2, url: ORIGIN + "/app" },
]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push({ tabId, msg });
cb();
},
};
const settings = await openSettings(bg);
await settings.remove(settings.connected, "fresh.example");
expect(await siteAccounts(bg)).toEqual({ result: [] });
expect(sentToTabs).toEqual([
{
tabId: 1,
msg: {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
},
]);
expect(listed(settings.connected)).toEqual([]);
// The other site is untouched.
expect(await siteAccounts(bg, ORIGIN)).toEqual({
result: [signer.address],
});
});
// The same hostname can hold both kinds of connection: one origin allowed
// without Remember, then another, on a different port, allowed with it.
test("removing a remembered site also ends its connection made without Remember", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
await connect(bg, FRESH_OTHER_PORT, true);
const settings = await openSettings(bg);
await settings.remove(settings.allowed, "fresh.example");
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
result: [],
});
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
});
test("a page can neither remove a site nor list the connected ones", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
const page = { url: FRESH_ORIGIN + "/index.html" };
const remove = bg.send(
{ type: "AUTISTMASK_REMOVE_SITE", hostname: "fresh.example" },
page,
);
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
expect(remove.sendResponse).toHaveBeenCalledWith({
error: "Unauthorized sender",
});
expect(list.sendResponse).toHaveBeenCalledWith({
error: "Unauthorized sender",
});
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
});
});
+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);
});
});
+252
View File
@@ -0,0 +1,252 @@
// The recipient line of a contract creation
// (https://git.eeqj.de/sneak/AutistMask/issues/250).
//
// A transaction with no `to` creates a contract. The approval screen, the
// wait, success and error screens, and the transaction detail view each say
// so in a sentence on the recipient line, where they used to show a blank
// line (an empty address, with a colour dot whose colour was `undefined`) or,
// on the approval screen, "(contract creation)". A transaction with a real
// `to` still shows that address.
//
// Driven against a minimal DOM stub in the shape
// tests/typedDataPermit.test.js uses.
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The transaction detail view fetches on-chain details after drawing; an
// answer that is not ok leaves the drawn lines as they are.
debugFetch: async () => ({ ok: false }),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The wait screen polls for a receipt; this one never arrives.
jest.mock("../src/shared/balances", () => ({
getProvider: () => ({ getTransactionReceipt: () => new Promise(() => {}) }),
refreshBalances: () => {},
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
const transactionDetail = require("../src/popup/views/transactionDetail");
const SENTENCE =
"This transaction creates a new contract. It has no recipient.";
const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TX_HASH =
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
// Init code for a contract creation's data.
const INIT_CODE = "0x600160005500";
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: () => {},
querySelectorAll: () => [],
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// The line a transaction with a real `to` shows: that address, and nothing
// left over from an empty one.
function expectAddressLine(html) {
expect(html).toContain(RECIPIENT);
expect(html).not.toContain(SENTENCE);
expect(html).not.toContain("undefined");
}
beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.wallets = [];
state.trackedTokens = [];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
txStatus.init({ doRefreshAndRender: () => {} });
});
afterEach(() => {
txStatus.endWait();
});
// Open the transaction approval screen the way the popup does: the background
// hands over the populated transaction and show() draws it.
async function openTxApproval(to, data) {
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
reply({
type: "tx",
hostname: "dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to,
value: "0x0",
data,
accessList: [],
},
});
},
};
approval.init({});
await approval.show(1);
}
describe("the transaction approval screen", () => {
test("a contract creation says so instead of naming a contract", async () => {
await openTxApproval(null, INIT_CODE);
expect(node("approve-tx-to").innerHTML).toBe(SENTENCE);
});
test("a transaction with a recipient shows its address", async () => {
await openTxApproval(RECIPIENT, "0x");
expectAddressLine(node("approve-tx-to").innerHTML);
});
});
// approval.js carries a contract creation to these screens with `to` as "".
describe("the wait, success and error screens", () => {
const creation = {
to: "",
amount: "0.0000",
token: "ETH",
tokenSymbol: null,
};
const transfer = { ...creation, to: RECIPIENT };
test("a contract creation says so on the wait screen", () => {
txStatus.showWait(creation, TX_HASH);
expect(node("wait-tx-to").innerHTML).toBe(SENTENCE);
});
test("a transaction with a recipient shows its address on the wait screen", () => {
txStatus.showWait(transfer, TX_HASH);
expectAddressLine(node("wait-tx-to").innerHTML);
});
test("a contract creation says so on the success and error screens", () => {
state.viewData = {
amount: "0.0000",
symbol: "ETH",
to: "",
hash: TX_HASH,
blockNumber: 1,
};
txStatus.renderSuccess();
expect(node("success-tx-to").innerHTML).toBe(SENTENCE);
txStatus.showError(creation, TX_HASH, "The transaction failed.");
expect(node("error-tx-to").innerHTML).toBe(SENTENCE);
});
test("a transaction with a recipient shows its address on the success and error screens", () => {
state.viewData = {
amount: "0.0050",
symbol: "ETH",
to: RECIPIENT,
hash: TX_HASH,
blockNumber: 1,
};
txStatus.renderSuccess();
expectAddressLine(node("success-tx-to").innerHTML);
txStatus.showError(transfer, TX_HASH, "The transaction failed.");
expectAddressLine(node("error-tx-to").innerHTML);
});
});
// The history list gives a contract creation `to: ""` (src/shared/
// transactions.js), and that is what the detail view is opened with.
describe("the transaction detail view", () => {
function historyTx(to) {
return {
hash: TX_HASH,
from: FROM,
to,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
};
}
test("a contract creation says so", () => {
transactionDetail.show(historyTx(""));
expect(node("tx-detail-to").innerHTML).toBe(SENTENCE);
expect(node("tx-detail-type").textContent).toBe("Contract Creation");
});
test("a transaction with a recipient shows its address", () => {
transactionDetail.show(historyTx(RECIPIENT));
expectAddressLine(node("tx-detail-to").innerHTML);
});
});
+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]");
});
});
+72 -7
View File
@@ -172,6 +172,15 @@ async function openLostPassword(deleteWallet, walletIdx) {
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
// The stub is what every persistence assertion below rests on, so its one
@@ -398,7 +407,9 @@ describe("deleting without the password", () => {
expect(saved.activeAddress).toBe(A0);
expect(saved.selectedWallet).toBe(0);
expect(saved.selectedAddress).toBe(0);
expect(sent).toEqual([]);
expect(sent).toEqual([
{ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses: [B0] },
]);
// Settings is stubbed, so this is where the route hands over, not
// where it renders.
expect(mockSettingsShow).toHaveBeenCalled();
@@ -417,7 +428,10 @@ describe("deleting without the password", () => {
"Wallet 3",
]);
expect(saved.activeAddress).toBe(B0);
expect(sent).toEqual([{ type: "AUTISTMASK_ACTIVE_CHANGED" }]);
expect(sent).toEqual([
{ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses: [A0, A1] },
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
]);
});
test("deleting the last wallet lands on Welcome with nothing left", async () => {
@@ -456,15 +470,21 @@ describe("what the screen leaves behind", () => {
);
});
// Left mid-delete, the screen has to come back usable.
test("the confirm button is re-enabled on the way out", async () => {
const { helpers, deleteWallet } = load();
// Both routes now re-enable through finishDelete(), not their leave
// hooks, so the button comes back live once a delete completes.
test("the confirm button is re-enabled after a delete", async () => {
const { deleteWallet } = load();
await openLostPassword(deleteWallet, 1);
node("btn-delete-wallet-lost-confirm").disabled = true;
helpers.showView("settings");
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
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
@@ -475,3 +495,48 @@ describe("what the screen leaves behind", () => {
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",
]);
});
});
+5 -5
View File
@@ -372,10 +372,10 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
STEP_TIMEOUT_MS,
);
// EIP-6963, asked of the provider itself. The announcement carries the
// uuid src/content/index.js reads out of extension storage — call site 1
// in the issue — and it has to name this extension and hand back the very
// object on window.ethereum.
// EIP-6963, asked of the provider itself. The announcement carries a
// UUIDv4 inpage.js generates fresh for this page load (nothing persists
// it — see issue #398) and has to name this extension and hand back the
// very object on window.ethereum.
const announced = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const onAnnounce = (e) => {
@@ -402,7 +402,7 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
);
assert(
typeof announced.uuid === "string" && announced.uuid.length === 36,
"the announcement carries no stored provider uuid: " +
"the announcement carries no provider uuid: " +
JSON.stringify(announced.uuid),
);
+203
View File
@@ -1525,6 +1525,7 @@ async function goToConfirm(page, { token, balance, amount }) {
await page.fill("#send-amount", amount);
await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx");
await assertAddressesFit(page, "the confirmation screen");
}
// 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),
);
await assertAddressesFit(popup, "the dApp transaction prompt");
const broadcastBefore = env.routeOpts.broadcastTransactions.length;
await popup.fill("#approve-tx-password", PASSWORD);
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();
});
// ------------------------------------------- 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
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
// 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
// with under this extension's id.
// ever changes, because a committed private key is one anyone can sign a
// 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", () => {
const root = path.join(__dirname, "..");
const tracked = require("child_process")
.execSync("git ls-files", {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
})
.execSync("git ls-files", { cwd: root, encoding: "utf8" })
.split("\n")
.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 () => {
await fetchOnto([row({ decimals: "6" })]);
// The same explorer entry now also names the token (issue #323).
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" })]);
expect(
swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
).toBe("1000.0000");
).toBe("1000.0000 NOVEL");
});
});
+62 -5
View File
@@ -49,11 +49,12 @@ class StubCustomEvent extends StubEvent {
}
}
// Every code the background emits on the RPC path today, read out of
// src/background/index.js. The provider must not know this list — it passes
// through whatever arrived — but the cases below are the real ones.
// Examples of codes the background emits on the RPC path, read out of
// src/background/index.js. The provider must not know any list of codes — it
// passes through whatever arrived — but the cases below are real ones.
const REJECTED = 4001; // user rejected the request
const UNAUTHORIZED = 4100; // site not connected / wrong address
const UNSUPPORTED_METHOD = 4200; // a method the wallet does not implement
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain
// A stub window with the four things inpage.js touches: message listeners,
@@ -115,6 +116,41 @@ async function rejectionFrom(start, response) {
return outcome.error;
}
// The reply the real background worker (src/background/index.js) sends for
// `method`, loaded against just enough of the extension API to receive one
// RPC message. Same shape as tests/coldWorkerChainId.test.js.
function backgroundReply(method) {
jest.resetModules();
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: async () => {},
registerAlarmHandlers: () => {},
}));
let messageListener = null;
global.chrome = {
runtime: {
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
},
};
require("../src/background/index");
return new Promise((resolve) => {
messageListener(
{ type: "AUTISTMASK_RPC", method, params: [] },
{ origin: "https://dapp.example" },
resolve,
);
});
}
describe("an EIP-1193 code reaches the page", () => {
test("a user rejection arrives as code 4001", async () => {
const err = await rejectionFrom(
@@ -164,8 +200,9 @@ describe("an EIP-1193 code reaches the page", () => {
expect(err.message).toBe(message);
});
// The provider is not allowed to know the list above: a code added to the
// background later must reach the page without this file being edited.
// The provider is not allowed to know the codes above: any other code,
// including one added to the background later, must reach the page
// without inpage.js being edited.
test("a code the provider has never heard of is passed through", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_accounts" }),
@@ -203,6 +240,26 @@ describe("an EIP-1193 code reaches the page", () => {
});
});
// The reply here is the background's own, not one written in this file: it
// used to carry no code for a method the wallet does not implement
// (https://git.eeqj.de/sneak/AutistMask/issues/279), so a site probing for an
// optional method could not tell "not implemented" from "the call failed".
describe("a method the wallet does not implement", () => {
afterEach(() => {
delete global.chrome;
});
test("reaches the page as code 4200", async () => {
const method = "wallet_noSuchMethod";
const err = await rejectionFrom(
(p) => p.request({ method }),
await backgroundReply(method),
);
expect(err.code).toBe(UNSUPPORTED_METHOD);
expect(err.message).toBe("Unsupported method: " + method);
});
});
describe("the message is untouched", () => {
test("a coded error keeps the message byte for byte", async () => {
const message =
+118
View File
@@ -0,0 +1,118 @@
// The EIP-6963 provider UUID inpage.js announces (src/content/inpage.js).
//
// The bug this pins down (issue #398): the UUID used to be generated once,
// persisted in extension storage, and announced verbatim to every page on
// every load and across browser restarts, so any site — connected or not —
// could read a stable cross-site, cross-session identifier for the install.
// EIP-6963 asks for one UUIDv4 per page load, shared by every announcement in
// that load. The fix generates it per page load and stores nothing.
//
// The stored UUID reached inpage.js as an AUTISTMASK_PROVIDER_UUID page
// message from the content script, and inpage.js then announced it. Each load
// below is posted the same stored UUID that way, so on the old code both loads
// announce it and the last two tests fail.
//
// inpage.js is a bare IIFE injected into the page's JS context, not a module;
// see tests/inpageErrors.test.js for why it is evaluated against a stub window
// rather than imported. Here the stub captures the CustomEvent that carries
// the announcement, so the UUID this file reads is the one a real dApp's
// eip6963:announceProvider listener would see.
const fs = require("fs");
const path = require("path");
const { webcrypto } = require("crypto");
const SOURCE = fs.readFileSync(
path.join(__dirname, "..", "src", "content", "inpage.js"),
"utf8",
);
const loadInto = new Function(
"window",
"self",
"crypto",
"Event",
"CustomEvent",
SOURCE,
);
class StubEvent {
constructor(type) {
this.type = type;
}
}
class StubCustomEvent extends StubEvent {
constructor(type, init) {
super(type);
this.detail = init && init.detail;
}
}
// What the old content script read out of extension storage and posted to
// every page load.
const STORED_UUID = "11111111-2222-4333-8444-555555555555";
// Evaluate inpage.js once against a fresh stub window, post it STORED_UUID the
// way the old content script did, then dispatch a `requestProvider` event so a
// re-announcement is observed as well as the announcement at load. Returns
// every UUID the load announced, in order.
function announcedUuids() {
const listeners = {};
const uuids = [];
const win = {
addEventListener(type, fn) {
(listeners[type] || (listeners[type] = [])).push(fn);
},
removeEventListener(type, fn) {
const fns = listeners[type];
if (!fns) return;
const i = fns.indexOf(fn);
if (i !== -1) fns.splice(i, 1);
},
postMessage() {},
dispatchEvent(event) {
if (event.type === "eip6963:announceProvider") {
uuids.push(event.detail.info.uuid);
}
for (const fn of (listeners[event.type] || []).slice()) fn(event);
return true;
},
};
win.window = win;
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
win.dispatchEvent({
type: "message",
source: win,
data: { type: "AUTISTMASK_PROVIDER_UUID", uuid: STORED_UUID },
});
win.dispatchEvent(new StubEvent("eip6963:requestProvider"));
return uuids;
}
const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
describe("the EIP-6963 provider UUID is fresh per page load", () => {
test("a load announces a UUIDv4", () => {
const uuids = announcedUuids();
expect(uuids.length).toBeGreaterThan(0);
expect(uuids[0]).toMatch(UUID_V4);
});
test("every announcement within one load carries the same UUID", () => {
const uuids = announcedUuids();
expect(uuids.length).toBeGreaterThan(1);
expect(new Set(uuids).size).toBe(1);
});
test("two page loads announce different UUIDs, neither the stored one", () => {
const first = announcedUuids();
const second = announcedUuids();
expect(first).not.toContain(STORED_UUID);
expect(second).not.toContain(STORED_UUID);
expect(second[0]).not.toBe(first[0]);
});
});
+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/);
});
});
+534
View File
@@ -0,0 +1,534 @@
// The typed-data signing screen
// (https://git.eeqj.de/sneak/AutistMask/issues/400).
//
// A Permit or Permit2 signature lets its spender take tokens from the signer's
// address, and it is how most wallet drains are done. Listed as plain
// key/value lines it reads exactly like a sign-in message, so the screen has
// to warn for it, naming the spender and the amount, and must not warn for a
// sign-in message.
//
// ethers signs only the fields a type declares in `types` and drops any other
// key in the message, so the warning reads the spender, tokens and amounts
// from those fields alone, except a `Permit`'s token, which is the domain's
// `verifyingContract`: a key the page adds beside them must not change what
// the warning says.
//
// ethers signs the type it derives from `types`, not the page's
// `primaryType`, so the screen names the derived type, and a request whose
// stated type is missing or differs is refused rather than shown under a name
// it is not signed as. The refusal is checked on the sign screen itself,
// driven against a minimal DOM stub in the shape
// tests/deleteWalletLostPassword.test.js uses.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { getAddress, MaxUint256 } = require("ethers");
const { state } = require("../src/shared/state");
const { decryptWithPassword } = require("../src/shared/vault");
const approval = require("../src/popup/views/approval");
const { formatTypedDataHtml, typedDataRefusal } = approval;
const SPENDER = getAddress("0xbad000000000000000000000000000000000bad0");
const DECOY = getAddress("0xdec0000000000000000000000000000000000dec");
const OWNER = getAddress("0x0000000000000000000000000000000000000a11");
// Bundled, so the symbol and the 6-decimal scale come from the list.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
const WARNING = "TOKEN PERMISSION";
// Permit2's PermitSingle, granting the largest uint160 allowance there is.
const PERMIT_SINGLE = {
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
PermitSingle: [
{ name: "details", type: "PermitDetails" },
{ name: "spender", type: "address" },
{ name: "sigDeadline", type: "uint256" },
],
PermitDetails: [
{ name: "token", type: "address" },
{ name: "amount", type: "uint160" },
{ name: "expiration", type: "uint48" },
{ name: "nonce", type: "uint48" },
],
},
primaryType: "PermitSingle",
domain: { name: "Permit2", chainId: 1, verifyingContract: PERMIT2 },
message: {
details: {
token: USDC,
amount: ((1n << 160n) - 1n).toString(),
expiration: "1790000000",
nonce: "0",
},
spender: SPENDER,
sigDeadline: "1790000000",
},
};
// Permit2's PermitBatch: 5 USDC and 7 DAI, a line for each token.
const PERMIT_BATCH = {
types: {
EIP712Domain: PERMIT_SINGLE.types.EIP712Domain,
PermitBatch: [
{ name: "details", type: "PermitDetails[]" },
{ name: "spender", type: "address" },
{ name: "sigDeadline", type: "uint256" },
],
PermitDetails: PERMIT_SINGLE.types.PermitDetails,
},
primaryType: "PermitBatch",
domain: PERMIT_SINGLE.domain,
message: {
details: [
{
token: USDC,
amount: "5000000",
expiration: "1790000000",
nonce: "0",
},
{
token: DAI,
amount: "7000000000000000000",
expiration: "1790000000",
nonce: "0",
},
],
spender: SPENDER,
sigDeadline: "1790000000",
},
};
// One of Permit2's four transfer types, letting SPENDER take 5 USDC. The
// batch types take a list of `TokenPermissions`; the witness types add a
// struct of the site's own as their last field.
function permit2Transfer(primaryType, { batch = false, witness = false }) {
const fields = [
{
name: "permitted",
type: batch ? "TokenPermissions[]" : "TokenPermissions",
},
{ name: "spender", type: "address" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
];
const types = {
EIP712Domain: PERMIT_SINGLE.types.EIP712Domain,
[primaryType]: fields,
TokenPermissions: [
{ name: "token", type: "address" },
{ name: "amount", type: "uint256" },
],
};
const permitted = { token: USDC, amount: "5000000" };
const message = {
permitted: batch ? [permitted] : permitted,
spender: SPENDER,
nonce: "0",
deadline: "1790000000",
};
if (witness) {
fields.push({ name: "witness", type: "Order" });
types.Order = [{ name: "recipient", type: "address" }];
message.witness = { recipient: OWNER };
}
return { types, primaryType, domain: PERMIT_SINGLE.domain, message };
}
// EIP-2612's Permit for 5 USDC; the token is the domain's contract.
const PERMIT = {
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
domain: {
name: "USD Coin",
version: "2",
chainId: 1,
verifyingContract: USDC,
},
message: {
owner: OWNER,
spender: SPENDER,
value: "5000000",
nonce: "0",
deadline: "1790000000",
},
};
// DAI's older permit: the same name, no amount, all or nothing by `allowed`.
const DAI_PERMIT = {
types: {
EIP712Domain: PERMIT.types.EIP712Domain,
Permit: [
{ name: "holder", type: "address" },
{ name: "spender", type: "address" },
{ name: "nonce", type: "uint256" },
{ name: "expiry", type: "uint256" },
{ name: "allowed", type: "bool" },
],
},
primaryType: "Permit",
domain: {
name: "Dai Stablecoin",
version: "1",
chainId: 1,
verifyingContract: DAI,
},
message: {
holder: OWNER,
spender: SPENDER,
nonce: "0",
expiry: "0",
allowed: true,
},
};
const LOGIN = {
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
],
Login: [
{ name: "contents", type: "string" },
{ name: "nonce", type: "uint256" },
],
},
primaryType: "Login",
domain: { name: "Example", version: "1", chainId: 1 },
message: { contents: "Sign in to example.com", nonce: "7" },
};
// The warning box alone. It comes before the key/value lines, which list the
// spender and the raw amount too, so an assertion on the whole screen would
// pass with no warning at all.
function warningOf(data) {
const html = formatTypedDataHtml(JSON.stringify(data));
return html.slice(0, html.indexOf(">Domain<"));
}
function request(data) {
return { method: "eth_signTypedData_v4", typedData: JSON.stringify(data) };
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("a token permission is warned about, naming spender and amount", () => {
test("a Permit2 PermitSingle for an unlimited allowance", () => {
const warning = warningOf(PERMIT_SINGLE);
expect(warning).toContain(WARNING);
expect(warning).toContain(SPENDER);
expect(warning).toContain(USDC);
expect(warning).toContain("Unlimited");
});
test("a Permit2 PermitBatch names both tokens and both amounts", () => {
const warning = warningOf(PERMIT_BATCH);
expect(warning).toContain(WARNING);
expect(warning).toContain(SPENDER);
expect(warning).toContain(USDC);
expect(warning).toContain("5.0000 USDC");
expect(warning).toContain(DAI);
expect(warning).toContain("7.0000 DAI");
});
test.each([
["PermitTransferFrom", {}],
["PermitWitnessTransferFrom", { witness: true }],
["PermitBatchTransferFrom", { batch: true }],
["PermitBatchWitnessTransferFrom", { batch: true, witness: true }],
])("a Permit2 %s", (primaryType, shape) => {
const warning = warningOf(permit2Transfer(primaryType, shape));
expect(warning).toContain(WARNING);
expect(warning).toContain(SPENDER);
expect(warning).toContain(USDC);
expect(warning).toContain("5.0000 USDC");
});
test("an EIP-2612 Permit for 5 USDC", () => {
const warning = warningOf(PERMIT);
expect(warning).toContain(WARNING);
expect(warning).toContain(SPENDER);
expect(warning).toContain("5.0000 USDC");
});
test("DAI's older permit, which grants everything", () => {
const warning = warningOf(DAI_PERMIT);
expect(warning).toContain(WARNING);
expect(warning).toContain(SPENDER);
expect(warning).toContain("Unlimited");
});
test("a sign-in message carries no warning, and is still shown", () => {
const html = formatTypedDataHtml(JSON.stringify(LOGIN));
expect(html).not.toContain(WARNING);
expect(html).toContain("Sign in to example.com");
});
});
describe("the warning reads only the fields the signed type declares", () => {
// An unlimited EIP-2612 permit with DAI's `allowed` added as a key the
// type does not declare. ethers signs exactly the unlimited permit.
test("an added key does not change the amount", () => {
const data = {
...PERMIT,
message: {
...PERMIT.message,
value: MaxUint256.toString(),
allowed: false,
},
};
expect(warningOf(data)).toContain("Unlimited");
});
// A Permit whose type names its spender `operator`; the page adds a
// `spender` key the type does not declare.
test("an added key is not named as the spender", () => {
const data = {
...PERMIT,
types: {
...PERMIT.types,
Permit: [
{ name: "owner", type: "address" },
{ name: "operator", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
message: { ...PERMIT.message, operator: SPENDER, spender: DECOY },
};
const warning = warningOf(data);
expect(warning).toContain(WARNING);
expect(warning).not.toContain(DECOY);
});
// The permit for a Uniswap v3 position NFT: a `Permit` with no amount
// field at all, which ethers signs and its contract accepts.
test("a Permit with no amount still warns, above the normal lines", () => {
const data = {
types: {
EIP712Domain: PERMIT.types.EIP712Domain,
Permit: [
{ name: "spender", type: "address" },
{ name: "tokenId", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
domain: {
name: "Uniswap V3 Positions NFT-V1",
version: "1",
chainId: 1,
verifyingContract: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
},
message: {
spender: SPENDER,
tokenId: "12345",
nonce: "0",
deadline: "1790000000",
},
};
const html = formatTypedDataHtml(JSON.stringify(data));
const warning = warningOf(data);
expect(warning).toContain(WARNING);
expect(warning).toContain(SPENDER);
expect(warning).toContain("<div>Amount</div><div>Unknown</div>");
expect(html).toContain(">Domain<");
expect(html).toContain(">Primary type<");
expect(html).toContain("tokenId:");
});
});
describe("the primary type shown is the one ethers signs", () => {
// A drain stated as a sign-in: the page says Login, the types say
// PermitSingle, and ethers would sign PermitSingle.
const disguised = { ...PERMIT_SINGLE, primaryType: "Login" };
test("a stated type that differs from the signed one is refused", () => {
expect(typedDataRefusal(request(disguised))).toBe(
"This typed data names its primary type as Login, but it would be signed as PermitSingle, so it cannot be signed.",
);
});
test("the screen names the signed type, and still warns", () => {
const html = formatTypedDataHtml(JSON.stringify(disguised));
expect(html).toContain(">PermitSingle<");
expect(html).not.toContain(">Login<");
expect(html).toContain(WARNING);
});
test("a missing primary type is refused", () => {
const unnamed = { ...PERMIT_SINGLE, primaryType: undefined };
expect(typedDataRefusal(request(unnamed))).toBe(
"This typed data does not name its primary type, so it cannot be signed.",
);
});
test("a stated type that is the signed one is not refused", () => {
expect(typedDataRefusal(request(PERMIT_SINGLE))).toBeNull();
expect(typedDataRefusal(request(LOGIN))).toBeNull();
});
});
// ------------------------------------------------------------ the sign screen
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
};
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// Open the sign screen for a typed-data request from OWNER, the way the popup
// does: the background hands over the request and show() draws it. Returns
// every message the screen sends to the background.
async function openSignScreen(data) {
const sent = [];
globalThis.document = makeDocument();
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
sent.push(msg);
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
reply({
type: "sign",
hostname: "dapp.example",
isPhishingDomain: false,
approvedFrom: OWNER,
signParams: request(data),
});
},
};
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: OWNER, balance: "0.0000", tokenBalances: [] },
],
},
];
approval.init({});
await approval.show(1);
return sent;
}
describe("the sign screen refuses a mismatched primary type", () => {
const disguised = { ...PERMIT_SINGLE, primaryType: "Login" };
const REFUSAL =
"This typed data names its primary type as Login, but it would be signed as PermitSingle, so it cannot be signed.";
beforeEach(() => {
decryptWithPassword.mockClear();
});
test("a matching primary type leaves Sign enabled", async () => {
await openSignScreen(PERMIT_SINGLE);
expect(node("approve-sign-error").textContent).toBe("");
expect(node("btn-approve-sign").disabled).toBe(false);
});
test("a mismatched one shows the error, with Sign disabled", async () => {
await openSignScreen(disguised);
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
expect(node("approve-sign-error").style.visibility).toBe("visible");
expect(node("btn-approve-sign").disabled).toBe(true);
});
// The handler is called directly, as a button re-enabled by some other
// path would call it: the refusal must not rest on the button alone.
test("Sign clicked anyway decrypts and signs nothing", async () => {
const sent = await openSignScreen(disguised);
node("approve-sign-password").value = "any password";
await click("btn-approve-sign");
expect(decryptWithPassword).not.toHaveBeenCalled();
expect(sent.map((msg) => msg.type)).not.toContain(
"AUTISTMASK_SIGN_RESPONSE",
);
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
expect(node("btn-approve-sign").disabled).toBe(true);
});
});
+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 = {
trackedTokens: [
{ 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(
"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 = [
{ 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", () => {
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", () => {
@@ -140,7 +141,7 @@ describe("the Min. received line takes the same rule", () => {
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
];
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);
});
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", () => {
const { constants, wallet } = loadWallet();