30 Commits
Author SHA1 Message Date
clawbot 2c882c75d6 fix: refusing a From address the wallet does not hold moves nothing (closes #609)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Sign & Send wrote its refusal of a From address the selected wallet does not
hold onto the password error line, which reserves one line. The sentence runs
to about three, so it pushed Sign & Send down.

The refusal now has its own box above the password field, its sentence fixed
in index.html, holding its space from the first paint and only made visible,
as #583 did for the other
refusals. Drawing the screen and each press hide it again. A Chrome test
writes the other wallet into the stored selection, reopens the popup onto the
screen, presses Sign & Send and checks that nothing was broadcast and nothing
moved.

Model: opus-5-5
2026-10-19 20:50:02 +02:00
clawbot d6fb002656 fix: each router release names and reads its own commands (closes #592)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Each Universal Router release now carries the command it runs for each
command type, by the name its pinned Commands.sol gives it, and the decoder
names and reads each step as that command instead of using one table for
every router. On UniversalRouterV1 and UniversalRouterV1_2_V2Support, 0x10
to 0x13 are NFT commands, such as Seaport: they are no longer read as V4
commands, so they draw no swap lines and their payouts count as unread.
UniversalRouterV1 has no BALANCE_CHECK_ERC20 and reverts on 0x0e, which is
now listed as Command 0x0e and sets no minimum. No release lets a V4 swap
fail, so the zero-minimum reading for a flagged one is gone.

Model: opus-5-5
2026-10-19 18:00:12 +02:00
clawbot cab7cb02b6 fix: the wallet's own send shows its amount to four decimal places (closes #522)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
ConfirmTx and the wait, success and error screens printed Send's amount field
as it stood (`.5 ETH`, or every digit Max filled in); TxApproval shows
`0.5000 ETH`. ConfirmTx now writes the amount as TxApproval does: parsed at the
scale it is encoded with, written back as plain decimal text and cut by
truncateAmountNeverZero(), and hands the status screens a copy carrying that
text. A token whose scale is unknown, which cannot be sent, has the field's
text cut the same way. The transaction is still signed for the exact amount,
which also prices the USD value. A saved confirmation screen whose amount is
not text is no longer redrawn.

Model: opus-5-5
2026-10-19 17:23:02 +02:00
clawbot 3db066413e fix: a balance check of another address sets no swap minimum (closes #600)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
BALANCE_CHECK_ERC20 takes (owner, token, minBalance), and every Universal
Router release that has it checks the owner's balance as written, without
map(), so address(1) and address(2) there are plain addresses. The decoder
read only the token and the minimum, so a check of another address's balance
read as Min. received. A check whose owner is not the transaction's sender now
sets neither Token Out nor the minimum, and its step reads "Balance Check (of
another address)"; one whose input does not decode reads "Balance Check (could
not be decoded)". A check of the sender keeps its reading, including the zero
reading for one the router lets fail.

Model: opus-5-5
2026-10-19 16:27:41 +02:00
clawbot c99772a520 fix: a step the router lets fail is marked and sets no minimum (closes #602)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Each Universal Router release now carries the command types its dispatch()
can return a failure for instead of reverting, read from its source; only a
step of one of those with the allow-revert flag (0x80) set can fail while the
transaction goes on. Such a step is listed with "(allowed to fail)". A minimum
such a step states guarantees nothing, so it is read as zero: a
BALANCE_CHECK_ERC20 then keeps an earlier step's minimum, and 0x10 on
UniversalRouterV1 and UniversalRouterV1_2_V2Support, an NFT call there still
read as a V4 swap, states no minimum. Every swap and every payment of ETH or an
ERC-20 token succeeds or reverts the whole transaction, so the flag on them has
no effect.

Model: opus-5-5
2026-10-19 15:24:13 +02:00
clawbot a73fb17295 fix: a send the confirmation screen refuses moves nothing on it (closes #583)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Sign & Send wrote its refusals, a network changed since review and a gas limit or fee over its bound, into the errors box above the password field. That box reserves one empty line, so a refusal grew it and pushed the password field and Sign & Send down.

Each refusal now has its own box with its sentence fixed in index.html, holding its space from the first paint and only made visible, as #558 did for the balance messages. One sentence is true of all three bounds and quotes no number; the full reason is logged. A Chrome test raises the stub node's fee only at signing and checks nothing moved; a unit test refuses a gas limit over its bound into the same box.

Model: opus-5-5
2026-10-19 14:45:01 +02:00
clawbot 99269675b7 fix: the swap decoder reads who the router's transfer commands pay (closes #599)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION (0x07, on the 0x7f-mask
routers), PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 (0x40) and V4
TAKE_PORTION were payouts the decoder did not read. Each recipient is now
read: the sender, the router, a hop into the next V2 pair, or another
address, which is named. Paying all the router holds of a token pays it out
as SWEEP does; a TRANSFER of CONTRACT_BALANCE of ETH reverts. A
PERMIT2_TRANSFER_FROM into the router shows the amount it moves and the
token's address, and a bridge deposit names each of its recipient and
depositor that is not the sender; either keeps the minimum from reading
received.

Model: opus-5-5
2026-10-19 14:03:52 +02:00
clawbot 20e4f1652f fix: a recipient warning on the confirmation screen is about the recipient on screen (closes #575)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
checkRecipientHistory() showed the warning boxes whenever its lookups
answered, so after Back and a review to another recipient, a late answer
about the first recipient could show the never-sent-a-transaction, contract
or Etherscan phishing box on the second recipient's screen. It now drops a
result for a transaction no longer on screen, as estimateGas() and
readBalances() already do.

tests/confirmTxLateResult.test.js holds back the first screen's answer until
the second screen is shown, for each of the three boxes, the balance read and
the fee estimate.

Model: opus-5-5
2026-10-19 12:27:09 +02:00
clawbot 9c67e87ae5 fix: tracked tokens are kept per network, and bundled picks are offered on mainnet only (closes #523)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A network switch puts the outgoing network's tracked tokens away in a new
persisted field, networkTrackedTokens, and brings back the incoming network's
list, or an empty one, as endpoints are kept; the background's switch uses the
same function. The two add-token screens offer their common-token picks on
Ethereum Mainnet only. After a switch in Settings, Back renders the screen it
lands on again, and the stack is cut at the first screen that cannot be
rendered from saved state, or at AddressToken for any token but the native
one; the cut stack is saved with the switch. ConfirmTx drawn again keeps the
network it was reviewed on. A contract lookup that answers after a switch adds
nothing.

Model: opus-5-5
2026-10-19 11:50:39 +02:00
clawbot e0e8de8111 fix: the approval screen names the address a swap's output is paid to (closes #594)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The swap decoder read no recipient, so a swap paying its output to another
address still showed Min. received. It now reads the address each swap step,
SWEEP, WRAP_ETH, UNWRAP_WETH, V4 TAKE and TAKE_ALL pays. Min. received shows
only when some step pays the sender, none pays another address, none that may
pay one went unread, and the output does not stay in the router with no later
SWEEP or UNWRAP_WETH paying it out. A payment to the V2 pair the next V2 swap
spends as already paid is a hop, not a payout. Otherwise it reads Min. paid
out, with Paid to lines.

Model: opus-5-5
2026-10-19 11:33:54 +02:00
clawbot 5e4b8ff3b0 fix: the confirmation screen reads its balance on the network it is drawn for (closes #558)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
ConfirmTx showed and checked the balance Send handed it, so a screen the popup reopened onto stated the balance as Send knew it, not as it is on the network the screen is drawn for.

Each time it is shown, ConfirmTx now reads the From address's ETH balance, and for a token send its token balance, from the node reviewedNetworkProvider() picks for the network the transaction was reviewed for, the node the fee estimate and recipient checks ask. The fee estimate starts once the read returns. Until then the balance line says "Loading..." and Sign & Send stays disabled; a failed read is stated as unknown. Each message the read can bring has a box reserved from the first paint, so nothing moves when it returns.

Model: opus-5-5
2026-10-19 09:54:09 +02:00
clawbot e6fe9b2593 test: the popup boot in the jest suite sends no request to the internet (closes #559)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
bootPopup() in tests/support/popupBoot.js mocked three of the modules
that reach the network, but a boot onto the transaction screen still
asked Blockscout for its transaction, and a boot onto the confirmation
screen had src/shared/etherscanLabels.js look the recipient up on
Etherscan, both through the real fetch().
tests/persistedFieldContract.test.js boots onto both, so every run sent
those requests and logged their answers after its tests had finished.
bootPopup() now replaces fetch() with the throwing stub other test files
use, and cleanupPopup() puts it back. tests/popupBoot.test.js checks
that both requests reach the stub.

Model: opus-5-5
2026-10-19 08:46:18 +02:00
clawbot 1f5cdeb7b4 fix: a confirmation screen drawn again keeps the network it was reviewed for (closes #586)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
ConfirmTx set the transaction's chain id to the active network's every
time it drew the screen, so a popup reopened onto a saved confirmation
after a network switch in Settings described the reviewed transfer as
the new network's and sent it there.

show() now sets the chain id only on a transaction Send hands over, and
the screen describes the transaction entirely on that network: native
currency, USD values, explorer links, and the node and explorer its
checks ask. explorerUrl(), renderAddressHtml(), getPrice(), formatFee()
and checkEtherscanLabel() take an optional chain id for this. A saved
transaction whose chain id names no network is not restored.

Model: opus-5-5
2026-10-19 07:38:15 +02:00
clawbot b5d2cedb57 fix: the swap decoder reads each command's type with its router's mask (closes #535)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Each Universal Router address now carries the mask its own source reads a
command's type with: 0x1f for UniversalRouterV1, 0x3f for V1_2 and V2, 0x7f
for V2_1_1 and V2_1_2. The fixed 0x1f dropped the 0x20 bit, so
EXECUTE_SUB_PLAN (0x21) was listed as a V3 exact-out swap. A sub-plan step is
now listed as "Execute Sub-Plan (its steps are not shown)". Its commands are
not read and may spend from or pay the user wherever the step stands, so with
one the summary names no swap direction, Token In and Token Out read
"Unknown (could not be decoded)", and there is no Amount or Min. received.

Model: opus-5-5
2026-10-19 07:13:22 +02:00
clawbot f8b050f501 docs: the user guide says switching networks restores the last endpoints used (closes #589)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The user guide said switching the network in Settings resets the RPC and
Blockscout endpoints to that network's defaults. Switching restores the
endpoints last used on that network, or its defaults if it has none, as
README's Settings section says; the guide now says that.

The guide also said all three approval screens show the phishing warning.
There are four: a connected site's request to switch the network has its own
approval screen with the same warning. The guide now describes that request in
a short paragraph and says four.

Model: opus-5-5
2026-10-19 06:51:17 +02:00
clawbot e791dc72d2 fix: the delete wallet screens name the wallet's own key material (closes #529)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
DeleteWallet and DeleteWalletLostPassword told key and xprv wallets that
their recovery phrase was the way back, but those wallets have none. Both
screens now name the recovery phrase, the private key or the extended
private key, according to the wallet's kind.

The noun comes from keyMaterialName() in the popup view helpers, which
DeleteAddress now uses too in place of its own two-way choice, so these
three screens get it from one place. The lost-password screen's route-back
sentence is reworded to fit all three kinds.

Model: opus-5-5
2026-10-19 06:12:00 +02:00
clawbot cb58e9d5f7 fix: the Settings network help text says switching restores the last endpoints used (closes #513)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The text under Network said switching resets the RPC and Blockscout
endpoints to their defaults. Switching actually restores the endpoints
last used on that network, or that network's defaults if it has none,
as README's Settings section says. The text now says that, in README's
words.

Model: opus-5-5
2026-10-19 05:28:21 +02:00
clawbot 8b6c2b4df9 fix: the swap summary is shown only for a transaction sent to a Universal Router (closes #520)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The Universal Router decoder recognised execute() by its selector alone, so
the same calldata sent to any address was described as a swap through the
router. It now keeps the router deployments Uniswap publishes for Ethereum
Mainnet and Sepolia, and decodes only a transaction sent to one of them on
its network, ignoring address case. TxApproval passes the transaction's
chain id, TransactionDetail the active network's.

Model: opus-5-5
2026-10-19 04:40:55 +02:00
clawbot 85fa864db4 test: no timer a popup test started fires after cleanupPopup() (closes #578)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
tests/support/popupBoot.js recorded every interval the popup sets but left
timeouts alone, so the two-second timer that clears a flash message could fire
after cleanupPopup() had removed the page, throw "document is not defined",
and fail whichever test file the jest worker had moved on to. Timeouts still
run for real during a popup test; the helper now records each one until it
fires or is cleared, and cleanupPopup() clears the ones still pending. The
fake timers tests/firstWalletRefresh.test.js used to hold that timer are
removed. tests/popupBoot.test.js shows a flash message, cleans up, waits past
the flash duration, and fails if anything throws.

Model: opus-5-5
2026-10-19 04:06:24 +02:00
clawbot d2fd0f532a fix: a popup that opened on Welcome keeps refreshing once the first wallet is added (closes #530)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The ten-second refresh was started only by init(), and only when a wallet
existed as the popup opened, so a popup that opened on Welcome refreshed the
new wallet once and then showed that balance until it was reopened. init()
and the three import paths on AddWallet now call startRefresh(), which
refreshes at once and starts the ten-second refresh unless it is already
running, and does nothing while the recovery screen is up. The two AddWallet
tests that build their own context pass it in place of the refresh they
passed before.

Model: opus-5-5
2026-10-19 03:19:53 +02:00
clawbot b923b2c19c fix: a V4 step of chained swap actions shows what the user receives (closes #563)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A V4 step's input side is what SETTLE or SETTLE_ALL names, else the one
currency its swap actions spend and never buy; its output side is what
TAKE or TAKE_ALL names, else the one they buy and never spend. Amount sums
the amountIn of the actions spending the input side, shown only when each
states one; Min. received likewise. An unread swap action leaves the sides
to SETTLE, SETTLE_ALL, TAKE and TAKE_ALL, with no figure. A step with a
swap action always sets the output side, sets the input side only if no
earlier step did, and leaves a side its rules cannot name unknown, with no
figure. SWAP_EXACT_OUT spends its first path entry.

Model: opus-5-5
2026-10-19 02:50:16 +02:00
clawbot e2e1c5d40e fix: the recipient warning says what it checked and skips addresses that have received ETH (closes #528)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The transaction count the node reports is what an address has sent, so
an address that only receives, such as a savings address, got the "ZERO
transaction history" box on every send. The check now also asks the node
for the recipient's balance and warns only when both are zero. The box
says the address has never sent a transaction and holds no ETH, naming
the active network's native token as the contract warning does.

Model: opus-5-5
2026-10-19 02:34:13 +02:00
clawbot fc042c3191 fix: a page whose origin has no host is never told an address by accountsChanged (closes #564)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The background judges each tab by its URL's origin, the only one it can see, so a page its server sandboxes, whose own origin is null, was still sent the new active address on an address switch although all its requests are refused.

The background now names the origin it judged by on each accountsChanged event, and the content script passes the address to the page only when the page's own window.origin is that origin, an empty list otherwise. An event naming no origin also reaches the page as an empty list. chainChanged is unchanged. The README's SiteApproval section says the same.

Judgement call: covered by a unit test that sets window.origin; no browser-suite test.

Model: opus-5-5
2026-10-19 00:59:39 +02:00
clawbot 8b0a1bff02 fix: a site allowed without Remember stays connected when the background worker idles (closes #526)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A connection allowed without "Remember my choice for this site" lived in a module-level object in the background, so it ended the first time Chrome stopped the idle worker. Each such connection is now one item in the browser's session storage, which outlives the worker and is emptied when the browser closes, read and written through new wrappers in src/shared/browserApi.js. Removal in Settings, address switch, address removal and wallet removal still end it; Settings now waits for the removal before redrawing.

manifest/firefox.json now declares a minimum of Firefox 115.0, the first with session storage.

Judgement call: address and wallet removal still notify the background without waiting.

Model: opus-5-5
2026-10-18 23:59:24 +02:00
clawbot 1f5ec1c8de fix: Send refuses every amount the confirmation screen would refuse (closes #532)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Send's Review accepted any amount parseFloat() read as above zero, so 1e-3, 1abc and amounts with too many decimal places reached ConfirmTx, which then refused them or failed to estimate the fee.

isValidAmount() in src/shared/txValidation.js is now the one amount check, used by Send's Review and Max and by validateTransfer() for ConfirmTx: plain digits with at most one decimal point, above zero, nothing but zeros past the token's places. Zeros past those places are accepted, since the transfer encodes the amount exactly. Send now reads the token's decimal places before the ENS lookup, because the check needs them.

Judgement call: ConfirmTx now also refuses zero, as Send already did.

Model: opus-5-5
2026-10-18 22:46:07 +02:00
clawbot f00954e2c5 fix: failed requests reach the page with an error code and the node's data (closes #533)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A request the node refuses now reaches the page with the node's own JSON-RPC code and data: a reverting eth_call, an eth_sendTransaction whose gas estimate reverts, a failed broadcast. Every other refusal from the background carries a code too: 4100 when there is no address, -32603 for a failure inside the wallet, -32602 for a transaction request the wallet refuses as malformed. On the transaction path the page keeps the wallet's own sentence.

nodeError(), which finds the node's error inside an ethers error, lives in src/shared/approvalVerify.js beside failureTexts(), which did the same lookup.

Not run in a browser.

Model: opus-5-5
2026-10-18 22:12:10 +02:00
clawbot e48308c5b6 fix: the first wallet's first address is active as soon as it is added (closes #512)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The popup picked an active address only when it opened, so after the first
wallet was created nothing was active until a reopen: Home had no headline,
and Send and Receive refused. Each of the three import paths on AddWallet now
makes the new wallet's first address active when no address is. When the
phrase or xprv scan then replaces the address list with one that leaves that
address out, the new list's first address becomes active and the background
is told, as when the user picks an address on Home. The address scan stand-in
in the popup test boot now answers in the real scan's shape, which the phrase
and xprv imports read.

Model: opus-5-5
2026-10-16 07:15:29 +02:00
clawbot aa10a2a5f5 fix: each browser-suite run builds and removes an image of its own (closes #547)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
script/test-e2e and script/test-e2e-firefox tagged their image with one
fixed name. When two clones ran a suite at once, the second build moved
that tag, the image store deleted the first run's now-untagged image, and
that suite exited 125 without running a test. Each run now tags its image
with the suite's name and twelve random hex digits and runs it by that
tag. The cleanup that runs on exit, Ctrl-C, kill and a closed terminal
removes the run's container if docker's client left it running, then the
image. The image-ID file that guarded against a moving tag is gone.
README.md's End-to-End Tests section says which endings clean up.

Model: opus-5-5
2026-10-16 06:51:08 +02:00
clawbot 2c0888e112 fix: approval windows open as wide as the page (closes #518)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The approval window opened 360 pixels wide while the page is 396, cutting
off the Reject button; it now opens at 396. The browser counts the
window's frame in that width, and a tall screen gives some of it to a
vertical scrollbar, so once the approval's screen is up the page widens
its own window by however far it would scroll sideways, times the zoom of
its own tab. Firefox draws some zoom steps a little larger than it
reports, so the page measures again after each widening, three times at
most. The toolbar popup's window is left alone. The Firefox suite checks
that the sign prompt fits at 170%. README's Visual Style line now gives
the page's real width.

Model: opus-5-5
2026-10-16 05:44:50 +02:00
clawbot f857bb0a3f fix: a request whose origin has no host is refused, not treated as a site (closes #527)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A page with an opaque origin, such as one its server sandboxes with a
Content-Security-Policy, is reported to the background with the origin
"null", and Chrome reports a local file as "file://". The background took
either as a site, so every such page was one site: a connection remembered
for one covered them all, and the phishing check never matched. The
background now parses the reported origin and, when it has no host, refuses
the request with 4100 like one with no origin, before any method runs.
tests/rpcOrigin.test.js sends both, and README's SiteApproval says what is
refused.

Model: opus-5-5
2026-10-16 04:44:49 +02:00
77 changed files with 10097 additions and 1051 deletions
+414 -130
View File
@@ -362,6 +362,14 @@ are outside `make check`. Neither opens the popup from the toolbar button: both
load its page in an ordinary tab, so what the toolbar popup itself adds, its
size and its closing when it loses focus, is covered by neither.
Each run builds its image under a tag of its own, `autistmask-e2e-chrome:` or
`autistmask-e2e-firefox:` followed by twelve random hex digits, so two clones
can run the same suite at once without one build taking the other's image away.
The run removes that image, and its container if one is still running, after the
suite passes or fails, on Ctrl-C, on `kill`, and when the terminal or SSH
session it runs in closes. A run killed outright (`kill -9`) cannot clean up:
its image stays under its tag until removed by hand with `docker image rm`.
### Chrome (`make test-e2e`)
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
@@ -422,6 +430,15 @@ reserve while sitting on the same side of the estimate, so swapping the two in
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only.
It also covers a send that Sign & Send refuses for its network fee: the stub
node quotes an ordinary fee while the confirmation screen is drawn and one over
the bound when Sign & Send asks again, and the refusal must broadcast nothing
and leave the password field and Sign & Send where they were. It checks the same
for a send refused because the selected wallet does not hold its From address:
with the confirmation screen showing a send from the HD wallet, the key wallet
is selected by writing it straight into extension storage, as another popup
selecting it would save it, and the popup is reopened onto that screen.
It also covers both ways the wait for a sent transaction's receipt ends on the
error screen: lookups that still find no receipt 60 seconds after the broadcast,
and six lookups in a row that fail. Each must show its own message, and Done
@@ -479,6 +496,9 @@ papered over:
forced by clicking Reveal and the settings gear in one page task, which a
person cannot do; the case a person can hit, the first decrypt after the popup
opens while libsodium is still loading, is not driven.
- Approval windows open at 1280x720 whatever size the extension asks for, so
whether an approval screen fits its window is checked in the Firefox suite
only.
Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
@@ -560,9 +580,12 @@ the worker stopping. It starts Chromium itself, speaks the DevTools protocol
over `--remote-debugging-pipe`, and attaches to pages only. Before raising the
approvals it requires the worker to stop on its own with nothing pending, so a
run in which something else keeps the worker running fails. The site is
connected and the RPC endpoint set through the extension's own screens, against
the loopback dApp page and stub node the Firefox suite also uses
(`tests/e2e/dapp.js`). Unlike the suite above, it does not fail on page errors.
connected, without "Remember my choice for this site", and the RPC endpoint set
through the extension's own screens, against the loopback dApp page and stub
node the Firefox suite also uses (`tests/e2e/dapp.js`). Once the worker has
stopped, the run requires the site still to be told the address
([#526](https://git.eeqj.de/sneak/AutistMask/issues/526)). Unlike the suite
above, it does not fail on page errors.
### Firefox (`make test-e2e-firefox`)
@@ -573,7 +596,9 @@ suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
rejected and then approved, `eth_sendTransaction`, and a closed approval window
rejecting with EIP-1193 4001 — driven through the real content script,
background page and approval windows.
background page and approval windows. The `personal_sign` prompt is also
required to fit its window without scrolling sideways, with the extension's
pages at 170% zoom.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and
@@ -844,7 +869,11 @@ on the next event. Two consequences shape every recurring job in the background:
- Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage. `localStorage` does not exist in a
service worker at all — the one remaining user of it, `src/shared/ens.js`,
runs only in the popup and is marked as such.
runs only in the popup and is marked as such. The sites allowed without
"Remember my choice for this site" are kept in the browser's session storage
(`storage.session`), which outlives the worker but not the browser, so such a
connection lasts until it is ended or the browser closes. Firefox has session
storage from version 115, the minimum `manifest/firefox.json` declares.
Pending approvals are the exception: an approval waiting for the user, and the
site's request waiting on it, live only in the worker's memory, and only the
@@ -947,8 +976,16 @@ discoverable.
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
this scale.
- **360x600 popup**: Standard browser extension popup dimensions. The UI is
designed for this fixed viewport.
- **396x600 popup**: The page is 396 pixels wide (`body` in
`src/popup/styles/main.css`), and 600 pixels is as tall as the browser lets
the toolbar popup grow. The UI is designed for this fixed viewport. The window
an approval opens in is given the same size. The browser counts the window's
frame in that width, and a screen taller than the window gives some of it to a
vertical scrollbar, so once the approval's screen is up the page widens its
window by however far it would scroll sideways, scaled by the browser's zoom.
Firefox draws some zoom steps a little larger than the zoom it reports, so the
page measures again once the window has grown, and widens it again while it
still falls short.
#### No Layout Shift
@@ -985,14 +1022,16 @@ rendered differently on two screens.
The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
wallet's balances and the Send and confirmation screens, which send on the
active network, use the active network's. A transaction's figures use the one of
the network its chain id names, whichever network is active: the value and fee
on the approval screen, the amount on the wait, success and error screens, the
wallet's balances and the Send screen, which sends on the active network, use
the active network's. The confirmation screen uses the one of the network its
transaction was reviewed on, the network active when Send showed it, even after
the network is switched in Settings. A transaction's figures use the one of the
network its chain id names, whichever network is active: the value and fee on
the approval screen, the amount on the wait, success and error screens, the
transaction history and the transaction detail screen, and the refusal of a fee
above 1 ETH. A chain id that names no network reads `ETH`. Wherever this
document shows ETH as the label of a native balance, value or fee, in a "Native
ETH transfer" type line, in the contract-recipient warning or in that refusal,
ETH transfer" type line, in the two recipient warnings or in that refusal,
Sepolia shows `SepoliaETH`. The swap lines keep `ETH`, the router's own name for
the native currency, and the ETH/USD price line, shown on mainnet only, keeps
its fixed wording.
@@ -1047,23 +1086,29 @@ The rule and its exception live in `src/shared/amountDisplay.js` as
confirmation screens display goes through the floored one — the ERC-20 amount,
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`). Both screens render a network fee through
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
rather than its truncated figure, so the same fee reads the same on both, USD
value included. Balances are stored exactly (`src/shared/balances.js`), whatever
decimals a token declares, so a balance below the floor reaches these screens as
it is. The history list (`src/shared/transactions.js`) uses the unfloored one:
the transaction detail view is the authoritative record and already shows exact
precision. The balance lists use neither: they round to four places with
`toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal
rule is unchanged everywhere else, including for amounts at or above the floor
on the approval screens.
`Current balance` (`src/popup/views/send.js`), and the amount, balance and
network fee on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`), which hands the wait, success and error
screens the amount as it showed it. The transaction is still signed for the
exact amount, and its USD value is priced from it. Both screens render a network
fee through `formatFee()` in `src/popup/views/helpers.js`, which prices the
exact fee in USD rather than its truncated figure, so the same fee reads the
same on both, USD value included. Balances are stored exactly
(`src/shared/balances.js`), whatever decimals a token declares, so a balance
below the floor reaches these screens as it is. The history list
(`src/shared/transactions.js`) uses the unfloored one: the transaction detail
view is the authoritative record and already shows exact precision. The balance
lists use neither: they round to four places with `toFixed(4)` (`balanceLine()`
in `src/popup/views/helpers.js`). The 4-decimal rule is unchanged everywhere
else, including for amounts at or above the floor on the approval screens.
The floor applies only where the token's scale is known. Where it is not, the
approval screen states base units instead of a quantity — see Unknown token
scale below — and no truncation happens at all.
On the approval screen and the wait, success and error screens that carry its
amount forward, the floor applies only where the token's scale is known. Where
it is not, they state base units instead of a quantity — see Unknown token scale
below — and no truncation happens at all. On ConfirmTx and the wait, success and
error screens that follow it, the amount of a token whose scale is unknown,
which cannot be sent, is the text from Send's amount field cut to four decimals
with the same floor (`.5` reads `.5000`).
**Specific Exception — unknown token scale:** Calldata carries base units and no
scale, so every amount on the dApp approval screen needs the token's `decimals`.
@@ -1114,8 +1159,9 @@ fetched, without that cross-address check, and shows the row's figure.
**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:
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless
only the user is paid, see Transaction Decoding), 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.
@@ -1140,11 +1186,13 @@ read:
- `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,
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action.
amount of the step that sets the input side: the last `PERMIT2_PERMIT` step if
there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH`
or V4 swap step that names an input token or amount. A V4 step with a swap
action sets the input side when no earlier step set it, even when it names
neither, as described below. A V2 exact-out step gives its `amountInMax`, and
a V4 swap step the `amountIn` of the swap action that spends its input
currency.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
@@ -1160,22 +1208,51 @@ read:
guarantees nothing: it sets `Token Out` and `Min. received` only when the
output side holds no minimum, not even a zero one, at the point the check is
reached, and otherwise leaves the current token and figure in place. A nonzero
`minBalance` sets both lines, as a swap step does.
`minBalance` sets both lines, as a swap step does. Only a check of the
transaction's sender's own balance sets either line; a check of any other
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
that the router lets fail without reverting the transaction (see Transaction
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
lets fail is likewise read as zero.
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, or `Unknown (could not be decoded)` instead when a V4 swap action in
the transaction could not be read (see Transaction Decoding below). A V4 step
whose swap action could not be read still sets each side as a swap step does,
and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's
token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH
only when the output side is WETH, on mainnet or Sepolia, or when no step set
the output side; a WETH `Min. received` figure then reads in ETH. Otherwise
`Token Out` and `Min. received` keep the output side's own token and figure,
whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys
USDC and then unwraps the WETH it did not spend shows USDC. The token permission
warning on the signature screen has its own amount wording, including `Unknown`;
the SignApproval section below describes it.
the transaction could not be read or the transaction has an `EXECUTE_SUB_PLAN`
step, which leaves both sides unknown (see Transaction Decoding below). A V4
step with a swap action always sets the output side, and sets the input side
only when no earlier step set it. It finds each side by three rules:
1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else
the one currency that some swap action in the step spends and none buys. Its
output side is the currency its `TAKE` or `TAKE_ALL` names, or else the one
currency that some swap action buys and none spends. If there is not exactly
one such currency, that side is unknown, and the step sets it as unknown with
no figure. A step that buys WETH with USDT and USDC with that WETH, with
nothing naming its sides, reads `Swap USDT → USDC`.
2. Its `Amount` is the `amountIn` of the swap action that spends the input
currency. If several exact-in actions spend it, each with a stated amount, it
is their sum; in any other case the step gives no `Amount`. The decoder reads
no amount from an exact-out action, and an open delta is not a stated amount.
Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency.
3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no
`Amount` or `Min. received`. A side they did not name is unknown, and the
step sets it as unknown with no figure.
A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`,
`TAKE` or `TAKE_ALL` names.
An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH,
on mainnet or Sepolia, or when no step set the output side; a WETH
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
unwraps the WETH it did not spend shows USDC. The token permission warning on
the signature screen has its own amount wording, including `Unknown`; the
SignApproval section below describes it.
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
@@ -1442,6 +1519,19 @@ such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it.
A network switch in Settings leaves every screen beneath Settings showing the
previous network, so after it "Back" behaves as in a reopened popup: every
screen on the stack counts as not yet rendered, and the stack is cut at the
first screen that cannot be rendered from saved state, such as Send or AddToken,
or at AddressToken for any token but the native one, because that token was
picked on the previous network. The cut stack is saved with the switch, so a
popup closed straight after it and reopened onto Settings lands in the same
place. "Back" then renders the screen it lands on for the new network, except
ConfirmTx, which keeps the network its transaction was reviewed on, and anything
typed there and not saved, which belonged to the previous network, is dropped.
Without a wallet the stack is not cut, because it starts at Welcome, which shows
nothing from a network.
Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
exit from that screen rather than only on its "Back" button, so nothing secret
@@ -1531,8 +1621,14 @@ view would leave a wallet one click from deletion.
- **Transitions**:
- "Import" with a valid entry and a matching password of at least 12
characters → creates the wallet, clears the navigation stack, and →
**Home**. The phrase and xprv modes then scan for further used addresses
and report the count as a flash message.
**Home**. When no address is active yet, as on the first wallet, the new
wallet's first address becomes the active one. The phrase and xprv modes
then scan for further used addresses and report the count as a flash
message. When the scan finds two or more used addresses, they replace the
wallet's list, which then holds the first address only if it was used. If
the first address was active and is not in the new list, the new list's
first address becomes the active one and `AUTISTMASK_ACTIVE_CHANGED` is
broadcast.
- "Import" with a missing, short or mismatched password → full-sentence
error on the error line, no screen change
- "Import" with an invalid entry or a duplicate wallet or address → flash
@@ -1659,7 +1755,13 @@ view would leave a wallet one click from deletion.
- **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
- "Review" with an unresolvable ENS name or an invalid amount → flash
message, no screen change
message, no screen change. An amount is valid when it is a number above
zero, written as digits with at most one decimal point, with nothing but
zeros after the token's last decimal place. ETH has 18 places, and a token
with more, or with an unknown number, is held to 18. Zeros past those
places leave the amount unchanged, so `1.0000000` of a 6-place token
passes and is sent as exactly 1. ConfirmTx makes the same check before
anything can be sent
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
#### ConfirmTx (`confirm-tx`)
@@ -1671,40 +1773,59 @@ view would leave a wallet one click from deletion.
- Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
filled in is worked out again from this screen's own fee estimate when it
arrives, as the balance minus the reserve, and the transaction is signed
with that estimate's fee fields, so a fee fetched again at signing cannot
exceed what the amount leaves behind. The address keeps whatever part of
the reserve the transaction does not use. If the balance no longer covers
the fee, the amount is left as it was and the amount-plus-fee error below
blocks the send
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
for a token whose scale is unknown
- Amount: value + symbol (USD in parentheses), the value to four decimal
places with the nonzero floor, as on TxApproval (see Display Consistency),
however Send's amount field wrote it (`.5` and `0.5` both read `0.5000`).
For a token whose scale is unknown, which cannot be sent, the value is the
text from Send's amount field cut the same way (`.5` reads `.5000`). An
ETH amount Send's "Max" filled in is worked out again from this screen's
own fee estimate when it arrives, as the balance minus the reserve, and
the transaction is signed with that estimate's fee fields, so a fee
fetched again at signing cannot exceed what the amount leaves behind. The
address keeps whatever part of the reserve the transaction does not use.
If the balance could not be read or no longer covers the fee, the amount
is left as it was and one of the errors below blocks the send
- Your balance: the From address's balance on the network its transaction
was reviewed on, read from that network's RPC endpoint each time the
screen is shown, so a screen the popup reopens onto states and checks that
network's balance as it is then, whichever network is active. "Loading..."
until the read returns, then value + symbol (USD in parentheses), or
`unknown (SYMBOL)` for a token whose scale is unknown or when the read
failed. The amount check uses the same balances: the ETH balance, and for
a token transfer the token balance as well
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
fetched async. The first line is what the transfer is expected to cost,
`gasLimit * gasPrice` (USD in parentheses); the second is the
`gasLimit * maxFeePerGas` reserve the node requires, which is what the
balance check gates on. The second line is omitted on a network with no
type-2 pricing, where the two are the same number, but its space is
reserved either way
fetched async once the balance is read. The first line is what the
transfer is expected to cost, `gasLimit * gasPrice` (USD in parentheses);
the second is the `gasLimit * maxFeePerGas` reserve the node requires,
which is what the balance check gates on. The second line is omitted on a
network with no type-2 pricing, where the two are the same number, but its
space is reserved either way
- Warnings: inline warnings from the local checks (scam address, self-send)
plus four reserved warning boxes made visible by the async checks —
recipient with no transaction history, recipient is a contract, burn
address, and an Etherscan phishing/scam label
- Errors (insufficient balance), plus three reserved error boxes — the
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
estimated. The first two are mutually exclusive per transfer type, so only
the applicable one holds space. The last names its cause: for a token
whose scale is unknown the fee can never be estimated, and it says the
wallet does not know how many decimal places the token uses and that the
transaction cannot be sent; for any other failure it asks the user to go
back and try again
recipient that has never sent a transaction and holds no ETH, recipient is
a contract, burn address, and an Etherscan phishing/scam label. An address
that has received ETH but never sent a transaction still holds that ETH,
so it gets no warning
- Errors (an invalid amount), plus five reserved error boxes, so the balance
and the fee arriving later move nothing — the amount exceeds the balance
(for a token whose scale is unknown it says the token's balance is
unknown), the balance could not be read (it asks the user to go back and
try again), the amount plus the fee exceeds the balance (ETH transfers),
not enough ETH to pay the fee for the transfer (ERC-20 transfers), and the
fee could not be estimated. The third and fourth are mutually exclusive
per transfer type, so only the applicable one holds space. The last names
its cause: for a token whose scale is unknown the fee can never be
estimated, and it says the wallet does not know how many decimal places
the token uses and that the transaction cannot be sent; for any other
failure it asks the user to go back and try again
- Three more reserved error boxes, made visible when "Sign & Send" refuses a
send because the active network changed, because the selected wallet does
not hold the From address, or because of its gas limit or fee (see
Transitions), so the refusal moves nothing
- Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors, and while the network fee
estimate is pending or unavailable)
- "Sign & Send" button (disabled if errors, while the balance is being read,
and while the network fee estimate is pending or unavailable)
- **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
@@ -1715,19 +1836,38 @@ view would leave a wallet one click from deletion.
the contract's own answer is read at signing time only to be compared with
it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`)
- "Sign & Send" after the active network was switched in Settings while this
screen was open → nothing is signed; "The active network changed after
this transaction was reviewed, so it was not sent. Please go back and
review it again." in the errors box, no screen change. The transaction
carries the chain id of the network the screen was shown for, so it cannot
be signed for another
- "Sign & Send" after the active network was switched in Settings since this
transaction was reviewed → nothing is signed; "The active network changed
after this transaction was reviewed, so it was not sent. Please go back
and review it again." in its own box, no screen change. After a switch,
"Back" from Settings draws this screen again from the saved transaction,
as a popup reopened onto it does; when Send is beneath it on the stack, as
when Send showed it in the same popup, "Back" lands below Send instead
(see the top of Screen Map). The transaction carries the chain id of the
network active when Send showed this screen, and keeps it when the screen
is drawn again from the saved transaction, so it cannot be signed for
another. The screen describes it on that network, whichever network is
active: its amounts are in that network's native currency, its USD values
are that network's, so a testnet transaction shows none, its From, To and
token contract links open that network's explorer, the balance read, the
fee estimate and the contract and never-sent recipient checks ask that
network's RPC endpoint, and the Etherscan label check asks that network's
explorer
- "Sign & Send" (correct password) when the gas limit, a fee per gas or the
total fee the RPC endpoint fills in at signing is over its bound in
`assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is
signed; "The gas limit or fee the RPC endpoint quoted for this transaction
is more than this wallet will sign for, so it was not sent. Please go back
and try again." in its own box, the same sentence for all three, no screen
change
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not
hold the From address → full-sentence error on the password error line,
nothing sent (no screen change)
hold the From address → nothing is signed; "The selected wallet does not
hold the From address above, so nothing was sent. Please go back and send
again." in its own box, no screen change
- "Sign & Send" (correct password, but the From address's key is refused,
see Data Model) → nothing is signed → **ErrorTx** saying why
- "Back" → **Send**
@@ -1858,13 +1998,18 @@ view would leave a wallet one click from deletion.
- Contract address input
- Status line ("Looking up token...", cleared or replaced on failure)
- Common token quick-pick buttons (top 25 by market cap), which fill the
contract address input
contract address input. Shown on Ethereum Mainnet only, because the
bundled token list holds mainnet contracts
- "Add" button
- **Transitions**:
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
**AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change
- "Add", then a network switch in Settings before the contract lookup
answers → nothing is tracked; "The active network changed before the
lookup finished, so the token was not added." flash message, no screen
change
- "Back" → previous screen (AddressDetail)
#### Settings (`settings`)
@@ -1875,16 +2020,19 @@ view would leave a wallet one click from deletion.
- Wallets: one row per wallet with its name (tap to rename inline), a
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
plus a "+ Add wallet" button
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
plus a "+ Add token" button
- Tracked Tokens: one row per token tracked on the active network with an
`[x]` remove button, plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
network changes only here, or when the user approves a site's request on
**NetworkApproval**; either way, switching restores the RPC and Blockscout
endpoints last used on that network, or that network's defaults if it has
none, and sends `chainChanged` with the new chain id to every open tab.
Choosing the network already active changes nothing and sends nothing
none, and the tokens tracked on that network, and sends `chainChanged`
with the new chain id to every open tab. A token is tracked only on the
network it was added on, because a contract address names a token on one
network only. Choosing the network already active changes nothing and
sends nothing
- Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against
@@ -1903,7 +2051,7 @@ view would leave a wallet one click from deletion.
under any address, with remove buttons
- Connected Sites: the origins 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
holds these, in session storage, and Settings asks it for them with
`AUTISTMASK_GET_CONNECTED_SITES`
- Denied Sites: the origins remembered as denied, under any address, with
remove buttons
@@ -1922,13 +2070,15 @@ view would leave a wallet one click from deletion.
its origin is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
without "Remember" from that origin, under any address, and send
`accountsChanged` with an empty list to the open tabs of that origin. The
same host under another scheme or port is another site and is left alone.
Only the extension's own pages may send either message
`accountsChanged` with an empty list to the open tabs of that origin.
Settings waits for the background's answer before it draws the lists
again. The same host under another scheme or port is another site and is
left alone. 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)
- "Back" (or Settings gear again) → previous screen (Home); after a network
switch, the stack is cut first (see the top of Screen Map)
#### ShowRecoveryPhrase (`show-phrase`)
@@ -1969,7 +2119,9 @@ view would leave a wallet one click from deletion.
- **Elements**:
- "Back" button, "Delete Wallet" heading
- Warning naming the wallet and stating that deletion is permanent and any
funds are unrecoverable without the recovery phrase
funds are unrecoverable without the recovery phrase, the private key or
the extended private key, according to the wallet's kind
(`keyMaterialName()` in `src/popup/views/helpers.js`)
- Password input
- Error line
- "Confirm Delete" button
@@ -1985,8 +2137,8 @@ view would leave a wallet one click from deletion.
(`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`
without "Remember", which only the background holds, in session storage,
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**
@@ -2010,9 +2162,10 @@ view would leave a wallet one click from deletion.
cannot be unlocked again, and that no password is needed to delete it
- What deletion does and does not do: it erases the copy of the key stored
on this device; nothing on chain changes and no money is moved
- The route back — adding the wallet again with the recovery phrase and a
new password — and, in bold, that without that phrase written down the
deletion loses everything the wallet holds, forever
- The route back — adding the wallet again, with a new password, from a kept
copy of its recovery phrase, private key or extended private key (named as
on DeleteWallet) — and, in bold, that without that copy the deletion loses
everything the wallet holds, forever
- That the other wallets are not touched
- The wallet's name, and a text input asking for it to be typed back. A name
that shows nothing at all (only spaces, or only characters that paint
@@ -2070,8 +2223,9 @@ view would leave a wallet one click from deletion.
the address **only if it has on-chain activity**. An address that was
never used is not found by that scan. The text is written by
`recoveryPathText()` rather than sitting in `index.html`, so it can name
the wallet's own kind of key material: an xprv wallet has no recovery
phrase to re-import.
the wallet's own kind of key material through `keyMaterialName()`, as
DeleteWallet and DeleteWalletLostPassword do: an xprv wallet has no
recovery phrase to re-import.
- A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `formatAddressTotal()` (see
@@ -2099,15 +2253,16 @@ view would leave a wallet one click from deletion.
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`.
without "Remember", which only the background holds, in session storage, 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.
#### SettingsAddToken (`settings-addtoken`)
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
across every address, unlike AddToken which is reached from one address.
across every address on the active network, unlike AddToken which is reached
from one address.
- **Elements**:
- "Back" button, "Add Token" heading
- Instruction text
@@ -2116,11 +2271,19 @@ view would leave a wallet one click from deletion.
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
"Add selected" button
- "Or enter contract address:" input, a status line, and an "Add" button
- The two lists from the bundled token list, "Top tokens:" and "Or pick from
top 100:", are shown on Ethereum Mainnet only, because that list holds
mainnet contracts. On any other network the contract address is the only
add path
- **Transitions**:
- Any of the three add paths, on success → adds the token and shows an
- Any of the add paths shown, on success → adds the token and shows an
"Added SYMBOL" flash message (no screen change)
- A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change
- "Add" by contract address, then a network switch in Settings before the
lookup answers → nothing is tracked; "The active network changed before
the lookup finished, so the token was not added." flash message, no screen
change
- "Back" → previous screen (Settings)
#### SiteApproval (`approve-site`)
@@ -2134,14 +2297,17 @@ view would leave a wallet one click from deletion.
code `4100` and opens no prompt. Two such origins are `null`, which the
browser reports for a page with an opaque origin, such as one its server
sandboxes with a Content-Security-Policy, and `file://`, which Chrome reports
for a local file. The background script prefers the toolbar popup
(`action.openPopup()`) and falls back to a separate popup window
(`src/background/index.js`, `requestApproval()`). Only one exists per site at
a time: a further connection request from a site whose prompt is still
unanswered is refused with EIP-1193 code `-32002` and opens no new prompt. If
that prompt was in a toolbar popup that closed before it connected, and the
toolbar popup has since been set to open something else, the refused request
shows that prompt again.
for a local file. Nor is such a page told the active address by
`accountsChanged`, even at a connected site's URL: the background names the
origin of the tab's URL on the event, and the content script passes the
address on only to a page whose own origin is that one. The background script
prefers the toolbar popup (`action.openPopup()`) and falls back to a separate
popup window (`src/background/index.js`, `requestApproval()`). Only one exists
per site at a time: a further connection request from a site whose prompt is
still unanswered is refused with EIP-1193 code `-32002` and opens no new
prompt. If that prompt was in a toolbar popup that closed before it connected,
and the toolbar popup has since been set to open something else, the refused
request shows that prompt again.
- **Elements**:
- "Connection Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
@@ -2173,8 +2339,10 @@ view would leave a wallet one click from deletion.
one shown and signed in place of the node's estimate, but the node still
estimates the transaction with that limit, as the check that it neither
reverts nor runs out of gas; a request that gives both with different values
is refused. A request that cannot be populated — unreachable node, reverting
gas estimate — opens no window and is failed back to the site. Only one
is refused with EIP-1474 code `-32602`. A request that cannot be populated —
unreachable node, reverting gas estimate — opens no window and is failed back
to the site, with the node's own error code and data when the node refused it,
so the site can decode a revert, and with `-32603` otherwise. Only one
transaction approval exists at a time: populating fixes the nonce, so a second
`eth_sendTransaction` arriving while one is unanswered is refused with
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
@@ -2192,13 +2360,17 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a
transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding)
amounts, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying that its output
stays in the router or who it pays is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link
- 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
- Value: amount in ETH (4 decimal places with the nonzero floor, USD in
parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places with
the nonzero floor, USD in parentheses), with the gas limit and the fee per
gas in gwei below it
- Network and nonce
- Raw data: full calldata displayed inline (shown if present)
- Password input and an error line
@@ -2330,8 +2502,9 @@ view would leave a wallet one click from deletion.
- **Transitions**:
- "Switch" → closes popup; the background switches the network as
**Settings** does, restoring the RPC and Blockscout endpoints last used on
that network (or that network's defaults if it has none), sends
`chainChanged` to every open tab, and answers the site with success
that network (or that network's defaults if it has none) and the tokens
tracked on that network, sends `chainChanged` to every open tab, and
answers the site with success
- "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
nothing changes
- Popup window closed without answering → the same as "Reject"
@@ -2808,11 +2981,122 @@ Currently supported:
or recipient, and amount.
- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g.
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap
action is read only in the parameter shape deployed on mainnet and Sepolia,
not in the newer one on `v4-periphery` `main`, which adds a `minHopPriceX36`
field. An action in any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`.
The swap summary is shown only for a transaction sent to one of the Universal
Router addresses Uniswap publishes for the network: on TxApproval the network
the transaction is for, on TransactionDetail the active network. The same
calldata sent to any other address is shown as raw data. Decodes Permit2,
V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap action is read only
in the parameter shape deployed on mainnet and Sepolia, not in the newer one
on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`. Each step is named and read as the command
that the release of the router at that address runs for its command type, as
that release's `contracts/libraries/Commands.sol` names it. The decoder reads
the type from the command byte with the mask that router uses: `0x1f` for
`UniversalRouterV1`, `0x3f` for `UniversalRouterV1_2_V2Support` and
`UniversalRouterV2`, and `0x7f` for `UniversalRouterV2_1_1` and
`UniversalRouterV2_1_2`. The allow-revert flag (`0x80`) therefore does not
change the command a step is read as. Releases run different commands for some
types: on `UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` to
`0x13` are NFT commands, where the later routers run `V4_SWAP`,
`V3_POSITION_MANAGER_PERMIT`, `V3_POSITION_MANAGER_CALL` and
`V4_INITIALIZE_POOL`. The decoder does not read those routers' NFT commands,
and lists each by its name there, for example `Seaport` for `SEAPORT` (`0x10`
on `UniversalRouterV1`). A type a release names no command for is listed by
its number, for example `Command 0x0e` on `UniversalRouterV1`, which has no
`BALANCE_CHECK_ERC20` and reverts on it. The router reverts the whole
transaction when a step fails, unless the step's command byte has that flag
set and its command is one the router lets fail, as each release's
`contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`,
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`, `PERMIT2_PERMIT`,
`PERMIT2_PERMIT_BATCH`, `BALANCE_CHECK_ERC20`, `V3_POSITION_MANAGER_PERMIT`,
`V3_POSITION_MANAGER_CALL`, `V4_INITIALIZE_POOL`, `V4_POSITION_MANAGER_CALL`
and `EXECUTE_SUB_PLAN`; on `UniversalRouterV1_2_V2Support`,
`BALANCE_CHECK_ERC20`, `EXECUTE_SUB_PLAN` and its NFT market calls and NFT
owner checks; on `UniversalRouterV1`, its NFT market calls and NFT owner
checks. Such a step may then fail while the transaction goes on to the next
step, and it is listed with ` (allowed to fail)` after its name, for example
`Balance Check (allowed to fail)`. On every other command, among them each V2,
V3 and V4 swap and each payment of ETH or an ERC-20 token, the flag has no
effect: the step succeeds or the whole transaction reverts, so the step reads
as it does without the flag. A `BALANCE_CHECK_ERC20` the router lets fail
guarantees nothing, so it is read as one whose `minBalance` is zero. Only the
routers with the `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`),
listed as `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`),
listed as `Across Bridge Deposit`; the other routers revert on `0x07`, and it
is listed there as `Command 0x07`. The decoder does not read the commands
inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they
may spend the user's tokens or pay the user. That step is listed as
`Execute Sub-Plan (its steps are not shown)`, and whatever the other steps
state, the summary names no swap direction, `Token In` and `Token Out` read
`Unknown (could not be decoded)`, and there is no `Amount` or `Min. received`
line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `PERMIT2_TRANSFER_FROM` (which
moves the user's own tokens) and V4 `TAKE` and `TAKE_PORTION` names the
address it pays, and `TAKE_ALL` pays the transaction's sender. The router
reads `address(1)` as the transaction's sender, and `address(2)` as the router
itself, which holds what it is paid until a later step pays it out; a payment
to the router, as `address(2)` or by its own address, is not counted as paid
out. `UniversalRouterV1` alone pays a `PERMIT2_TRANSFER_FROM` to `address(1)`
or `address(2)` as those addresses, and the decoder names them. A
`PERMIT2_TRANSFER_FROM` into the router is named with the amount it moves,
because the decoder does not check that later steps spend all of it.
`ACROSS_V4_DEPOSIT_V3` pays the bridge, which pays the deposit's recipient on
another network and refunds a deposit nobody fills to its depositor; the
router passes both on as written, so `address(1)` and `address(2)` there are
addresses, and each that is not the transaction's sender is named. A
`BALANCE_CHECK_ERC20` pays nobody: the router checks that its owner holds at
least `minBalance` of its token, and reads the owner as written, so
`address(1)` and `address(2)` there are addresses too. A check of any address
but the transaction's sender's says nothing about what the user receives: it
sets neither `Token Out` nor the minimum, and it is listed as
`Balance Check (of another address)`. A check whose input does not decode,
such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router the swap's output token, the output stays in
the router unless a later step pays all the router holds of it to anyone but
the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is WETH, a
`TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a
`PAY_PORTION_FULL_PRECISION` of 1e18 of that token, or an
`ACROSS_V4_DEPOSIT_V3` of `CONTRACT_BALANCE` of that token, or of ETH when it
sets `useNative`. The router sends ETH as written, so a `TRANSFER` of
`CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
token to the V2 pair that the very next step spends, as a V2 exact-in of that
token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
the router enforces that swap's own minimum. The decoder checks that the
address paid is that pair, deriving it from the V2 factory and pair init code
hash the network's routers were deployed with and the first two tokens of that
step's path; a payment to any other address is named. (Sepolia's
`UniversalRouterV1_2_V2Support` was deployed with placeholders for both
values, so no V2 swap step can succeed on that router.) Every other payment
counts, in whichever step and of whatever token, a share of the output
included: an interface fee paid to another address by `PAY_PORTION`,
`TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
because the swap's minimum includes the fee. The minimum reads `Min. received`
only when some step pays the transaction's sender (the From address on
TxApproval, the sender the explorer reports on TransactionDetail) and no
`Paid to` line is shown. `Paid to` lines after `Token Out` name each other
address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
for each move into the router, followed by a `Token moved into the router`
line that gives the moved token's address as the `Token In` line does; one
reads `The router, where it stays (no later step pays it out)` when the output
stays in the router; and one more reads `Unknown (could not be decoded)` when
a step that may pay an address went unread. When no step pays anyone, a single
line reads `Unknown (not named in the calldata)`. Wherever there is a
`Paid to` line, the minimum reads `Min. paid out`. A step goes unread when its
recipient does not decode as an address, or when it is a command the decoder
does not read: every command other than `PERMIT2_PERMIT`,
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
`PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
`UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`,
`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among
them), or a type its release names no command for.
### Non-Goals Forever
+10 -2
View File
@@ -306,7 +306,14 @@ enter your password and click "Confirm" to authorize it. Message and typed-data
signature requests work the same way, with a "Sign" button, and also require
your password.
If the requesting site's domain is on the phishing blocklist, all three approval
A connected site can also ask to switch the wallet's network. A popup shows the
site's origin, the current network and the requested network. Nothing changes
unless you click "Switch"; clicking "Reject" or closing the popup leaves
everything as it was. Switching works exactly as it does in Settings, and
changes the network for the whole wallet and for every site, not only for the
one that asked.
If the requesting site's domain is on the phishing blocklist, all four approval
screens show a red phishing warning before you decide.
You can manage site permissions in Settings. Allowed and denied sites can be
@@ -378,7 +385,8 @@ Click the gear icon on the home screen to access settings:
- **Display**: Toggle whether tracked tokens with zero balance are shown, switch
timestamps to UTC, and choose the theme (System, Light, or Dark).
- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching
resets the RPC and Blockscout endpoints to that network's defaults.
restores the RPC and Blockscout endpoints you last used on that network, or
that network's defaults if you have not used it before.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy.
- **Blockscout API**: Change the Blockscout instance used for token balances and
+2 -1
View File
@@ -27,7 +27,8 @@
"web_accessible_resources": ["src/content/inpage.js"],
"browser_specific_settings": {
"gecko": {
"id": "autistmask@sneak.berlin"
"id": "autistmask@sneak.berlin",
"strict_min_version": "115.0"
}
}
}
+30 -17
View File
@@ -22,13 +22,29 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
IIDFILE=""
# Each run builds, runs and then removes an image under a tag of its own.
# Under one tag shared by every run, a second clone's build moves the tag
# off this run's image, and the image store can then delete that untagged
# image before or while this run uses it. The tag is random rather than
# $$ because CI jobs share one docker daemon, but each job's container
# numbers its processes from scratch.
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
NAME="$("$SCRIPT_DIR/projectname")-e2e-chrome"
IMAGE="$NAME:$SUFFIX"
# Each docker run below uses this container name: docker run --rm returns
# only once its container is removed, so no run meets the one before it.
CONTAINER="$NAME-$SUFFIX"
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
# After a third Ctrl-C docker's client exits whether or not the
# container has stopped, and a container still running keeps the image
# in use.
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
docker container rm -f "$CONTAINER" >/dev/null
fi
# No image to remove when the build failed or was interrupted.
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
docker image rm "$IMAGE" >/dev/null
fi
}
@@ -40,22 +56,16 @@ main() {
exit 1
fi
IIDFILE="$(mktemp)"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
# HUP: the terminal or SSH session running the suite closed.
trap 'cleanup; exit 130' HUP INT TERM
echo "Building the Chrome e2e image (extension included)..."
# --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run.
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/Dockerfile .
docker build --no-cache -t "$IMAGE" -f tests/e2e/Dockerfile .
echo "Running e2e suite in the pinned Playwright container..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --ipc=host: Chromium's shared-memory needs more than the default
# 64MB /dev/shm or renderers crash.
# HOME=/tmp: the image's root home is not a reliable place for the
@@ -75,11 +85,12 @@ main() {
# probe. The image is pinned by digest, so this can only ever bite
# on a deliberate bump.
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/run.js
# Where chrome.storage.local lives, and what moves it: two unpacked loads
@@ -93,9 +104,10 @@ main() {
# --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave.
echo "Running the extension-id and storage-partition observations..."
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/storagePartition.js
# Approvals left open for seven minutes in minimized windows, past the
@@ -105,9 +117,10 @@ main() {
# attached to. It reaches nothing but its own loopback server.
echo "Running the approvals left open for seven minutes..."
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/slowApproval.js
}
+22 -16
View File
@@ -21,13 +21,23 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IIDFILE=""
# A tag and a container name of this run's own, as in script/test-e2e,
# which says why.
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
NAME="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IMAGE="$NAME:$SUFFIX"
CONTAINER="$NAME-$SUFFIX"
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
# After a third Ctrl-C docker's client exits whether or not the
# container has stopped, and a container still running keeps the image
# in use.
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
docker container rm -f "$CONTAINER" >/dev/null
fi
# No image to remove when the build failed or was interrupted.
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
docker image rm "$IMAGE" >/dev/null
fi
}
@@ -39,22 +49,16 @@ main() {
exit 1
fi
IIDFILE="$(mktemp)"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
# HUP: the terminal or SSH session running the suite closed.
trap 'cleanup; exit 130' HUP INT TERM
echo "Building the pinned Firefox e2e image (extension included)..."
# --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run.
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/firefox/Dockerfile .
docker build --no-cache -t "$IMAGE" -f tests/e2e/firefox/Dockerfile .
echo "Running the Firefox e2e suite..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
# --network none: the suite stubs nothing, so this is what keeps the
# run offline and deterministic. The extension swallows its own
@@ -69,10 +73,11 @@ main() {
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
# it is cosmetic and headless Firefox runs fine without it.
docker run --rm \
--name "$CONTAINER" \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/firefox/run.js dist/firefox
# The install/uninstall/re-install property, against the packaged XPI
@@ -82,10 +87,11 @@ main() {
# and the suite above shares one session throughout.
echo "Running the Firefox re-install suite against the packaged XPI..."
docker run --rm \
--name "$CONTAINER" \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/firefox/reinstall.js
}
+131 -58
View File
@@ -26,6 +26,7 @@ const {
typedDataMessageRefusal,
typedDataChainRefusal,
failureIsRetryable,
nodeError,
describeTxFailure,
sameAddress,
ApprovalMismatchError,
@@ -48,6 +49,9 @@ const {
actionApi,
getPlatformInfo,
runtimeApi,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
tabsQuery,
tabsSendMessage,
windowsApi,
@@ -60,12 +64,31 @@ const runtime = runtimeApi();
const windowsNs = windowsApi();
const actionNs = actionApi();
// Connected sites (in-memory, non-persisted): { "origin:address": true }
// Connections the user allowed without "Remember my choice for this site".
// Each is one item in the browser's session storage, key
// origin + ":" + address and value true, and session storage holds nothing
// else. Session storage outlives this worker being stopped when idle and
// started again, so such a connection lasts until it is ended below or the
// browser closes. One item per connection means that making one is a single
// write, and two never overwrite each other.
//
// A site is its full origin (scheme://host[:port]), here and in the
// remembered allowedSites/deniedSites lists alike: a grant to
// https://dapp.example says nothing about http://dapp.example or another port.
const connectedSites = {};
// An origin can carry a port, so the address is what follows the key's last
// colon and the origin is what precedes it.
async function isConnected(origin, address) {
const key = origin + ":" + address;
const items = await sessionStorageGet(key);
return items[key] === true;
}
// End every connection allowed without "Remember" whose key `ends` returns
// true for.
async function endConnections(ends) {
const keys = Object.keys(await sessionStorageGet(null)).filter(ends);
if (keys.length > 0) await sessionStorageRemove(keys);
}
// Pending approval requests: { id: { origin, resolve } }
const pendingApprovals = {};
@@ -272,6 +295,21 @@ function failureError(err) {
return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
}
// The EIP-1193 error for a request that failed, with `message` as the
// sentence the page is shown. `rpcError` holds the JSON-RPC code and data the
// failure carries, if any: the node's own when the node refused the request,
// passed on unchanged so a site can tell a revert from any other failure and
// decode its revert data. A failure with no numeric code is the wallet's own,
// and is answered with the internal error code above.
function requestError(message, rpcError) {
if (rpcError && typeof rpcError.code === "number") {
const error = { code: rpcError.code, message };
if (rpcError.data !== undefined) error.data = rpcError.data;
return error;
}
return { code: INTERNAL_ERROR_CODE, message };
}
// The active address of a profile snapshot. Pure, and taking the snapshot as
// an argument rather than reading storage itself: a handler that has already
// read state must not answer "which account is this" from a SECOND, later read
@@ -317,7 +355,8 @@ function extractHostname(origin) {
}
}
// Proxy an RPC call to the Ethereum node
// Proxy an RPC call to the Ethereum node. An error the node answers with is
// thrown with the node's own JSON-RPC `code` and `data` on it, for the page.
async function proxyRpc(method, params) {
const rpcUrl = await getRpcUrl();
const resp = await debugFetch(rpcUrl, {
@@ -332,7 +371,10 @@ async function proxyRpc(method, params) {
});
const json = await resp.json();
if (json.error) {
throw new Error(json.error.message || "RPC error");
const err = new Error(json.error.message || "RPC error");
err.code = json.error.code;
err.data = json.error.data;
throw err;
}
return json.result;
}
@@ -453,7 +495,10 @@ function releaseApproval(approval) {
// the result on the floor, so a rejection here would be unhandled.
async function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 360;
// As wide as the page: body in src/popup/styles/main.css. The browser
// counts the window's frame in this width; the page itself adds back
// whatever the frame takes (src/popup/index.js).
const popupWidth = 396;
const popupHeight = 600;
let currentWin = null;
@@ -473,7 +518,7 @@ async function openApprovalWindow(id) {
// Centred on a browser window only. The last focused window can be
// another approval window still open, and centring on one can give a
// position the browser refuses ("Bounds must be at least 50% within
// visible screen space"): headless Chrome reports this 360x600 popup as
// visible screen space"): headless Chrome reports this 396x600 popup as
// 1280x720. The request then failed with no window at all. Over a popup,
// the browser picks the position.
if (currentWin && currentWin.type === "normal") {
@@ -725,7 +770,7 @@ async function handleConnectionRequest(origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress) {
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
}
const allowed = s.allowedSites[activeAddress] || [];
@@ -741,10 +786,10 @@ async function handleConnectionRequest(origin) {
};
}
// Check allowed list or in-memory connected
// Check allowed list or a connection allowed without "Remember"
if (
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]
(await isConnected(origin, activeAddress))
) {
return { result: [activeAddress] };
}
@@ -781,7 +826,7 @@ async function handleConnectionRequest(origin) {
if (decision.remember) {
await rememberSiteChoice("allowedSites", activeAddress, origin);
} else {
connectedSites[origin + ":" + activeAddress] = true;
await sessionStorageSet({ [origin + ":" + activeAddress]: true });
}
return { result: [activeAddress] };
} else {
@@ -835,7 +880,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]
(await isConnected(origin, activeAddress))
) {
return { result: [activeAddress] };
}
@@ -871,7 +916,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -964,10 +1009,10 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
const allowed = s.allowedSites[activeAddress] || [];
const isConnected =
const hasPermission =
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress];
if (!isConnected || !activeAddress) {
(await isConnected(origin, activeAddress));
if (!hasPermission || !activeAddress) {
return { result: [] };
}
return {
@@ -989,12 +1034,12 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1043,12 +1088,12 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1090,12 +1135,13 @@ async function handleRpc(method, params, origin) {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
// A node that answered with an error is reported as itself. The
// wallet being unable to read its own profile is not that, and it
// must not be flattened into a message with no code: it goes back
// to the dispatcher, which has the one answer for it.
// A node that answered with an error is reported as itself, with
// its own code and data. The wallet being unable to read its own
// profile is not that, and it must not be answered as an ordinary
// failure: it goes back to the dispatcher, which has the one
// answer for it.
if (e instanceof StateUnusableError) throw e;
return { error: { message: e.message } };
return { error: requestError(e.message, e) };
}
}
@@ -1111,12 +1157,14 @@ async function handleRpc(method, params, origin) {
async function handleSendTransaction(params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress) return { error: { message: "No accounts available" } };
if (!activeAddress) {
return { error: { code: 4100, message: "No accounts available" } };
}
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1170,7 +1218,7 @@ async function handleSendTransaction(params, origin) {
txParams,
);
} catch (e) {
return { error: { message: e.message } };
return { error: requestError(e.message, e) };
}
// Population is a network round trip, and the user can switch address
@@ -1180,10 +1228,9 @@ async function handleSendTransaction(params, origin) {
// while the page is still waiting and nothing has been displayed.
if (!sameAddress(await getActiveAddress(), activeAddress)) {
return {
error: {
message:
error: requestError(
"The active address changed while this transaction was being prepared, so it was not sent.",
},
),
};
}
@@ -1229,10 +1276,6 @@ async function broadcastChainChanged(chainId) {
// Broadcast accountsChanged to all tabs, respecting per-address permissions
async function broadcastAccountsChanged() {
// Clear non-remembered approvals on address switch
for (const key of Object.keys(connectedSites)) {
delete connectedSites[key];
}
// Reject and close any pending approval popups so they don't hang. An
// approval an attempt has already claimed is left alone entirely: it is
// being signed and broadcast right now, and neither rejecting it to the
@@ -1251,6 +1294,10 @@ async function broadcastAccountsChanged() {
}
}
resetPopupUrl();
// An address switch ends every connection allowed without "Remember". This
// waits on storage, so it comes after the pending approvals above, which
// are still rejected the moment the switch arrives.
await endConnections(() => true);
const s = await getState();
const activeAddress = activeAddressOf(s);
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
@@ -1261,17 +1308,24 @@ async function broadcastAccountsChanged() {
return;
}
for (const tab of tabs) {
// The tab's URL is all the background knows of the page in it, and a
// page at a site's URL may have another origin: a page its server
// sandboxes with a Content-Security-Policy has the origin "null". So
// the event names the origin it was judged by, and the content script
// passes the address on only to a page whose own origin is that one
// (src/content/index.js).
const origin = tab.url ? new URL(tab.url).origin : "";
const hasPermission =
activeAddress &&
(allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]);
(await isConnected(origin, activeAddress)));
// Same as chainChanged above: a tab without our content script
// rejects, and that is expected rather than a fault.
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: hasPermission ? [activeAddress] : [],
origin,
}).catch(() => {});
}
}
@@ -1676,7 +1730,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (outcome.spendApproval) {
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error) },
{ holdsClaim: true },
);
} else {
@@ -1703,7 +1757,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error) },
{ holdsClaim: true },
);
sendResponse({
@@ -1736,10 +1790,13 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// that says plainly it did not take the transaction:
// describeTxFailure() reclassifies that, and the stage it
// returns is the one reported.
//
// A node that refused the transaction gives the page its own
// code and data, whatever sentence the page is shown.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error, nodeError(e)) },
{ holdsClaim: true },
);
sendResponse({
@@ -1861,7 +1918,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!retryable) {
settleApproval(
msg.id,
{ error: { message: errMsg } },
{ error: requestError(errMsg) },
{ holdsClaim: true },
);
} else {
@@ -1889,41 +1946,57 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
// 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)) {
endConnections((key) => {
const address = key.slice(key.lastIndexOf(":") + 1);
if (removed.some((a) => sameAddress(a, address))) {
delete connectedSites[key];
}
}
return removed.some((a) => sameAddress(a, address));
}).catch((err) => {
log.errorf(
"ending the removed addresses' connections failed:",
err,
);
});
return false;
}
// Settings lists the sites connected without "Remember", which only this
// worker knows. The origin is what precedes the key's last colon.
// Settings lists the sites connected without "Remember", which only
// session storage holds.
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse(
Object.keys(connectedSites).map((key) =>
sessionStorageGet(null)
.then((items) =>
Object.keys(items).map((key) =>
key.slice(0, key.lastIndexOf(":")),
),
);
return false;
)
.catch((err) => {
log.errorf("listing the connected sites failed:", err);
return [];
})
.then(sendResponse);
return true;
}
// Settings removed this site (msg.origin) 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.
// The answer goes back once they have ended, and Settings waits for it
// before it lists the connected sites again.
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
for (const key of Object.keys(connectedSites)) {
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
delete connectedSites[key];
}
}
endConnections(
(key) => key.slice(0, key.lastIndexOf(":")) === msg.origin,
)
.catch((err) => {
log.errorf(
"ending the removed site's connections failed:",
err,
);
})
.then(() => {
broadcastSiteRemoved(msg.origin);
return false;
sendResponse(null);
});
return true;
}
// Settings switched the network and has saved it. Open tabs are told the
+12 -3
View File
@@ -49,14 +49,23 @@ window.addEventListener("message", (event) => {
// Listen for events pushed from the background (e.g. accountsChanged)
runtimeApi().onMessage.addListener((msg) => {
if (msg.type === "AUTISTMASK_EVENT") {
if (msg.type !== "AUTISTMASK_EVENT") return;
// The background chooses the addresses in accountsChanged for the site
// at the origin the event names, which is the origin of the tab's URL.
// This page hears them only when its own origin is that one. A page whose
// origin has no host is not a site: one its server sandboxes with a
// Content-Security-Policy has the origin "null" whatever its URL, and is
// told no address, as its requests are refused.
let data = msg.data;
if (msg.eventName === "accountsChanged" && msg.origin !== window.origin) {
data = [];
}
window.postMessage(
{
type: "AUTISTMASK_EVENT",
eventName: msg.eventName,
data: msg.data,
data,
},
"*",
);
}
});
+61 -21
View File
@@ -634,17 +634,12 @@
</div>
</div>
<div id="confirm-warnings" class="mb-2 invisible"></div>
<div id="confirm-recipient-warning" class="mb-2 invisible">
<!-- These two sentences name the network's native token, so
show() in confirmTx.js sets them. -->
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient address has ZERO transaction
history. This may indicate a fresh or unused address.
Double-check the address before sending.
</div>
</div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
id="confirm-recipient-warning"
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
></div>
<div
id="confirm-contract-warning"
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
@@ -669,6 +664,20 @@
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
></div>
<!-- Its sentence names the token, or why the token's balance
is unknown, so show() in confirmTx.js sets it. -->
<div
id="confirm-balance-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<div
id="confirm-balance-read-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
Your balance could not be read from the network, so this
transaction cannot be checked against it. Please go back and
try again.
</div>
<div
id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
@@ -688,6 +697,33 @@
id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<!-- Shown when Sign & Send refuses a send for its network, for
a From address the selected wallet does not hold, or for its
gas limit or fee. Each holds its space from the first paint, so
the refusal moves nothing. -->
<div
id="confirm-network-changed-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The active network changed after this transaction was
reviewed, so it was not sent. Please go back and review it
again.
</div>
<div
id="confirm-from-address-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The selected wallet does not hold the From address above, so
nothing was sent. Please go back and send again.
</div>
<div
id="confirm-fee-bound-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The gas limit or fee the RPC endpoint quoted for this
transaction is more than this wallet will sign for, so it
was not sent. Please go back and try again.
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
@@ -843,7 +879,7 @@
id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
></div>
<div class="mb-2">
<div id="add-token-common" class="mb-2">
<label class="block mb-1 text-xs text-muted"
>Or pick a common token:</label
>
@@ -929,9 +965,9 @@
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Network</h3>
<p class="text-xs text-muted mb-1">
Select the Ethereum network. Switching networks will
update the RPC and Blockscout endpoints to their
defaults.
Select the Ethereum network. Switching restores the RPC
and Blockscout endpoints last used on that network, or
that network's defaults if it has none.
</p>
<div class="text-xs flex items-center gap-1">
<select
@@ -1128,7 +1164,8 @@
<p class="text-xs mb-3">
Deleting
<strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your recovery phrase.
funds will be unrecoverable without your
<span id="delete-wallet-key-material"></span>.
</p>
<div class="mb-2">
<label class="block mb-1">Password</label>
@@ -1179,13 +1216,13 @@
money at its addresses is not moved or destroyed.
</p>
<p class="text-xs mb-2">
If you have the recovery phrase for this wallet written
down, add the wallet again afterwards with a new password
and you will have it back.
If you have kept a copy of this wallet's
<span id="delete-wallet-lost-key-material"></span>, add the
wallet again afterwards with a new password and you will
have it back.
<strong
>If you do not have it written down, deleting this
wallet means losing everything it holds,
forever.</strong
>If you have not, deleting this wallet means losing
everything it holds, forever.</strong
>
</p>
<p class="text-xs mb-3">Your other wallets are not touched.</p>
@@ -1326,8 +1363,10 @@
<h2 class="font-bold mb-2">Add Token</h2>
<p class="text-xs text-muted mb-3">
Pick a common token or enter a contract address manually.
Common tokens are offered on Ethereum Mainnet only.
</p>
<div id="settings-addtoken-common">
<!-- top 10 quick-pick buttons -->
<div class="mb-3">
<label class="block mb-1 text-xs text-muted"
@@ -1357,6 +1396,7 @@
Add selected
</button>
</div>
</div>
<!-- manual contract address -->
<div class="mb-3">
+59 -6
View File
@@ -21,6 +21,12 @@ const {
goBack,
} = require("./views/helpers");
const { applyTheme } = require("./theme");
const {
tabsGetCurrent,
tabsGetZoom,
windowsGetCurrent,
windowsUpdate,
} = require("../shared/browserApi");
// Renders a view the popup lands on without having navigated to it forward:
// on restore here, and on Back. Only the views that can be fully re-rendered
// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go
@@ -58,8 +64,8 @@ window.addEventListener("pagehide", () => pageClosed.abort());
let refreshInFlight = false;
// The ten-second refresh init() starts, stopped when the popup moves to the
// recovery screen: there is no profile left to refresh.
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
// the recovery screen: there is no profile left to refresh.
let refreshTimer = null;
async function doRefreshAndRender() {
@@ -93,9 +99,23 @@ async function doRefreshAndRender() {
}
}
// Refreshes now and every ten seconds from then on. Called once a wallet
// exists: by init() when the popup opens with one, and by AddWallet when it
// adds one, which in a popup that opened on Welcome is the first
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the refresh already
// running it starts no second one, and on the recovery screen it does nothing.
function startRefresh() {
if (!$("view-state-recovery").classList.contains("hidden")) return;
doRefreshAndRender();
if (refreshTimer === null) {
refreshTimer = setInterval(doRefreshAndRender, 10000);
}
}
const ctx = {
renderWalletList,
doRefreshAndRender,
startRefresh,
pageClosed: pageClosed.signal,
showAddWalletView: () => {
pushCurrentView();
@@ -163,6 +183,40 @@ function fallbackView() {
showView("main");
}
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is
// up, the window is widened by however far the page would scroll sideways.
// That distance is in page pixels and the window's width in screen pixels:
// at 150% zoom one page pixel is 1.5 screen pixels. The zoom is read for this
// page's own tab, because asked without a tab id Firefox answers for the
// window focused last. Firefox draws some zoom steps a little larger than the
// zoom it reports, 170% among them, so once the page has its new width (its
// resize event) it is measured again, and the window widened again while the
// page still falls short, three times at most. A window that cannot grow sends
// no resize event, and the page stops there. The connection prompt can also
// show in the toolbar popup, whose window is the user's browser window; that
// one is left alone.
async function widenApprovalWindow() {
const page = document.documentElement;
if (page.scrollWidth <= page.clientWidth) return;
const win = await windowsGetCurrent();
if (win.type !== "popup") return;
const tab = await tabsGetCurrent();
const zoom = await tabsGetZoom(tab.id);
let width = win.width;
for (let tries = 0; tries < 3; tries++) {
const sideways = page.scrollWidth - page.clientWidth;
if (sideways <= 0) return;
width += Math.ceil(sideways * zoom);
const resized = new Promise((resolve) =>
window.addEventListener("resize", resolve, { once: true }),
);
await windowsUpdate(win.id, { width });
await resized;
}
}
async function init() {
// First, before anything can save: showView() saves on every navigation
// without awaiting, so a save that fails from here on has somewhere to be
@@ -239,8 +293,8 @@ async function init() {
// the run on both harnesses (Playwright `pageerror`, and the Firefox
// driver's console-service drain), so nothing is lost by leaving it
// on that path. show() puts the approval's own screen up once the
// background has described it.
approval.show(approvalId);
// background has described it; the window is widened to fit it then.
approval.show(approvalId).then(widenApprovalWindow);
return;
}
@@ -278,8 +332,7 @@ async function init() {
} else {
renderWalletList();
restoreView();
doRefreshAndRender();
refreshTimer = setInterval(doRefreshAndRender, 10000);
startRefresh();
}
}
+17 -8
View File
@@ -13,6 +13,7 @@
// cannot be required outside a browser.
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
const { SUPPORTED_CHAIN_IDS } = require("../shared/networks");
// The views this page load has rendered.
//
@@ -37,9 +38,11 @@ function markViewRendered(view) {
if (view) renderedViews.add(view);
}
// Begin a fresh page-load scope. The popup gets one by being loaded; the
// Begin a fresh page-load scope. The popup gets one by being loaded, and
// asks for one when the user switches the network, because every screen
// then on the page shows the previous network (views/settings.js); the
// unit tests, which simulate several page loads against one module
// instance, ask for one.
// instance, ask for one too.
function resetRenderedViews() {
renderedViews.clear();
}
@@ -124,14 +127,19 @@ function isRenderableDecoded(value) {
}
// The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings().
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
// truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in
// when it is absent, picks the network whose explorer the address links open,
// so it has to name one.
function isRenderablePendingTx(value) {
return (
isRecord(value) &&
isText(value.token) &&
isAddressText(value.from) &&
isAddressText(value.to)
isAddressText(value.to) &&
isText(value.amount) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
);
}
@@ -213,9 +221,10 @@ function renderView(view, state, views) {
// in the two cases where the view is known to be on the page already:
//
// - It is not one the popup renders from persisted state. The restored
// stack is filtered against RESTORABLE_VIEWS, so such a view can only
// be on the stack from this page load, where forward navigation
// rendered it on the way in.
// stack is filtered against RESTORABLE_VIEWS, and a network switch cuts
// it the same way (views/settings.js), so such a view can only be on the
// stack from this page load, where forward navigation rendered it on the
// way in.
// - This page load has rendered it. Re-rendering would re-fetch and
// clobber what it holds; Home is rendered anyway, see above.
//
+19 -1
View File
@@ -1,6 +1,6 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
@@ -9,6 +9,12 @@ function show() {
$("add-token-address").value = "";
$("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden";
// The bundled list holds Ethereum Mainnet contracts, which name no token
// on any other network.
$("add-token-common").classList.toggle(
"hidden",
state.networkId !== "mainnet",
);
const list = $("common-token-list");
list.innerHTML = getTopTokens(25)
.map(
@@ -46,6 +52,9 @@ function init(ctx) {
infoEl.textContent = "Looking up token...";
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", contractAddr);
// The network the lookup asks, which Settings may switch away from
// before it answers, as for a transaction in confirmTx.js.
const chainId = currentNetwork().chainId;
try {
const info = await lookupTokenInfo(
contractAddr,
@@ -53,6 +62,15 @@ function init(ctx) {
state.networkId,
ctx.pageClosed,
);
// A contract address names a token on one network only, so an
// answer after a switch is not added to the new network's list.
if (currentNetwork().chainId !== chainId) {
showFlash(
"The active network changed before the lookup finished," +
" so the token was not added.",
);
return;
}
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
address: contractAddr,
+28 -3
View File
@@ -20,6 +20,7 @@ const {
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
const { notify } = require("../../shared/browserApi");
/**
* Check if an address already exists in ANY wallet (hd, xprv, or key).
@@ -194,6 +195,9 @@ async function importMnemonic(ctx) {
};
state.wallets.push(wallet);
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
}
await saveState();
clearViewStack();
ctx.renderWalletList();
@@ -215,14 +219,22 @@ async function importMnemonic(ctx) {
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
// The new list leaves out the first address when the scan found it
// unused. If it was the active one, the new list's first takes over,
// and the background is told as when the user picks one on Home.
const activeMoved =
state.activeAddress === firstAddress &&
wallet.addresses[0].address !== firstAddress;
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
await saveState();
ctx.renderWalletList();
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
ctx.startRefresh();
}
async function importPrivateKey(ctx) {
@@ -254,12 +266,15 @@ async function importPrivateKey(ctx) {
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
});
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = addr;
}
await saveState();
clearViewStack();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
ctx.startRefresh();
}
async function importXprvKey(ctx) {
@@ -315,6 +330,9 @@ async function importXprvKey(ctx) {
};
state.wallets.push(wallet);
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
}
await saveState();
clearViewStack();
ctx.renderWalletList();
@@ -336,14 +354,21 @@ async function importXprvKey(ctx) {
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
// As for a recovery phrase: an active first address the scan left
// out gives way to the new list's first.
const activeMoved =
state.activeAddress === firstAddress &&
wallet.addresses[0].address !== firstAddress;
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
await saveState();
ctx.renderWalletList();
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
ctx.startRefresh();
}
function init(ctx) {
+19 -5
View File
@@ -79,9 +79,10 @@ function tokenAmountText(rawAmount, decimals, symbol) {
};
}
// Try to decode calldata using known ABIs.
// Try to decode calldata sent from `fromAddress` to `toAddress` on the network
// with chain id `chainId`, using known ABIs.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) {
function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Where a token's scale is looked for, for every decoder below: the ERC-20
@@ -185,7 +186,13 @@ function decodeCalldata(data, toAddress) {
}
// Try Uniswap Universal Router
const routerResult = uniswap.decode(data, toAddress, decimalsSources);
const routerResult = uniswap.decode(
data,
toAddress,
chainId,
decimalsSources,
fromAddress,
);
if (routerResult) return routerResult;
return null;
@@ -269,8 +276,15 @@ function showTxApproval(details) {
chainId: approvedTx.chainId,
};
// If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(approvedTx.data, toAddr || "");
// If this is an ERC-20 call, try to extract the real recipient and amount.
// Decoded for the network the transaction is for, which the Network line
// names, and the address the From line names.
const decoded = decodeCalldata(
approvedTx.data,
toAddr || "",
approvedTx.chainId,
details.approvedFrom,
);
if (decoded && decoded.details) {
// 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
+265 -145
View File
@@ -2,7 +2,13 @@
// Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts.
const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const {
$,
showError,
@@ -11,7 +17,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
blockieHtml,
attachCopyHandlers,
@@ -20,6 +25,10 @@ const {
formatFee,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
@@ -32,11 +41,12 @@ const {
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const {
displayedDecimals,
toDecimals,
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The balance lines, the fee reserve and the insufficient-balance messages go
// through it, as the approval screen's amounts do.
// The amount, the balance line and the fee reserve go through it, as the
// approval screen's amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
@@ -60,6 +70,13 @@ let feeWei = null;
// amount was derived from. Null for any other send, which ethers prices from
// the node at signing time.
let maxSendFees = null;
// The From address's balances on the network this screen is drawn for, read
// from that network's node by readBalances() each time the screen is shown,
// so a screen the popup reopens onto states them as they are then, not as
// Send knew them. Null until the read returns; then `eth` and `token` are
// decimal strings, `token` null for an ETH send or a token whose scale is
// unknown, and both null if the read failed.
let balances = null;
function restore() {
const d = state.viewData;
@@ -68,11 +85,11 @@ function restore() {
}
}
function confirmAddressHtml(address, ensName, title) {
function confirmAddressHtml(address, ensName, title, chainId) {
const blockie = blockieHtml(address);
return (
`<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName })
renderAddressHtml(address, { title, ensName, chainId })
);
}
@@ -83,34 +100,96 @@ function valueWithUsd(text, usdAmount) {
return text;
}
// The Amount line, with its USD value. A max ETH send's line is drawn again
// once its amount is re-derived from the fee estimate.
// The amount as this screen and the wait, success and error screens show it,
// written as the approval screen writes a site's amount: the amount being
// signed, parsed at the scale it is encoded with and written back as plain
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
// `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is
// unknown, which cannot be sent: its amount is cut as typed, so `.5` reads
// `.5000`. txInfo.amount, which is what gets signed, is left as it is.
function shownAmount(txInfo) {
let exact = txInfo.amount;
try {
if (txInfo.token === "ETH") {
exact = formatEther(parseEther(txInfo.amount));
} else {
const decimals = displayedDecimals(txInfo.tokenDecimals);
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
}
} catch {
// A token whose scale is unknown, which is never signed, or an amount
// the amount check refuses: cut as Send's amount field held it.
}
return truncateAmountNeverZero(exact);
}
// The Amount line, with its USD value, which is priced from the exact amount.
// A max ETH send's line is drawn again once its amount is re-derived from the
// fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol),
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
amountUsd,
);
}
// The balance line, with its USD value: "Loading..." until readBalances()
// returns, then the balance it read.
function renderBalance(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const symbol = displaySymbol(rawSymbol);
if (balances === null) {
$("confirm-balance").textContent = "Loading...";
return;
}
// A null balance is one nobody established, for a token whose scale is
// unknown or after a read that failed, and is stated as unknown, never as
// zero (https://git.eeqj.de/sneak/AutistMask/issues/349).
const bal = isErc20 ? balances.token : balances.eth;
if (bal === null) {
$("confirm-balance").textContent = "unknown (" + symbol + ")";
return;
}
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
function show(txInfo) {
pendingTx = txInfo;
feeStatus = FEE_PENDING;
feeWei = null;
maxSendFees = null;
// The network this screen is drawn for, and the only one it sends on:
// Settings can switch the active network while the screen is open. The
// wait, success and error screens label the amount by it too.
txInfo.chainId = currentNetwork().chainId;
balances = null;
// The network this screen is drawn for, and the only one it sends on: the
// one active when Send handed the transaction over. Drawn again from the
// saved transaction, as when the popup reopens onto this screen, it keeps
// that network, so Sign & Send refuses once Settings has switched to
// another. Everything this screen shows is that network's: native
// currency, USD values, explorer links, the balance, and the node and
// explorer its checks ask. The wait, success and error screens label
// amounts by it.
if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const rawSymbol = isErc20
? txInfo.tokenSymbol || "?"
: nativeCurrencyByChainId(txInfo.chainId);
const symbol = displaySymbol(rawSymbol);
// Transaction type
@@ -126,7 +205,7 @@ function show(txInfo) {
if (isErc20) {
$("confirm-token-contract").innerHTML = renderAddressHtml(
txInfo.token,
{},
{ chainId: txInfo.chainId },
);
tokenSection.classList.remove("hidden");
attachCopyHandlers(tokenSection);
@@ -140,6 +219,7 @@ function show(txInfo) {
txInfo.from,
null,
fromTitle,
txInfo.chainId,
);
// To (with blockie)
@@ -148,37 +228,12 @@ function show(txInfo) {
txInfo.to,
txInfo.ensName,
toTitle,
txInfo.chainId,
);
$("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol);
if (isErc20) {
// null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
// refused at encode time for the same missing scale; what this line
// must not do is state a quantity nobody established.
const bal = txInfo.tokenBalance;
const balUsd =
tokenPrice && bal != null ? parseFloat(bal) * tokenPrice : null;
$("confirm-balance").textContent =
bal == null
? "unknown (" + symbol + ")"
: valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
} else {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
renderBalance(txInfo);
// Check for warnings (synchronous local checks)
const localWarnings = getLocalWarnings(txInfo.to, {
@@ -212,15 +267,22 @@ function show(txInfo) {
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
$("confirm-gas-error").textContent =
"You do not have enough " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" to pay the network fee for this transfer. Please add " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" to this address and try again.";
// Shown later, once checkRecipientHistory() finds a contract.
// Both shown later, by checkRecipientHistory(). The first sentence says
// exactly what getFullWarnings() checks.
$("confirm-recipient-warning").textContent =
"WARNING: The recipient address has never sent a transaction and" +
" holds no " +
nativeCurrencyByChainId(txInfo.chainId) +
". This may indicate a fresh or unused address. Double-check the" +
" address before sending.";
$("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" or tokens directly to a contract may result in permanent loss of" +
" funds.";
@@ -237,11 +299,29 @@ function show(txInfo) {
" cannot be checked against your balance. Please go back and" +
" try again.";
// So does the message for an amount the balance does not cover. It is
// set here, before the first paint, so the balance read returning only
// makes it visible and moves nothing, which is why it quotes no balance.
// Without the token's scale readBalances() reads no token balance.
$("confirm-balance-error").textContent =
isErc20 && toDecimals(txInfo.tokenDecimals) === null
? "This token's balance is unknown, because nothing this wallet" +
" can consult reports how many decimal places it uses, so the" +
" amount you are trying to send cannot be checked against it."
: "Your " +
symbol +
" balance does not cover this amount. Please go back and send" +
" a smaller amount.";
renderValidation(txInfo);
// Reset password field and error
// Reset password field and error, and any refusal of an earlier Sign &
// Send.
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
// Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible";
@@ -262,68 +342,78 @@ function show(txInfo) {
$("confirm-burn-warning").style.visibility = "visible";
}
estimateGas(txInfo);
// The fee is estimated once the balances are read, because a max ETH
// send's amount is the ETH balance minus the estimate's reserve.
readBalances(txInfo).then(() => estimateGas(txInfo));
checkRecipientHistory(txInfo);
}
// Read the From address's balances from the node of the network this
// transaction was reviewed for: ETH, and for a token send the token, at the
// scale the screen renders the amount with. A token whose scale is unknown has
// no balance to read. Never rejects.
async function readBalances(txInfo) {
let read;
try {
const provider = reviewedNetworkProvider(txInfo);
const decimals = toDecimals(txInfo.tokenDecimals);
const readToken = txInfo.token !== "ETH" && decimals !== null;
const [ethWei, tokenUnits] = await Promise.all([
provider.getBalance(txInfo.from),
readToken
? new Contract(txInfo.token, ERC20_ABI, provider).balanceOf(
txInfo.from,
)
: null,
]);
read = {
eth: formatEther(ethWei),
token: readToken ? formatUnits(tokenUnits, decimals) : null,
};
} catch (e) {
log.errorf("balance read failed:", e.shortMessage || e.message);
read = { eth: null, token: null };
}
// The user may have left this transaction while the read was in flight.
if (pendingTx !== txInfo) return;
balances = read;
renderBalance(txInfo);
renderValidation(txInfo);
}
// Render the balance check for the transaction on screen. Called once during
// show() and again when the fee estimate resolves or fails. Every element it
// touches already occupies its space, so re-running it never moves anything.
// show(), again when the balances are read, and again when the fee estimate
// resolves or fails. Every element it touches already occupies its space, so
// re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency();
// Null while the balances are being read and after a read that failed.
// validateTransfer() takes a missing balance as nothing to spend from, so
// sending stays blocked either way, and the only message shown for it is
// the one saying the read failed.
const ethBalance = balances ? balances.eth : null;
const tokenBalance = balances ? balances.token : null;
const balanceRead = ethBalance !== null;
const { canSend, codes } = validateTransfer({
isErc20,
amount: txInfo.amount,
ethBalance: txInfo.balance,
tokenBalance: txInfo.tokenBalance,
tokenDecimals: txInfo.tokenDecimals,
ethBalance,
tokenBalance,
feeStatus,
feeWei,
});
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html, except the
// gas and fee-unknown ones, which show() sets.
// An invalid amount is known before the first paint, so its message is
// written here. Every message a later result brings has a reserved
// element, its sentence fixed in index.html or set by show(), and is only
// made visible below, so nothing moves when the result arrives.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
}
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
messages.push(
txInfo.tokenBalance == null
? "This token's balance is unknown, because nothing this" +
" wallet can consult reports how many decimal places it" +
" uses, so the amount you are trying to send cannot be" +
" checked against it."
: "Insufficient " +
symbol +
" balance. You have " +
truncateAmountNeverZero(txInfo.tokenBalance) +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
const errorsEl = $("confirm-errors");
if (messages.length > 0) {
@@ -336,13 +426,20 @@ function renderValidation(txInfo) {
errorsEl.style.visibility = "hidden";
}
setVisible("confirm-balance-read-error", balances !== null && !balanceRead);
setVisible(
"confirm-balance-error",
balanceRead &&
(codes.includes(CODES.INSUFFICIENT_TOKEN) ||
codes.includes(CODES.INSUFFICIENT_ETH)),
);
setVisible(
"confirm-amount-fee-error",
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
);
setVisible(
"confirm-gas-error",
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
);
setVisible(
"confirm-fee-unknown-error",
@@ -361,9 +458,23 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// The node of the network this transaction was reviewed for, which need not be
// the active one: the popup can reopen onto this screen after Settings switched
// the network. Its RPC URL is the one Settings has for that network: the
// active network's is state.rpcUrl, any other's the one remembered for it (see
// applyChainSwitchFields()).
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = reviewedNetworkProvider(txInfo);
const feeData = await provider.getFeeData();
let gasLimit;
@@ -411,12 +522,17 @@ async function estimateGas(txInfo) {
// does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent =
"~" + formatFee(estimateWei, nativeCurrency());
"~" +
formatFee(
estimateWei,
nativeCurrencyByChainId(txInfo.chainId),
txInfo.chainId,
);
$("confirm-fee-reserve").textContent =
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" " +
nativeCurrency() +
nativeCurrencyByChainId(txInfo.chainId) +
" reserved";
setVisible("confirm-fee-reserve", true);
} else {
@@ -425,20 +541,21 @@ async function estimateGas(txInfo) {
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = formatFee(
gasCostWei,
nativeCurrency(),
nativeCurrencyByChainId(txInfo.chainId),
txInfo.chainId,
);
setVisible("confirm-fee-reserve", false);
}
feeStatus = FEE_KNOWN;
feeWei = gasCostWei;
// A max ETH send is the balance minus this estimate's reserve, not the
// Send screen's, and is signed with this estimate's fee fields: fees
// fetched again at signing could exceed the reserve it leaves, and the
// node would refuse it for want of funds. Where the balance no longer
// covers the fee, the amount is left as it is and the balance check
// below says so.
// A max ETH send is the balance readBalances() read minus this
// estimate's reserve, not the Send screen's, and is signed with this
// estimate's fee fields: fees fetched again at signing could exceed
// the reserve it leaves, and the node would refuse it for want of
// funds. Where the balance is unknown or no longer covers the fee, the
// amount is left as it is and the balance check below says so.
if (txInfo.max && txInfo.token === "ETH") {
const amount = maxEthAmount(txInfo.balance, gasCostWei);
const amount = maxEthAmount(balances?.eth, gasCostWei);
if (amount !== null) {
txInfo.amount = amount;
renderAmount(txInfo);
@@ -467,16 +584,17 @@ 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. Apart
// from a max ETH send, which passes its estimate's fee fields as `fees`, 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.
// Populate the transaction this send describes, enforce the bounds against the
// gas limit and fees that were actually filled in, then sign and broadcast it.
// Apart from a max ETH send, which passes its estimate's fee fields as `fees`,
// 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 gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
// over its bound, and the caller then shows the one refusal reserved for all
// three rather than sending.
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request;
if (tx.token === "ETH") {
@@ -504,21 +622,16 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
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);
const provider = reviewedNetworkProvider(txInfo);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
chainId: txInfo.chainId,
});
// The user may have left this transaction while the checks were in
// flight; its warnings must not reach another recipient's screen.
if (pendingTx !== txInfo) return;
for (const w of asyncWarnings) {
if (w.type === "contract") {
$("confirm-contract-warning").style.visibility = "visible";
@@ -547,6 +660,11 @@ function init(_ctx) {
onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => {
// A press shows only its own refusal, if any.
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
const wallet = state.wallets[state.selectedWallet];
// Every Send button refuses a defective wallet before this screen,
// but the popup also reopens onto it from a saved view.
@@ -556,14 +674,12 @@ function init(_ctx) {
return;
}
// Back from Settings only unhides this screen, still drawn for the
// network that was active when it was shown.
// The screen describes the network the transaction was reviewed for,
// which Settings may have switched away from since: "Back" from
// Settings after a switch draws this screen again from the saved
// transaction, as a popup reopened onto it does.
if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
setVisible("confirm-network-changed-error", true);
return;
}
@@ -572,11 +688,7 @@ function init(_ctx) {
// address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) {
showError(
"confirm-tx-password-error",
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
setVisible("confirm-from-address-error", true);
return;
}
@@ -608,6 +720,10 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
// The wait, success and error screens show the amount as this screen
// does, so they are handed a copy carrying that text. pendingTx keeps
// the exact amount, which is the one signed.
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
let tx;
try {
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
@@ -624,19 +740,23 @@ function init(_ctx) {
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decryptedSecret = null;
txStatus.showWait(pendingTx, tx.hash);
txStatus.showWait(shownTx, 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.
// A gas limit or fee over its bound is refused before anything is
// broadcast, so there is no transaction that may have reached the
// network to warn about: the refusal stays on the confirmation
// screen where the user can go back, rather than routing to the
// sent/failed screen. Its sentence is fixed in index.html, so that
// its box can hold its space from the first paint; the reason in
// full, naming the bound, is logged.
if (e && e.approvalMismatch) {
showSendError(e.message);
log.errorf("send refused:", e.message);
setVisible("confirm-fee-bound-error", true);
return;
}
const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
txStatus.showError(shownTx, hash, e.shortMessage || e.message);
} finally {
$("btn-confirm-send").disabled = false;
$("btn-confirm-send").classList.remove("text-muted");
+2 -4
View File
@@ -17,9 +17,9 @@ const {
attachCopyHandlers,
addressHoldsFunds,
balanceLinesForAddress,
keyMaterialName,
} = require("./helpers");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { state, saveState } = require("../../shared/state");
const {
canRemoveAddress,
@@ -54,9 +54,7 @@ function setFlash(msg) {
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and
// this screen is offered on xprv wallets too.
function recoveryPathText(wallet) {
const secret = walletHasRecoveryPhrase(wallet)
? "recovery phrase"
: "extended private key";
const secret = keyMaterialName(wallet);
return (
"Getting the address back into this list is not easy, so be sure. " +
"Adding an address derives the next unused one, not this one, and " +
+7
View File
@@ -7,6 +7,7 @@ const {
goBack,
clearViewStack,
onViewLeave,
keyMaterialName,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
@@ -75,6 +76,9 @@ function show(walletIdx) {
clear();
deleteWalletIndex = walletIdx;
$("delete-wallet-name").textContent = displayName(walletIdx);
$("delete-wallet-key-material").textContent = keyMaterialName(
state.wallets[walletIdx],
);
showView("delete-wallet-confirm");
}
@@ -94,6 +98,9 @@ function showLostPassword() {
clearLostPassword();
$("delete-wallet-lost-name").textContent = name;
$("delete-wallet-lost-name-echo").textContent = name;
$("delete-wallet-lost-key-material").textContent = keyMaterialName(
state.wallets[walletIdx],
);
// showView() runs the leave hook of delete-wallet-confirm, which nulls
// deleteWalletIndex, so this screen's own selection is recorded after
// it and not before.
+39 -20
View File
@@ -22,9 +22,11 @@ const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { markViewRendered } = require("../viewRouter");
// When views are added, removed, or transitions between them change,
@@ -283,11 +285,12 @@ function unknownableAmount(balance) {
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
// on Sepolia, as src/shared/networks.js names it. The wallet's balances and
// the Send and confirmation screens, which send on the active network, label a
// native amount with this; a transaction already made or requested is labelled
// by its own chain id, through nativeCurrencyByChainId() in networks.js. The
// "ETH" that state.selectedToken and txInfo.token hold is the native token's
// id, not its label, and stays "ETH" on every network.
// the Send screen, which sends on the active network, label a native amount
// with this; the confirmation screen, and a transaction already made or
// requested, label it by the transaction's own chain id, through
// nativeCurrencyByChainId() in networks.js. The "ETH" that state.selectedToken
// and txInfo.token hold is the native token's id, not its label, and stays
// "ETH" on every network.
function nativeCurrency() {
return currentNetwork().nativeCurrency;
}
@@ -306,11 +309,12 @@ function tokenLabel(address) {
// A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// native currency of the network the fee is paid on, then its USD value when
// the ETH price is known. The USD value is of the exact fee, not of the
// truncated figure.
function formatFee(wei, symbol) {
// the ETH price is known on the network with `chainId`, or on the active
// network when no chain id is given. The USD value is of the exact fee, not of
// the truncated figure.
function formatFee(wei, symbol, chainId) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH");
const ethPrice = getPrice("ETH", chainId);
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return (
truncateAmountNeverZero(eth) +
@@ -418,6 +422,16 @@ function addressHoldsFunds(addr) {
return false;
}
// The name of a wallet's key material, which is also its only backup: the
// recovery phrase of an HD wallet, the private key of a key wallet, the
// extended private key of an xprv wallet. DeleteWallet,
// DeleteWalletLostPassword and DeleteAddress name the backup with it.
function keyMaterialName(wallet) {
if (walletHasRecoveryPhrase(wallet)) return "recovery phrase";
if (wallet.type === "key") return "private key";
return "extended private key";
}
// 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
@@ -593,17 +607,19 @@ const EXT_ICON =
`</svg></span>`;
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js; only the path segment is data, and it comes out
// of explorer JSON (a transaction's from/to, a token's address_hash), which
// nothing upstream validates as hex. percent-encoding it keeps a segment
// that contains a slash, a query or a fragment from re-pointing the link
// somewhere else in the explorer.
function explorerUrl(kind, value) {
return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
// src/shared/networks.js, the explorer of the network with `chainId`, or of
// the active network when no chain id is given; only the path segment is
// data, and it comes out of explorer JSON (a transaction's from/to, a token's
// address_hash), which nothing upstream validates as hex. percent-encoding it
// keeps a segment that contains a slash, a query or a fragment from
// re-pointing the link somewhere else in the explorer.
function explorerUrl(kind, value, chainId) {
const network = chainId ? networkByChainId(chainId) : currentNetwork();
return `${network.explorerUrl}/${kind}/${encodeURIComponent(value)}`;
}
function etherscanAddressUrl(address) {
return explorerUrl("address", address);
function etherscanAddressUrl(address, chainId) {
return explorerUrl("address", address, chainId);
}
// The URL still has to be escaped on the way into href="...": encoding
@@ -669,16 +685,18 @@ function attachCopyHandlers(container) {
// 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
// chainId — the network whose explorer the link opens; the active
// network's when absent
//
// After inserting the returned HTML into the DOM, call
// attachCopyHandlers() on the parent to wire up click-to-copy.
function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {};
const { title, ensName, maxLen, noLink, chainId } = opts || {};
const dot = addressDotHtml(address);
const displayAddr = maxLen
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
: address;
const link = etherscanAddressUrl(address);
const link = etherscanAddressUrl(address, chainId);
const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = "";
@@ -725,6 +743,7 @@ module.exports = {
balanceLine,
balanceLinesForAddress,
addressHoldsFunds,
keyMaterialName,
unknownableAmount,
nativeCurrency,
tokenLabel,
+34 -32
View File
@@ -24,6 +24,7 @@ const {
} = require("../../shared/amountDisplay");
const {
feeReserveWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
} = require("../../shared/txValidation");
@@ -163,8 +164,9 @@ function renderSendTokenSelect(addr) {
}
}
// The token balance and scale the Send screen states and hands the
// confirmation screen, so the two screens describe the holding the same way.
// The token balance the Send screen states, and the scale it hands the
// confirmation screen, which reads the balance itself from the network it is
// drawn for.
//
// The scale is resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's tracked
@@ -187,13 +189,11 @@ function renderSendTokenSelect(addr) {
//
// The two resolutions can therefore differ, and where they do, the stored
// `balance` is a quantity computed at a scale this screen has just declined to
// stand behind. Stating it would leave validateTransfer() checking the amount
// against a number the wallet does not vouch for, so it is withdrawn: unknown
// scale means unknown balance. It is null rather than "0": both screens state
// an unknown balance as unknown, and validateTransfer() treats it as no balance
// to spend from, which is the fail-closed side of an amount nobody can check.
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
// all is an absence of holdings, which is true at every scale.
// stand behind, so it is withdrawn: unknown scale means unknown balance. It is
// null rather than "0": this screen states it as unknown, as the confirmation
// screen does, and Max fills nothing in for it. Only a stored quantity is
// withdrawn: the "0" for a token that has no row at all is an absence of
// holdings, which is true at every scale.
function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -255,13 +255,18 @@ async function fillMaxAmount() {
const token = state.selectedToken || $("send-token").value;
if (token !== "ETH") {
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
const { tokenBalance: bal, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
);
if (bal == null) {
showFlash("This token's balance is unknown.");
return;
}
const amount = maxTokenAmount(bal);
if (!(parseFloat(amount) > 0)) {
// The balance is stored at the token's own decimal places and cut to
// 18 above, so the only balance this refuses is zero.
if (!isValidAmount(amount, tokenDecimals)) {
showFlash("This token's balance is zero.");
return;
}
@@ -367,7 +372,24 @@ function init(_ctx) {
);
return;
}
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
const token = state.selectedToken || $("send-token").value;
const addr = currentAddress();
let tokenSymbol = null;
let tokenDecimals = null;
if (token !== "ETH") {
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
}
// The confirmation screen's own amount check, so it is never shown
// an amount it would refuse.
if (!isValidAmount(amount, tokenDecimals)) {
showFlash("Please enter a valid amount.");
return;
}
@@ -391,33 +413,13 @@ function init(_ctx) {
}
}
const token = state.selectedToken || $("send-token").value;
const addr = currentAddress();
let tokenSymbol = null;
let tokenBalance = null;
let tokenDecimals = null;
if (token !== "ETH") {
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
));
}
ctx.showConfirmTx({
from: addr.address,
to: resolvedTo,
ensName: ensName,
amount: amount,
token: token,
balance: addr.balance,
tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals,
max: amountIsMax,
});
+29 -4
View File
@@ -16,6 +16,8 @@ const {
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch");
const { restorableStack } = require("../../shared/persistedState");
const { resetRenderedViews, markViewRendered } = require("../viewRouter");
const {
log,
debugFetch,
@@ -76,11 +78,13 @@ function forgetOrigin(siteMap, origin) {
// 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.
// connections approved without "Remember" and tells its open tabs. The
// background answers once those have ended, so the lists drawn after it no
// longer show the site.
async function removeAllowedSite(origin) {
forgetOrigin(state.allowedSites, origin);
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
await sendMessage({ type: "AUTISTMASK_REMOVE_SITE", origin });
await renderSiteLists();
}
@@ -230,8 +234,8 @@ async function renderSiteLists() {
Object.values(state.deniedSites).flat(),
removeDeniedSite,
);
// Sites allowed without "Remember" are held only by the background, in
// memory, so it is asked for them.
// Sites allowed without "Remember" are held only in session storage, which
// the background keeps, so it is asked for them.
renderSiteList(
"settings-connected-sites",
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
@@ -317,12 +321,33 @@ function init(ctx) {
networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value;
if (newId === state.networkId) return;
// The screens beneath Settings show the previous network, so Back
// finds them as it does in a reopened popup: each is rendered again
// when Back lands on it, and the stack is cut at the first one that
// cannot be rendered from saved state, such as Send or AddToken. It
// is cut at AddressToken too, unless that shows the native token: any
// other token there was picked on the previous network. Without a
// wallet the stack starts at Welcome, which that cut would drop, and
// Welcome shows nothing from a network. The cut comes before the
// switch so that the switch's save keeps it for a popup closed
// straight after.
if (state.hasWallet) {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1 && state.selectedToken !== "ETH") {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.viewStack = restorableStack(state.viewStack, "settings");
}
const net = await onChainSwitch(newId);
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
resetRenderedViews();
// Settings itself is brought up to date for the new network here.
markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
showFlash("Switched to " + net.name + ".");
});
+19 -1
View File
@@ -1,6 +1,6 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
@@ -75,6 +75,12 @@ function show() {
$("settings-addtoken-address").value = "";
$("settings-addtoken-info").textContent = "";
$("settings-addtoken-info").style.visibility = "hidden";
// The bundled list holds Ethereum Mainnet contracts, which name no token
// on any other network.
$("settings-addtoken-common").classList.toggle(
"hidden",
state.networkId !== "mainnet",
);
renderTop10();
renderDropdown();
showView("settings-addtoken");
@@ -130,6 +136,9 @@ function init(_ctx) {
infoEl.textContent = "Looking up token...";
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", addr);
// The network the lookup asks, which Settings may switch away from
// before it answers, as for a transaction in confirmTx.js.
const chainId = currentNetwork().chainId;
try {
const info = await lookupTokenInfo(
addr,
@@ -137,6 +146,15 @@ function init(_ctx) {
state.networkId,
ctx.pageClosed,
);
// A contract address names a token on one network only, so an
// answer after a switch is not added to the new network's list.
if (currentNetwork().chainId !== chainId) {
showFlash(
"The active network changed before the lookup finished," +
" so the token was not added.",
);
return;
}
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
address: addr,
+9 -2
View File
@@ -21,7 +21,7 @@ const {
displaySymbol,
goBack,
} = require("./helpers");
const { state } = require("../../shared/state");
const { state, currentNetwork } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log");
@@ -316,7 +316,14 @@ async function loadFullTxDetails(txHash, chainId) {
// Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract.
const decoded = decodeCalldata(inputData, txData.to?.hash || "");
// On the active network, the one whose explorer was just asked, and
// from the sender it reports.
const decoded = decodeCalldata(
inputData,
txData.to?.hash || "",
currentNetwork().chainId,
txData.from?.hash || "",
);
if (decoded) {
// Render decoded calldata matching approval view style
actionEl.textContent = decoded.name;
+21 -6
View File
@@ -51,12 +51,15 @@ function getLocalWarnings(address, options = {}) {
/**
* Check an address against local lists AND via RPC queries.
* Async — performs network calls to check contract status and tx history.
* Async — performs network calls to check contract status, tx count and
* balance.
*
* @param {string} address - The target address to check.
* @param {object} provider - An ethers.js provider instance.
* @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check).
* @param {string} [options.chainId] - Chain id of the network whose explorer
* the Etherscan label check asks; the active network's when omitted.
* @returns {Promise<Array<{type: string, message: string, severity: string}>>}
*/
async function getFullWarnings(address, provider, options = {}) {
@@ -78,21 +81,30 @@ async function getFullWarnings(address, provider, options = {}) {
log.errorf("contract check failed:", e.shortMessage || e.message);
}
// Skip tx count check for contracts — they may legitimately have
// zero inbound EOA transactions.
// The transaction count is the number of transactions the address has
// sent, so an address that has only ever received has a count of zero.
// Native token it received stays in its balance until it sends a
// transaction, so the warning also needs a zero balance. A contract is
// skipped: it has its own warning above.
if (!isContract) {
try {
const txCount = await provider.getTransactionCount(address);
if (txCount === 0) {
const balance = await provider.getBalance(address);
if (balance === 0n) {
warnings.push({
type: "new-address",
message:
"This address has never sent a transaction. Double-check it is correct.",
"This address has never sent a transaction and holds none of the network's native token. Double-check it is correct.",
severity: "info",
});
}
}
} catch (e) {
log.errorf("tx count check failed:", e.shortMessage || e.message);
log.errorf(
"tx count or balance check failed:",
e.shortMessage || e.message,
);
}
}
@@ -100,7 +112,10 @@ async function getFullWarnings(address, provider, options = {}) {
// Runs for ALL addresses including contracts, since many dangerous
// flagged addresses on Etherscan (drainers, phishing contracts) are contracts.
try {
const etherscanWarning = await checkEtherscanLabel(address);
const etherscanWarning = await checkEtherscanLabel(
address,
options.chainId,
);
if (etherscanWarning) {
warnings.push(etherscanWarning);
}
+23 -3
View File
@@ -41,6 +41,7 @@ const {
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
assertWithinCeilings,
nodeError,
} = require("./approvalVerify");
// How long the population may take before the request is failed back to the
@@ -72,15 +73,27 @@ const REQUEST_FIELDS = [
"type",
];
// EIP-1474's code for invalid parameters: what the page is given when the
// site asked for a transaction this wallet refuses as it was asked for.
const INVALID_PARAMS_CODE = -32602;
// Why no approval can be raised. The message is the sentence the page is
// shown. `code` and `data` are the JSON-RPC ones it is given with it: the
// node's own, unchanged, when the node refused the transaction, so a site can
// decode a revert; INVALID_PARAMS_CODE when the site's request is refused;
// none when the failure is the wallet's own, which the background answers as
// an internal error.
class ApprovalPrepareError extends Error {
constructor(message) {
constructor(message, code, data) {
super(message);
this.name = "ApprovalPrepareError";
if (code !== undefined) this.code = code;
if (data !== undefined) this.data = data;
}
}
function fail(message) {
return new ApprovalPrepareError(message);
function fail(message, code, data) {
return new ApprovalPrepareError(message, code, data);
}
function present(v) {
@@ -134,6 +147,7 @@ function requestFrom(txParams, from) {
) {
throw fail(
"The site asked for two different gas limits for this transaction.",
INVALID_PARAMS_CODE,
);
}
request.gasLimit = txParams.gas;
@@ -144,6 +158,7 @@ function requestFrom(txParams, from) {
) {
throw fail(
"The site asked for a transaction of a type this wallet does not sign.",
INVALID_PARAMS_CODE,
);
}
return request;
@@ -228,6 +243,9 @@ async function prepareApprovalTx(provider, from, txParams) {
);
} catch (e) {
if (e instanceof ApprovalPrepareError) throw e;
// A refusal from the node, such as a reverting gas estimate, keeps
// the node's code and revert data.
const fromNode = nodeError(e);
throw fail(
sentence(
"The transaction could not be prepared: " +
@@ -235,6 +253,8 @@ async function prepareApprovalTx(provider, from, txParams) {
e.message ||
"the network did not answer"),
),
fromNode?.code,
fromNode?.data,
);
}
+11 -1
View File
@@ -746,6 +746,15 @@ function errorText(err) {
return "The transaction could not be sent.";
}
// The JSON-RPC error object the node answered with, `{ code, message, data }`,
// out of the error ethers threw for that answer. ethers keeps it under
// `info.error`, or under `error` when it could not classify it. Null when
// there is none, as for a node that could not be reached.
function nodeError(err) {
if (!err || typeof err !== "object") return null;
return err.error || (err.info && err.info.error) || null;
}
// Every string a failure might carry its reason in. ethers reports the node's
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
// nested under `error` or `info.error` with the node's message intact, and the
@@ -757,7 +766,7 @@ function failureTexts(err) {
for (const text of [err.shortMessage, err.message, err.reason]) {
if (text) texts.push(String(text));
}
const nested = err.error || (err.info && err.info.error);
const nested = nodeError(err);
if (nested && nested.message) texts.push(String(nested.message));
return texts;
}
@@ -891,6 +900,7 @@ module.exports = {
assertWithinCeilings,
sameAddress,
failureIsRetryable,
nodeError,
isNonceCollision,
describeTxFailure,
describeSigningFailure,
+92 -5
View File
@@ -174,9 +174,9 @@ function notify(message) {
// default: resolving {} would make an existing wallet read back as no wallet,
// and resolving a no-op write would discard the user's state with nothing
// logged. A caller that wants to degrade takes storageLocal() directly.
function storageUnavailable(method) {
function storageUnavailable(area, method) {
return Promise.reject(
new Error("extension storage.local is not available: " + method),
new Error("extension storage." + area + " is not available: " + method),
);
}
@@ -187,7 +187,7 @@ function storageUnavailable(method) {
*/
function storageGet(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("get");
if (!storage) return storageUnavailable("local", "get");
return Promise.resolve(storage.get(keys));
}
@@ -198,7 +198,7 @@ function storageGet(keys) {
*/
function storageSet(items) {
const storage = storageLocal();
if (!storage) return storageUnavailable("set");
if (!storage) return storageUnavailable("local", "set");
return Promise.resolve(storage.set(items));
}
@@ -215,7 +215,55 @@ function storageSet(items) {
*/
function storageRemove(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("remove");
if (!storage) return storageUnavailable("local", "remove");
return Promise.resolve(storage.remove(keys));
}
// `storage.session`, the browser's session storage: what is kept there lasts
// until the browser closes. It survives the background worker being stopped
// and started again, which module-level variables do not (README, Background
// scheduling). The background keeps the connections allowed without
// "Remember" there; see src/background/index.js. Firefox has it from version
// 115, the minimum manifest/firefox.json declares.
//
// The three below reject where it is absent, as the three above do where
// `storage.local` is: a write that silently did nothing would tell a site it
// is connected and then refuse its next request.
function storageSession() {
const storage = namespaceMember("storage");
return (storage && storage.session) || null;
}
/**
* @param {string|string[]|null} keys null for every item.
* @returns {Promise<Object>} the stored items.
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageGet(keys) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "get");
return Promise.resolve(storage.get(keys));
}
/**
* @param {Object} items
* @returns {Promise<void>}
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageSet(items) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "set");
return Promise.resolve(storage.set(items));
}
/**
* @param {string|string[]} keys
* @returns {Promise<void>}
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageRemove(keys) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "remove");
return Promise.resolve(storage.remove(keys));
}
@@ -243,6 +291,22 @@ function tabsSendMessage(tabId, message) {
return invoke(tabsApi(), "sendMessage", tabId, message);
}
/**
* @returns {Promise<Object|undefined>} the tab the calling page is in, or
* undefined for a page not in a tab, such as the toolbar popup.
*/
function tabsGetCurrent() {
return invoke(tabsApi(), "getCurrent");
}
/**
* @param {number} tabId
* @returns {Promise<number>} that tab's zoom: 1.5 at 150%.
*/
function tabsGetZoom(tabId) {
return invoke(tabsApi(), "getZoom", tabId);
}
/**
* @param {Object} createData
* @returns {Promise<Object>} the created window.
@@ -258,6 +322,22 @@ function windowsGetLastFocused() {
return invoke(windowsApi(), "getLastFocused");
}
/**
* @returns {Promise<Object>} the window the calling page is in.
*/
function windowsGetCurrent() {
return invoke(windowsApi(), "getCurrent");
}
/**
* @param {number} windowId
* @param {Object} updateInfo
* @returns {Promise<Object>} the updated window.
*/
function windowsUpdate(windowId, updateInfo) {
return invoke(windowsApi(), "update", windowId, updateInfo);
}
/**
* @param {number} windowId
* @returns {Promise<void>}
@@ -275,15 +355,22 @@ module.exports = {
notify,
runtimeApi,
sendMessage,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
storageGet,
storageLocal,
storageRemove,
storageSet,
tabsApi,
tabsGetCurrent,
tabsGetZoom,
tabsQuery,
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetCurrent,
windowsGetLastFocused,
windowsRemove,
windowsUpdate,
};
+10
View File
@@ -38,6 +38,16 @@ function applyChainSwitchFields(s, newNetworkId) {
blockscoutUrl: s.blockscoutUrl,
};
const remembered = s.networkEndpoints[net.id] || {};
// Tracked tokens are kept per network the same way, because a contract
// address names a token on one network only. s.trackedTokens stays the
// active network's list, so nothing that reads it changes: the outgoing
// list is put away under its network, and the incoming network's list, or
// an empty one, takes its place.
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
s.trackedTokens = s.networkTrackedTokens[net.id] || [];
s.networkId = net.id;
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
+9 -4
View File
@@ -2,6 +2,8 @@
// Extension users make the requests directly to Etherscan — no proxy needed.
// This is a best-effort enrichment: network failures return null silently.
const { networkByChainId } = require("./networks");
// Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
@@ -72,18 +74,21 @@ function parseEtherscanPage(html) {
* Returns a warning object if the address is flagged, or null.
* Network failures return null silently (best-effort check).
*
* Uses the current network's explorer URL so the lookup works on both
* mainnet (etherscan.io) and Sepolia (sepolia.etherscan.io).
* Uses the explorer of the network with `chainId`, or of the active network
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
* Sepolia.
*
* @param {string} address - Ethereum address to check.
* @param {string} [chainId] - Chain id of the network to look it up on.
* @returns {Promise<{type: string, message: string, severity: string}|null>}
*/
async function checkEtherscanLabel(address) {
async function checkEtherscanLabel(address, chainId) {
try {
// Lazy require to avoid pulling in chrome.storage at module scope
// (which breaks unit tests that only exercise parseEtherscanPage).
const { currentNetwork } = require("./state");
const etherscanBase = currentNetwork().explorerUrl + "/address/";
const network = chainId ? networkByChainId(chainId) : currentNetwork();
const etherscanBase = network.explorerUrl + "/address/";
const resp = await fetch(etherscanBase + address, {
headers: { Accept: "text/html" },
});
+19
View File
@@ -23,6 +23,11 @@ const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
// Tracked tokens remembered per network: { [networkId]: [token, ...] }.
// trackedTokens above is the active network's list; this is what the
// others are restored from when the active network changes. See
// applyChainSwitchFields().
networkTrackedTokens: {},
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
@@ -289,6 +294,20 @@ function normalizePersisted(saved) {
// profile whose wallets are perfectly fine. An empty list is a legitimate
// value and survives.
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
// Each remembered list is floored like trackedTokens, because
// applyChainSwitchFields() puts it straight back onto trackedTokens when
// its network becomes the active one. A key that is not a network in
// networks.js is dropped, which also keeps an own "__proto__" key from
// reaching the plain assignment below.
out.networkTrackedTokens = {};
if (isRecord(saved.networkTrackedTokens)) {
for (const netId of Object.keys(saved.networkTrackedTokens)) {
if (!isKnownNetworkId(netId)) continue;
out.networkTrackedTokens[netId] = structuredClone(
tokenRefs(saved.networkTrackedTokens[netId]),
);
}
}
// The loud refusal for an unknown id is assertStateUsable(); this is the
// floor under it. networkId is an object KEY into networkEndpoints below,
// so a value that is not a network in networks.js must never get that far
+6 -3
View File
@@ -2,6 +2,7 @@
const { getTopTokenPrices } = require("./tokenList");
const { isBelowOneMillionth } = require("./amountDisplay");
const { networkByChainId } = require("./networks");
const PRICE_CACHE_TTL = 300000; // 5 minutes
@@ -36,10 +37,12 @@ function clearPrices() {
lastFetchedAt = 0;
}
// Return the USD price for a symbol, or null on testnet / unknown.
function getPrice(symbol) {
// Return the USD price for a symbol on the network with `chainId`, or on the
// active network when no chain id is given; null on testnet / unknown.
function getPrice(symbol, chainId) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
const network = chainId ? networkByChainId(chainId) : currentNetwork();
if (network.isTestnet) return null;
return prices[symbol] || null;
}
+12 -8
View File
@@ -42,9 +42,11 @@ const { log } = require("./log");
// and writes THIS object, never the proxy: the guard is for callers.
const rawState = {
...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
// mutates this map in place, and a spread copies the reference.
// Their own objects, not the ones DEFAULT_STATE holds:
// applyChainSwitchFields() mutates these maps in place, and a spread copies
// the reference.
networkEndpoints: {},
networkTrackedTokens: {},
currentView: null,
selectedWallet: null,
selectedAddress: null,
@@ -438,12 +440,14 @@ function mergeNetworkEndpoints(base, ours, theirs) {
//
// Every other persisted field stays a whole-field diff:
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
// the ones above, but nothing in src/ ever writes an entry into it — it is
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
// no in-place mutation for a whole-field diff to collide with; `viewData` is
// this page's own UI scratch space, not data another page has any reason to
// share membership of.
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
// `trackedTokens` and `networkId`, the two fields a network switch moves
// together with it; `tokenHolderCache` is a map shaped like the ones above,
// but nothing in src/ ever writes an entry into it — it is only ever reset
// wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place
// mutation for a whole-field diff to collide with; `viewData` is this page's
// own UI scratch space, not data another page has any reason to share
// membership of.
//
// This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes
+5 -5
View File
@@ -2,11 +2,11 @@
// encoded with.
//
// A token amount is a decimal string plus a scale, and the two come from
// different places. The confirmation screen renders the amount, the balance and
// the symbol from the block explorer's cached metadata (see
// fetchTokenBalances() in balances.js); the transfer used to be encoded from
// decimals() read off the contract at signing time, and nothing compared the
// two. A token whose on-chain scale differs from the cached one — an
// different places. The confirmation screen renders the amount, the symbol and
// the balance it reads from the node with the block explorer's cached metadata
// (see fetchTokenBalances() in balances.js); the transfer used to be encoded
// from decimals() read off the contract at signing time, and nothing compared
// the two. A token whose on-chain scale differs from the cached one — an
// upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
// entry — therefore signed an amount that was never displayed, off by a power
// of ten for every decimal place of disagreement.
+39 -13
View File
@@ -1,4 +1,5 @@
// Balance arithmetic for the Send and transaction confirmation screens.
// The amount check and balance arithmetic for the Send and transaction
// confirmation screens.
//
// Pure: no DOM, no network, no state. Everything is exact integer math on
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
@@ -20,7 +21,7 @@ const FEE_KNOWN = "known";
const FEE_UNAVAILABLE = "unavailable";
const CODES = {
// The amount is not a non-negative number we can do exact arithmetic on.
// The amount is one isValidAmount() refuses.
AMOUNT_INVALID: "amount-invalid",
// ERC-20: the token amount exceeds the token balance.
INSUFFICIENT_TOKEN: "insufficient-token",
@@ -87,10 +88,31 @@ function toFixedPoint(value) {
}
}
// Whether `amount` can be sent: a number above zero, written as digits with
// at most one decimal point ("0.5", "12", ".5"), with nothing but zeros after
// the token's last decimal place, `tokenDecimals`, and nothing but zeros after
// the 18th, the places the arithmetic here works in, which are ETH's. ETH, and
// a token whose decimal places are unknown, pass null and are held to the 18
// (the confirmation screen refuses to send such a token anyway). Zeros past
// those places are accepted because parseUnits() drops them here and again
// when the transfer is encoded, so "1.0000000" of a 6-place token is exactly
// 1 everywhere. Refuses "1e-3", "1abc", "0" and "-1". The Send screen checks
// it before the confirmation screen opens, and validateTransfer() checks it
// again there.
function isValidAmount(amount, tokenDecimals = null) {
if (typeof amount !== "string") return false;
const places = Math.min(tokenDecimals ?? SCALE_DECIMALS, SCALE_DECIMALS);
try {
return parseUnits(amount.trim(), places) > 0n;
} catch {
return false;
}
}
// The most ETH a send can carry: the exact balance minus the fee reserve from
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string
// balances.js stores, never a rounded one. Null when the balance does not
// exactly that reserve left behind. `ethBalance` is an exact decimal string as
// formatEther() writes it, never a rounded one. Null when the balance does not
// leave anything to send once the fee is paid, or when either input is
// unusable.
function maxEthAmount(ethBalance, feeWei) {
@@ -112,9 +134,11 @@ function maxTokenAmount(tokenBalance) {
// Validate a pending transfer against the balances that must cover it.
//
// isErc20 — token transfer rather than a native ETH transfer
// amount — human decimal string being sent, non-negative. Anything
// else, a negative value included, is an unusable amount
// amount — human decimal string being sent. One isValidAmount()
// refuses, a negative value included, is an unusable amount
// rather than an amount that passes every comparison.
// tokenDecimals — ERC-20: the token's decimal places, or null when they
// are unknown. Passed to isValidAmount().
// ethBalance — human decimal string, the sender's ETH balance
// tokenBalance — human decimal string, the sender's token balance
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
@@ -128,6 +152,7 @@ function maxTokenAmount(tokenBalance) {
function validateTransfer({
isErc20 = false,
amount,
tokenDecimals = null,
ethBalance,
tokenBalance,
feeStatus = FEE_PENDING,
@@ -135,17 +160,17 @@ function validateTransfer({
} = {}) {
const codes = [];
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// A negative amount parses to a valid bigint, so every comparison below
// is trivially false and the send clears the screen — then dies at encode
// time in parseEther(). Unusable, on the same footing as a malformed fee.
if (amountFp === null || amountFp < 0n) {
// An amount refused here never reaches the comparisons below: a negative
// one would pass every one of them, then die at encode time in
// parseEther().
if (!isValidAmount(amount, isErc20 ? tokenDecimals : null)) {
codes.push(CODES.AMOUNT_INVALID);
return { canSend: false, codes };
}
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// Fail closed. Anything that is not a usable fee under a recognised
// status — a malformed feeWei, or a status this module does not know —
// is an unavailable estimate, never a fee of zero. Every such input errs
@@ -195,6 +220,7 @@ module.exports = {
SCALE_DECIMALS,
feeReserveWei,
feeEstimateWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
+1052 -193
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -14,7 +14,7 @@ function sameAddress(a, b) {
// 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.
// "Remember", which only the background holds, in session storage.
function dropSitePermissions(state, addresses) {
for (const addr of addresses) {
delete state.allowedSites[addr];
+167
View File
@@ -0,0 +1,167 @@
// A page whose origin has no host is never told an address by accountsChanged
// (https://git.eeqj.de/sneak/AutistMask/issues/564).
//
// When the user switches address, the background tells each open tab the new
// active address if the site at the tab's URL is connected. A page at a
// connected site's URL can still have another origin: one its server
// sandboxes with a Content-Security-Policy has the origin "null", and every
// request it makes is refused. Each test here switches the address in the real
// background, takes the event it sent to a tab at the site's URL, and hands
// that event to the real content script in a page of the given origin.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const SITE = "https://dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
// One address, with SITE remembered as allowed for it.
function storedProfile() {
const mainnet = networkById("mainnet");
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: mainnet.defaultRpcUrl,
blockscoutUrl: mainnet.defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [SITE] },
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
delete global.window;
jest.resetModules();
});
// The popup tells the background the active address changed, and the one
// open tab, at SITE's URL, is sent an event. That event is returned.
async function eventSentToSiteTab() {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
let messageListener = null;
const sentToTabs = [];
global.chrome = {
storage: makeStorageStub({ autistmask: storedProfile() }),
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
getPlatformInfo: (cb) => cb({ os: "linux" }),
lastError: null,
},
windows: {
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push(msg);
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
messageListener(
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
FROM_POPUP,
() => {},
);
await settle();
expect(sentToTabs).toHaveLength(1);
return sentToTabs[0];
}
// The content script in a page whose origin is `pageOrigin` is handed `event`
// from the background. What it posts to the page is returned.
function postedToPage(event, pageOrigin) {
jest.resetModules();
let eventListener = null;
const posted = [];
global.chrome = {
runtime: {
onMessage: {
addListener: (fn) => {
eventListener = fn;
},
},
},
};
global.window = {
origin: pageOrigin,
addEventListener: () => {},
postMessage: (data) => posted.push(data),
};
require("../src/content/index");
eventListener(event);
return posted;
}
describe("accountsChanged after an address switch, at a connected site's URL", () => {
test("the site's own page is told the active address", async () => {
const event = await eventSentToSiteTab();
expect(postedToPage(event, SITE)).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [ADDRESS],
},
]);
});
test("a page whose origin has no host is told no address", async () => {
const event = await eventSentToSiteTab();
expect(postedToPage(event, "null")).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
]);
});
});
+18 -5
View File
@@ -230,7 +230,7 @@ beforeEach(() => {
addWallet.init({
renderWalletList: () => {},
doRefreshAndRender: () => {},
startRefresh: () => {},
pageClosed: new AbortController().signal,
});
confirmTx.init({});
@@ -291,7 +291,6 @@ async function confirmSend(from, selected) {
to: RECIPIENT,
amount: "0.5",
token: "ETH",
balance: "1.0",
});
await settle();
node("confirm-tx-password").value = PASSWORD;
@@ -443,11 +442,25 @@ describe("a confirmation screen whose From is not the selected address", () => {
expect(Transaction.from(signed).from).toBe(derived(1).address);
});
// The refusal's sentence is fixed in index.html, in a box of its own that
// holds its space from the first paint, so showing it moves nothing.
test("nothing is sent when the selected wallet does not hold it", async () => {
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
expect(node("confirm-tx-password-error").textContent).toBe(
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
expect(node("confirm-from-address-error").style.visibility).toBe(
"visible",
);
expect(node("confirm-tx-password-error").textContent).toBe("");
// Reviewing a send again draws the screen again, which hides it.
confirmTx.show({
from: derived(0).address,
to: RECIPIENT,
amount: "0.5",
token: "ETH",
});
await settle();
expect(node("confirm-from-address-error").style.visibility).toBe(
"hidden",
);
});
});
+1 -1
View File
@@ -94,7 +94,7 @@ describe.each([
async function create(pageClosed) {
addWallet.init({
renderWalletList: () => {},
doRefreshAndRender: () => {},
startRefresh: () => {},
pageClosed,
});
element("tab-" + mode).listeners.click();
+39
View File
@@ -0,0 +1,39 @@
// The confirmation screen's warning for a recipient that has never sent a
// transaction (https://git.eeqj.de/sneak/AutistMask/issues/528).
//
// The node's transaction count for an address is the number of transactions
// it has sent, so an address that only ever receives, such as a savings
// address or an exchange deposit address, has a count of zero. The warning
// used to fire on that count alone. It now also needs a zero balance: ETH an
// address received stays in its balance until it sends a transaction.
"use strict";
const { getFullWarnings } = require("../src/shared/addressWarnings");
global.fetch = jest.fn(async () => {
throw new Error("tests must not perform network requests");
});
const RECIPIENT = "0x" + "b".repeat(40);
// The warning types getFullWarnings() returns for RECIPIENT when the node
// reports it as a regular address with `txCount` sent transactions and a
// balance of `balance` wei.
async function warningTypes(txCount, balance) {
const provider = {
getCode: async () => "0x",
getTransactionCount: async () => txCount,
getBalance: async () => balance,
};
const warnings = await getFullWarnings(RECIPIENT, provider);
return warnings.map((w) => w.type);
}
test("a recipient that has received 0.5 ETH and never sent a transaction gets no warning", async () => {
expect(await warningTypes(0, 500000000000000000n)).toEqual([]);
});
test("a recipient that has never sent a transaction and holds no ETH gets the warning", async () => {
expect(await warningTypes(0, 0n)).toEqual(["new-address"]);
});
+8 -1
View File
@@ -68,7 +68,14 @@ function swapData(amountIn, amountOutMin) {
}
function swapDetail(amountIn, amountOutMin, label) {
const decoded = uniswap.decode(swapData(amountIn, amountOutMin), ROUTER);
// Sent by the address the swap pays, so the output is the user's.
const decoded = uniswap.decode(
swapData(amountIn, amountOutMin),
ROUTER,
"0x1",
{},
RECIPIENT,
);
return decoded.details.find((d) => d.label === label);
}
+129
View File
@@ -45,3 +45,132 @@ test("the approval window shows no screen until the background describes the app
expect(env.visibleViews()).toEqual(["approve-network"]);
expect(env.pageErrors).toEqual([]);
});
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too
// (https://git.eeqj.de/sneak/AutistMask/issues/518). The browser suites run
// without frames, and only the Firefox suite's sign prompt at a zoom other
// than 100%, so the widening is checked here too.
//
// A window 396 screen pixels wide whose page is drawn at `drawn` screen pixels
// to the page pixel. Widening it gives the page its new width, less the 15
// screen pixels of the scrollbar the approval screen has, and then calls the
// page's resize listeners, each of which asked to be called once.
function windowsStub(type, drawn) {
const updates = [];
return {
updates,
getCurrent: (done) => done({ id: 7, type, width: 396 }),
update: (id, info, done) => {
updates.push({ id, info });
done({});
globalThis.document.documentElement.clientWidth = Math.floor(
(info.width - 15) / drawn,
);
const resized = globalThis.window.listeners.resize || [];
globalThis.window.listeners.resize = [];
for (const fn of resized) fn();
},
};
}
// The approval page's own tab is at `zoom`. Asked without a tab id, Firefox
// answers for the window focused last, here a site's tab at 100%.
function tabsStub(zoom) {
return {
getCurrent: (done) => done({ id: 11 }),
getZoom: (...args) => {
const done = args.pop();
done(args[0] === 11 ? zoom : 1);
},
};
}
// An approval window at `zoom`, `clientWidth` page pixels wide before its
// screen is up, whose background holds its answer until the test gives it.
async function bootZoomedApprovalWindow(windows, zoom, clientWidth) {
let answer = null;
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") answer = reply;
},
},
windows,
tabs: tabsStub(zoom),
documentElement: { scrollWidth: 396, clientWidth },
});
return { env, answer };
}
test("at 150% zoom the approval window widens by what its screen lacks, scrollbar included", async () => {
const windows = windowsStub("popup", 1.5);
// At 150% a window 396 screen pixels wide shows 264 page pixels.
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.5, 264);
// Not before there is a screen to fit.
expect(windows.updates).toEqual([]);
// The screen is taller than the window, so once it is up a vertical
// scrollbar takes 15 screen pixels, 10 page pixels at 150%.
env.document.documentElement.clientWidth = 254;
answer({
type: "network",
origin: "https://dapp.example",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
isPhishingDomain: false,
});
await settle();
// 396 - 254 = 142 page pixels short, which at 150% is 213 screen pixels.
// At the 100% of the site's tab it would be 142, and the screen would
// still not fit.
expect(env.visibleViews()).toEqual(["approve-network"]);
expect(windows.updates).toEqual([{ id: 7, info: { width: 609 } }]);
expect(env.document.documentElement.clientWidth).toBe(396);
expect(env.pageErrors).toEqual([]);
});
test("at 170% the approval window is widened again while its screen still falls short", async () => {
// Firefox reports 170% as 1.7 but draws a page pixel about 1.714 screen
// pixels wide, so a window 396 wide shows 231 page pixels.
const windows = windowsStub("popup", 1.714);
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.7, 231);
// Less the scrollbar once the screen is up.
env.document.documentElement.clientWidth = 222;
answer({
type: "network",
origin: "https://dapp.example",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
isPhishingDomain: false,
});
await settle();
// 174 page pixels short, so 296 screen pixels wider at 1.7. That shows 394
// page pixels, still 2 short, so 4 screen pixels wider again.
expect(windows.updates).toEqual([
{ id: 7, info: { width: 692 } },
{ id: 7, info: { width: 696 } },
]);
const page = env.document.documentElement;
expect(page.scrollWidth).toBeLessThanOrEqual(page.clientWidth);
expect(env.pageErrors).toEqual([]);
});
test("a connection prompt in the toolbar popup leaves the browser window alone", async () => {
const windows = windowsStub("normal", 1.5);
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.5, 264);
answer({ origin: "https://dapp.example", isPhishingDomain: false });
await settle();
expect(env.visibleViews()).toEqual(["approve-site"]);
expect(windows.updates).toEqual([]);
expect(env.pageErrors).toEqual([]);
});
+36 -15
View File
@@ -321,9 +321,10 @@ function loadBackground(options) {
require("../src/background/index");
// Send a message the way the browser would, and hand back whatever the
// handler passed to sendResponse.
function send(msg, sender) {
const sendResponse = jest.fn();
// handler passed to sendResponse. `onResponse`, when given, receives the
// answer when the handler sends it, as the sender's callback does.
function send(msg, sender, onResponse) {
const sendResponse = jest.fn(onResponse);
const kept = messageListener(msg, sender || {}, sendResponse);
return { sendResponse, kept };
}
@@ -1161,9 +1162,12 @@ describe("a nonce collision is reported as a transaction that did not go out", (
const pending = bg.requestTx();
await settle();
// As ethers throws it: its own classification, and the node's
// JSON-RPC error under info.error.
bg.broadcastTransaction.mockRejectedValue(
Object.assign(new Error("nonce too low"), {
code: "NONCE_EXPIRED",
info: { error: { code: -32000, message: "nonce too low" } },
}),
);
const answer = bg.send(
@@ -1182,8 +1186,10 @@ describe("a nonce collision is reported as a transaction that did not go out", (
retryable: false,
stage: "nonce",
});
// The wallet's sentence, with the node's own code.
expect(pending.result()).toEqual({
error: {
code: -32000,
message: expect.stringMatching(
/transaction was not sent, because its nonce/,
),
@@ -1233,6 +1239,7 @@ describe("a nonce collision is reported as a transaction that did not go out", (
});
expect(second.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(/nonce had already been used/),
},
});
@@ -1323,6 +1330,7 @@ describe("what the approval is verified against", () => {
);
expect(pending.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(/approved maximum fee per gas/),
},
});
@@ -1382,7 +1390,10 @@ describe("what the approval is verified against", () => {
expect.objectContaining({ retryable: false, stage: "verify" }),
);
expect(pending.result()).toEqual({
error: { message: expect.stringMatching(/active address changed/) },
error: {
code: -32603,
message: expect.stringMatching(/active address changed/),
},
});
});
@@ -1526,7 +1537,10 @@ describe("what the approval is verified against", () => {
}),
);
expect(pending.result()).toEqual({
error: { message: expect.stringMatching(/active address changed/) },
error: {
code: -32603,
message: expect.stringMatching(/active address changed/),
},
});
});
@@ -1576,7 +1590,9 @@ describe("what the approval is verified against", () => {
error: refusal,
retryable: false,
});
expect(pending.result()).toEqual({ error: { message: refusal } });
expect(pending.result()).toEqual({
error: { code: -32603, message: refusal },
});
});
// ethers signs a `bool` field sent as the string "false" as true, and one
@@ -1611,7 +1627,9 @@ describe("what the approval is verified against", () => {
error: refusal,
retryable: false,
});
expect(pending.result()).toEqual({ error: { message: refusal } });
expect(pending.result()).toEqual({
error: { code: -32603, message: refusal },
});
},
);
@@ -1649,6 +1667,7 @@ describe("what the approval is verified against", () => {
expect(pending.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(
/active address changed while this transaction was being prepared/,
),
@@ -1671,8 +1690,11 @@ describe("what the approval is verified against", () => {
const pending = bg.requestTx();
await settle();
// A failure with no node error in it is answered as the wallet's own;
// tests/rpcErrorCodes.test.js has the node's own revert.
expect(pending.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(
/could not be prepared.*execution reverted/,
),
@@ -2594,9 +2616,9 @@ async function siteAccounts(bg, origin) {
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.
// A site connected without "Remember" is held only in the background's session
// storage, 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.
//
@@ -2775,8 +2797,7 @@ describe("removing a site in Settings disconnects it", () => {
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]);
bg.send(msg, bg.fromPopup, callback);
};
await require("../src/shared/state").loadState();
await require("../src/popup/views/settings").renderSiteLists();
@@ -2930,7 +2951,7 @@ describe("where an approval window opens", () => {
await settle();
expect(bg.created).toHaveLength(1);
expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
expect(bg.created[0]).toMatchObject({ left: 442, top: 60 });
});
test("not centred on an approval window the user was last in", async () => {
@@ -2947,7 +2968,7 @@ describe("where an approval window opens", () => {
bg.requestSign();
await settle();
// Centred, it would be at left 900, the position the browser refused.
// Centred, it would be at left 882, the position the browser refused.
expect(bg.created).toHaveLength(1);
expect(bg.created[0].left).toBeUndefined();
expect(bg.created[0].top).toBeUndefined();
@@ -2969,7 +2990,7 @@ describe("where an approval window opens", () => {
await settle();
expect(bg.created).toHaveLength(2);
expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
expect(bg.created[0]).toMatchObject({ left: 1502, top: 750 });
expect(bg.created[1].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined();
+11 -4
View File
@@ -204,17 +204,21 @@ describe("a chain switch on a worker that never loaded state", () => {
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
// And it took nothing else with it. Without the load these come back
// as [], {}, [] and "system" from DEFAULT_STATE — every wallet in the
// as [], {}, {} and "system" from DEFAULT_STATE — every wallet in the
// extension gone, encrypted secrets included.
expect(after.wallets).toEqual(walletFixture());
expect(after.hasWallet).toBe(true);
expect(after.activeAddress).toBe(ADDRESS);
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
expect(after.trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
expect(after.networkTrackedTokens).toEqual({
mainnet: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
});
expect(after.theme).toBe("dark");
// The mainnet token is tracked on mainnet only: Sepolia starts with
// none of its own.
expect(after.trackedTokens).toEqual([]);
// The user's mainnet endpoint is remembered rather than replaced by
// the public default, so switching back returns it.
expect(after.networkEndpoints.mainnet).toEqual({
@@ -226,6 +230,9 @@ describe("a chain switch on a worker that never loaded state", () => {
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
expect(bg.persisted().wallets).toEqual(walletFixture());
expect(bg.persisted().trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
});
test("compares the requested chain against the stored one, not the default", async () => {
+415
View File
@@ -0,0 +1,415 @@
// The amount of the wallet's own send on the confirmation, wait, success and
// error screens (https://git.eeqj.de/sneak/AutistMask/issues/522).
//
// These screens printed the text from Send's amount field as it was typed or
// filled in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where the
// approval screen shows a site's send as `0.5000 ETH`. They now show it to four
// decimal places with the nonzero floor, as the approval screen does, and the
// transaction is still signed for the exact amount.
//
// Driven through the real Send screen, confirmation screen, Sign & Send and
// wait screen, with only the node, the explorer and the DOM stubbed.
"use strict";
// What the stub node answers, and the signed transactions it was handed. The
// token's own decimals() answers 6, as the explorer does.
const mockNode = {
balanceWei: 0n,
tokenUnits: 0n,
tokenDecimals: 6n,
broadcast: [],
broadcastError: null,
receipt: null,
};
// The token rows the stub explorer reports for the address.
const mockExplorer = { items: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// The selector of decimals(); every other contract call is balanceOf().
const DECIMALS = "0x313ce567";
class StubProvider {
async getBalance() {
return mockNode.balanceWei;
}
async call(tx) {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[
tx.data.startsWith(DECIMALS)
? mockNode.tokenDecimals
: mockNode.tokenUnits,
],
);
}
async lookupAddress() {
return null;
}
// 20 gwei reserved per gas.
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getNetwork() {
return { chainId: 1n };
}
async broadcastTransaction(signed) {
if (mockNode.broadcastError) throw mockNode.broadcastError;
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
async getTransactionReceipt() {
return mockNode.receipt;
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The explorer's token list, which refreshBalances() also fetches.
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => mockExplorer.items,
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The confirmation screen's Etherscan label lookup is the only fetch() these
// screens make; it fails, as it does offline.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
children: [],
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
this.children.push(child);
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
// The success screen hides whole sections through .parentElement.
get parentElement() {
return global.document.getElementById(id + "-parent");
},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const {
Interface,
Transaction,
Wallet,
getAddress,
parseEther,
} = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const { encryptWithPassword } = require("../src/shared/vault");
const { state } = require("../src/shared/state");
const { clearPrices } = require("../src/shared/prices");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const txStatus = require("../src/popup/views/txStatus");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const PASSWORD = "correct horse battery staple";
const GWEI = 1000000000n;
// 1.234567890123456789 ETH.
const BALANCE_WEI = 1234567890123456789n;
// A token the bundled list does not know, with enough holders to be listed,
// at 6 decimal places.
const TOKEN = "0x" + "d".repeat(40);
const transferIface = new Interface([
"function transfer(address to, uint256 amount)",
]);
let encryptedSecret;
// The confirmation screen Review leads to, once shown.
let confirmed = null;
function el(id) {
return global.document.getElementById(id);
}
function text(id) {
return el(id).textContent;
}
// show() reads the balance and then estimates the fee without awaiting
// either; this lets both settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// The address holds BALANCE_WEI and, when `tokenUnits` is given, that many
// base units of TOKEN, fetched and stored where the popup stores them. Send
// is then open for `token` ("ETH" or TOKEN) with the recipient entered.
async function openSend(token, tokenUnits = null) {
mockNode.balanceWei = BALANCE_WEI;
mockNode.tokenUnits = tokenUnits ?? 0n;
mockExplorer.items =
tokenUnits === null
? []
: [
{
value: String(tokenUnits),
token: {
type: "ERC-20",
address_hash: TOKEN,
symbol: "TOK",
name: "Token",
decimals: "6",
holders_count: "50000",
},
},
];
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
state.trackedTokens,
"mainnet",
);
send.init({
showConfirmTx: (info) => (confirmed = info),
pageClosed: new AbortController().signal,
});
confirmTx.init({});
send.resetSendValidation();
state.currentView = "send";
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = "";
}
// Press Review and show the confirmation screen with its fee estimate settled.
async function review() {
await el("btn-send-review").handlers.get("click")();
confirmTx.show(confirmed);
await settle();
}
async function signAndSend() {
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
}
// The one transaction the stub node was handed.
function broadcastTx() {
expect(mockNode.broadcast).toHaveLength(1);
return Transaction.from(mockNode.broadcast[0]);
}
beforeAll(async () => {
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
beforeEach(() => {
elements.clear();
clearPrices();
confirmed = null;
state.selectedToken = null;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
mockNode.broadcast = [];
mockNode.broadcastError = null;
mockNode.receipt = null;
txStatus.init({
doRefreshAndRender() {},
pageClosed: new AbortController().signal,
});
});
afterEach(() => {
txStatus.endWait();
});
describe("the wallet's own send shows its amount to four decimal places", () => {
test("for a short amount", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
expect(text("confirm-amount")).toBe("0.5000 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
expect(broadcastTx().value).toBe(parseEther("0.5"));
});
// Send accepts both. The approval screen writes the same value `0.5000`.
test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => {
await openSend("ETH");
el("send-amount").value = typed;
await review();
expect(text("confirm-amount")).toBe("0.5000 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
expect(broadcastTx().value).toBe(parseEther("0.5"));
});
test("for a token amount typed as .5", async () => {
// 100 TOK.
await openSend(TOKEN, 100000000n);
el("send-amount").value = ".5";
await review();
expect(text("confirm-amount")).toBe("0.5000 TOK");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 TOK");
const [, amount] = transferIface.decodeFunctionData(
"transfer",
broadcastTx().data,
);
expect(amount).toBe(500000n);
});
test("for a Max amount with many decimals", async () => {
await openSend("ETH");
await el("btn-send-max").handlers.get("click")();
// The balance minus 21000 gas at 20 gwei.
expect(el("send-amount").value).toBe("1.234147890123456789");
await review();
expect(text("confirm-amount")).toBe("1.2341 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("1.2341 ETH");
expect(broadcastTx().value).toBe(BALANCE_WEI - 21000n * 20n * GWEI);
});
// Cut to four decimals it would read 0.0000, so it is shown to its first
// significant digit instead, and signed in full.
test("for a tiny nonzero amount", async () => {
await openSend("ETH");
el("send-amount").value = "0.000012345";
await review();
expect(text("confirm-amount")).toBe("0.00001 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.00001 ETH");
expect(broadcastTx().value).toBe(parseEther("0.000012345"));
});
test("for a token amount", async () => {
// 100 TOK.
await openSend(TOKEN, 100000000n);
el("send-amount").value = "12.345678";
await review();
expect(text("confirm-amount")).toBe("12.3456 TOK");
await signAndSend();
expect(text("wait-tx-summary")).toBe("12.3456 TOK");
const tx = broadcastTx();
expect(tx.to).toBe(getAddress(TOKEN));
const [to, amount] = transferIface.decodeFunctionData(
"transfer",
tx.data,
);
expect(to).toBe(RECIPIENT);
expect(amount).toBe(12345678n);
});
test("on the success screen", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
await signAndSend();
// A popup opened later resumes the wait, and the receipt is there.
mockNode.receipt = { blockNumber: 21000000 };
txStatus.restoreWait();
await settle();
expect(state.currentView).toBe("success-tx");
expect(text("success-tx-summary")).toBe("0.5000 ETH");
});
test("on the error screen", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
mockNode.broadcastError = new Error("the node refused it");
await signAndSend();
expect(state.currentView).toBe("error-tx");
expect(text("error-tx-summary")).toBe("0.5000 ETH");
});
});
+167 -6
View File
@@ -11,20 +11,115 @@
//
// 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.
// and sendTransaction() records whether the send actually happened.
//
// The last test drives the real confirmation screen instead, with only the
// node and the DOM stubbed: a gas limit over its own bound at an ordinary fee
// is refused in the same box as a fee over the bound
// (https://git.eeqj.de/sneak/AutistMask/issues/583). That the box moves
// nothing on the screen is checked by the Chrome e2e suite.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
// What the stub node quotes for that test: a gas limit of 150,000,000 at a
// maximum fee of 2 gwei per gas, 0.3 ETH in all. And the signed transactions
// it was handed.
const mockNode = {
gasLimit: 150000000n,
maxFeePerGas: 2000000000n,
broadcast: [],
};
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.network = network;
}
async getNetwork() {
return this.network;
}
async getBalance() {
return actual.parseEther("5");
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getFeeData() {
return {
maxFeePerGas: mockNode.maxFeePerGas,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 1000000000n,
};
}
async estimateGas() {
return mockNode.gasLimit;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
// The screens a send that was not refused goes on to.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
querySelectorAll: () => [],
remove() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
};
const { Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const { populateVerifyAndSend } = confirmTx;
const {
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify");
@@ -98,3 +193,69 @@ describe("populateVerifyAndSend enforces the combined fee bound", () => {
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
});
});
describe("Sign & Send on the confirmation screen", () => {
const PRIVATE_KEY = "0x" + "11".repeat(32);
const PASSWORD = "correct horse battery staple";
let encryptedSecret;
beforeAll(async () => {
await loadState();
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
function el(id) {
return global.document.getElementById(id);
}
function shown(id) {
return el(id).style.visibility === "visible";
}
// show() reads the balance and estimates the fee without awaiting
// either; this lets both finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("refuses a gas limit over its bound at an ordinary fee in the box for a refused gas limit or fee, and sends nothing", async () => {
expect(mockNode.gasLimit).toBeGreaterThan(MAX_GAS_LIMIT);
expect(mockNode.gasLimit * mockNode.maxFeePerGas).toBeLessThan(
MAX_TOTAL_FEE,
);
const from = new Wallet(PRIVATE_KEY).address;
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: from }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.init({});
confirmTx.show({
from,
to: RECIPIENT,
ensName: null,
amount: "1",
token: "ETH",
tokenSymbol: null,
tokenDecimals: null,
max: false,
});
await settle();
expect(el("btn-confirm-send").disabled).toBe(false);
expect(shown("confirm-fee-bound-error")).toBe(false);
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
expect(shown("confirm-fee-bound-error")).toBe(true);
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
});
});
+221
View File
@@ -0,0 +1,221 @@
// A late answer for a confirmation screen the user has left changes nothing on
// the screen shown since (https://git.eeqj.de/sneak/AutistMask/issues/575).
//
// The confirmation screen asks the node and the explorer about its transaction
// without waiting for the answers: the recipient checks, which can show three
// warning boxes, the balance read and the fee estimate. Back, a new recipient
// and Review again show the screen for another transaction while those
// questions may still be open. The recipient checks showed their boxes
// whenever they answered, so a box about the first recipient could appear on
// the second recipient's screen.
//
// Driven through the real confirmation screen, with only the node, the
// explorer and the DOM stubbed. Each test holds back the first screen's answer
// to one question, and gives it once the screen it tests is shown.
"use strict";
// The From address of every transaction here. The jest.mock() factory below
// may only name outside variables whose names start with "mock".
const mockFrom = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// Every answer is a mock, so a test can hold one back. Every address has
// sent one transaction and has no code, and only the From address holds
// anything: 5 ETH.
class StubProvider {}
Object.assign(StubProvider.prototype, {
getBalance: jest.fn(async (address) =>
address === mockFrom ? actual.parseEther("5") : 0n,
),
getCode: jest.fn(async () => "0x"),
getTransactionCount: jest.fn(async () => 1),
// 10 gwei expected, 20 gwei reserved per gas.
getFeeData: jest.fn(async () => ({
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
})),
estimateGas: jest.fn(async () => 21000n),
});
return { ...actual, JsonRpcProvider: StubProvider };
});
// An explorer address page whose title is `title`.
function explorerPage(title) {
return { ok: true, text: async () => "<title>" + title + "</title>" };
}
// The explorer labels no address.
global.fetch = jest.fn(async () => explorerPage("Etherscan"));
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
addEventListener() {},
querySelectorAll: () => [],
remove() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
};
const { JsonRpcProvider, parseEther } = require("ethers");
const { loadState } = require("../src/shared/state");
const confirmTx = require("../src/popup/views/confirmTx");
const node = JsonRpcProvider.prototype;
const FIRST = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SECOND = "0xE2E0000000000000000000000000000000000E2e";
function el(id) {
return global.document.getElementById(id);
}
function shown(id) {
return el(id).style.visibility === "visible";
}
// Show the confirmation screen for 1 ETH to `to` on mainnet, as Review on the
// Send screen does.
function review(to) {
confirmTx.show({
from: mockFrom,
to,
ensName: null,
amount: "1",
token: "ETH",
tokenSymbol: null,
tokenDecimals: null,
max: false,
});
}
// show() does not wait for its questions; this lets every answer not held
// back arrive.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// Hold back the next answer `question` gives. Returns the function that gives
// it.
function holdNextAnswer(question) {
let give;
question.mockImplementationOnce(() => new Promise((r) => (give = r)));
return (answer) => give(answer);
}
beforeAll(async () => {
await loadState();
});
beforeEach(() => {
elements.clear();
});
// The three boxes the recipient checks show, each with the question whose
// answer shows it for FIRST, and that answer. FIRST holds no ETH, so the
// answer that it has sent no transaction is what shows the box saying so.
describe.each([
{
box: "confirm-contract-warning",
question: node.getCode,
answer: "0x6080604052",
},
{
box: "confirm-recipient-warning",
question: node.getTransactionCount,
answer: 0,
},
{
box: "confirm-etherscan-warning",
question: global.fetch,
answer: explorerPage(
"Fake_Phishing1 | Address: " + FIRST + " | Etherscan",
),
},
])("the $box box", ({ box, question, answer }) => {
test("is shown when the answer about the recipient arrives while its screen is shown", async () => {
const give = holdNextAnswer(question);
review(FIRST);
await settle();
expect(shown(box)).toBe(false);
give(answer);
await settle();
expect(shown(box)).toBe(true);
});
test("is not shown on the next recipient's screen when the answer about the first arrives after that screen is shown", async () => {
const give = holdNextAnswer(question);
review(FIRST);
await settle();
review(SECOND);
await settle();
expect(shown(box)).toBe(false);
give(answer);
await settle();
expect(shown(box)).toBe(false);
});
});
test("a balance read answered after the next screen is shown leaves that screen's balance and balance check as they are", async () => {
const give = holdNextAnswer(node.getBalance);
review(FIRST);
await settle();
review(SECOND);
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
give(parseEther("0.5"));
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("btn-confirm-send").disabled).toBe(false);
});
test("a fee estimate answered after the next screen is shown leaves that screen's fee as it is", async () => {
const give = holdNextAnswer(node.getFeeData);
review(FIRST);
await settle();
review(SECOND);
await settle();
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
// 100 times the fee the second screen was quoted.
give({
maxFeePerGas: 2000000000000n,
maxPriorityFeePerGas: 100000000000n,
gasPrice: 1000000000000n,
});
await settle();
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
expect(el("confirm-fee-reserve").textContent).toBe(
"up to 0.0004 ETH reserved",
);
});
+536 -18
View File
@@ -7,25 +7,85 @@
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// Settings in between.
//
// The same holds when the popup was closed after the switch and reopened, and
// Back drew the screen again from the transaction it saved
// (https://git.eeqj.de/sneak/AutistMask/issues/586): the screen was drawn for
// the new network, and Sign & Send sent the reviewed transfer there. Drawn
// again, the screen describes the transaction entirely on the network it was
// reviewed for, because choosing that network again in Settings and pressing
// Back only unhides it, and Sign & Send then sends from it.
//
// Its balance line and balance check are that network's balance, read from
// that network's node each time the screen is drawn
// (https://git.eeqj.de/sneak/AutistMask/issues/558), not the balance Send
// handed over. The fee estimate after the read stays on that network when
// the network is switched while the read runs.
//
// Driven through the real confirmation screen, signer and chain switch, with
// only the node and the DOM stubbed. The stub node is on the network its
// provider was built for, so a broadcast records the network it went to.
// only the node, fetch and the DOM stubbed. The stub node is on the network
// its provider was built for, so a broadcast records the network it went to,
// and a balance read answers with that network's balance.
"use strict";
// The signed transactions the stub node was handed, with its network's chain id.
const mockNode = { broadcast: [] };
// The questions the stub node was asked for the balance, the fee and the
// recipient checks, by method, network name and RPC URL, in order. The
// balances it reports on each network, by network name: ETH in wei and the
// token in its base units. The signed transactions it was handed, with its
// network's chain id and RPC URL. And whether it quotes fees as a network
// with no type-2 pricing does.
const mockNode = {
asked: [],
balanceWei: {},
tokenUnits: {},
broadcast: [],
legacyFees: false,
};
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// The selector of balanceOf(address).
const BALANCE_OF = "0x70a08231";
class StubProvider {
constructor(url, network) {
this.url = url;
this.network = network;
}
record(method) {
mockNode.asked.push({
method,
network: this.network.name,
url: this.url,
});
}
async getNetwork() {
return this.network;
}
async getBalance() {
this.record("getBalance");
return mockNode.balanceWei[this.network.name];
}
// balanceOf() is the one contract call the screen makes before Sign
// & Send.
async call(tx) {
if (!tx.data.startsWith(BALANCE_OF)) {
throw new Error("the stub node answers only balanceOf()");
}
this.record("balanceOf");
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits[this.network.name]],
);
}
async getFeeData() {
this.record("getFeeData");
if (mockNode.legacyFees) {
return {
maxFeePerGas: null,
maxPriorityFeePerGas: null,
gasPrice: 10000000000n,
};
}
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
@@ -36,16 +96,22 @@ jest.mock("ethers", () => {
return 21000n;
}
async getCode() {
this.record("getCode");
return "0x";
}
async getTransactionCount() {
this.record("getTransactionCount");
return 1;
}
async lookupAddress() {
return null;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push({ chainId: this.network.chainId, signed });
mockNode.broadcast.push({
chainId: this.network.chainId,
url: this.url,
signed,
});
return { hash: "0x" + "ab".repeat(32) };
}
}
@@ -75,6 +141,8 @@ jest.mock("../src/popup/views/txStatus", () => ({
showError: jest.fn(),
}));
// The Etherscan label check is its only caller here: the explorer pages it
// asks for are recorded, and nothing is fetched.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
@@ -123,17 +191,35 @@ global.document = {
};
global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers");
const {
BaseWallet,
JsonRpcProvider,
Transaction,
Wallet,
parseEther,
} = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const { state, loadState, saveState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { NETWORKS } = require("../src/shared/networks");
const { prices } = require("../src/shared/prices");
const {
goBack,
pushCurrentView,
setBackRenderer,
showView,
} = require("../src/popup/views/helpers");
const {
makeBackRenderer,
resetRenderedViews,
} = require("../src/popup/viewRouter");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const TOKEN = "0x7070707070707070707070707070707070707070";
const PASSWORD = "correct horse battery staple";
function el(id) {
@@ -150,7 +236,15 @@ beforeAll(async () => {
beforeEach(() => {
elements.clear();
mockNode.asked = [];
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("5"),
};
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
mockNode.broadcast = [];
mockNode.legacyFees = false;
global.fetch.mockClear();
txStatus.showWait.mockClear();
txStatus.showError.mockClear();
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
@@ -160,16 +254,22 @@ afterEach(() => {
signTransaction.mockRestore();
});
// Show the confirmation screen for 1 of the native token on `networkId`, as
// Review on the Send screen does, with its fee estimate settled.
async function review(networkId) {
// Show the confirmation screen for 1 of the native token on `networkId`, or
// for the transfer `changes` makes of it, as Review on the Send screen does,
// with its balance read and its fee estimate settled.
async function review(networkId, changes = {}) {
await onChainSwitch(networkId);
await reviewOnActiveNetwork(changes);
}
// review() on the network already active, without switching to it again.
async function reviewOnActiveNetwork(changes = {}) {
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER, balance: "5.0" }],
addresses: [{ address: HOLDER }],
},
];
state.selectedWallet = 0;
@@ -181,15 +281,108 @@ async function review(networkId) {
ensName: null,
amount: "1",
token: "ETH",
balance: "5.0",
tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null,
max: false,
...changes,
});
await settle();
}
// The user's own node for a network, set in Settings.
function ownNode(networkId) {
return "https://" + networkId + ".node.example.invalid";
}
// After review() on `reviewedOn`: the user's own node set for it, the Settings
// gear, `switchedTo` chosen there, the popup closed and reopened onto
// Settings, and Back, which draws the confirmation screen again from the
// transaction it saved. The reopened page holds an ETH price of $2,000 and a
// USDC price of $1 whichever network is active, so each USD value shows which
// network it was priced for.
async function switchReopenAndGoBack(reviewedOn, switchedTo) {
state.rpcUrl = ownNode(reviewedOn);
pushCurrentView();
showView("settings");
await onChainSwitch(switchedTo);
await loadState();
elements.clear();
resetRenderedViews();
setBackRenderer(makeBackRenderer(state, { confirmTx }));
confirmTx.init({});
prices.ETH = 2000;
prices.USDC = 1;
mockNode.asked = [];
global.fetch.mockClear();
goBack();
await settle();
}
// The USD value `text` as the screen appends it for `networkId`: nothing on a
// testnet, which has no USD values.
function usdOn(networkId, text) {
return NETWORKS[networkId].isTestnet ? "" : " (" + text + ")";
}
// The screen for the transfer review() shows by default, described on
// `networkId`: in its native currency, priced by it, linking to its explorer.
function expectNativeTransferOn(networkId) {
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[networkId];
expect(el("confirm-type").textContent).toBe(
"Native " + symbol + " transfer",
);
expect(el("confirm-amount").textContent).toBe(
"1.0000 " + symbol + usdOn(networkId, "$2,000.00"),
);
expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
);
expect(el("confirm-fee-amount").textContent).toBe(
"~0.0002 " + symbol + usdOn(networkId, "$0.42"),
);
expect(el("confirm-fee-reserve").textContent).toBe(
"up to 0.0004 " + symbol + " reserved",
);
expect(el("confirm-gas-error").textContent).toBe(
"You do not have enough " +
symbol +
" to pay the network fee for this transfer. Please add " +
symbol +
" to this address and try again.",
);
expect(el("confirm-recipient-warning").textContent).toContain(
" holds no " + symbol + ". ",
);
expect(el("confirm-contract-warning").textContent).toContain(
" Sending " + symbol + " or tokens ",
);
expect(el("confirm-from").innerHTML).toContain(
`href="${explorerUrl}/address/${HOLDER}"`,
);
expect(el("confirm-to").innerHTML).toContain(
`href="${explorerUrl}/address/${RECIPIENT}"`,
);
}
// show() reads the balance and then estimates the fee, and checks the
// recipient, without awaiting any of them; this lets all of them finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
function errors() {
return el("confirm-errors").innerHTML;
}
function shown(id) {
return el(id).style.visibility === "visible";
}
function sendDisabled() {
return el("btn-confirm-send").disabled;
}
async function signAndSend() {
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
@@ -208,6 +401,25 @@ test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () =>
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
});
// Saving an RPC endpoint in Settings (src/popup/views/settings.js) sets only
// state.rpcUrl: the endpoint remembered for the active network keeps its old
// value until the next switch away from it.
test("a transfer reviewed after the active network's RPC endpoint was changed in Settings asks the new endpoint", async () => {
const newNode = "https://new.node.example.invalid";
await onChainSwitch("sepolia");
state.rpcUrl = newNode;
await saveState();
await reviewOnActiveNetwork();
expect(mockNode.asked).toEqual([
{ method: "getBalance", network: "sepolia", url: newNode },
{ method: "getCode", network: "sepolia", url: newNode },
{ method: "getTransactionCount", network: "sepolia", url: newNode },
{ method: "getFeeData", network: "sepolia", url: newNode },
]);
});
describe.each([
["sepolia", "mainnet", "SepoliaETH"],
["mainnet", "sepolia", "ETH"],
@@ -228,11 +440,219 @@ describe.each([
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").style.visibility).toBe("visible");
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
expect(shown("confirm-network-changed-error")).toBe(true);
expect(shown("confirm-fee-bound-error")).toBe(false);
});
},
);
describe.each([
["sepolia", "mainnet"],
["mainnet", "sepolia"],
])(
"a transfer reviewed on %s, with %s chosen in Settings, the popup reopened and Back pressed",
(reviewedOn, switchedTo) => {
const { nativeCurrency: symbol, explorerUrl } = NETWORKS[reviewedOn];
test("is described on the network it was reviewed for, and neither signed nor sent", async () => {
await review(reviewedOn);
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(state.currentView).toBe("confirm-tx");
expectNativeTransferOn(reviewedOn);
expect(mockNode.asked).toEqual([
{
method: "getBalance",
network: reviewedOn,
url: ownNode(reviewedOn),
},
{
method: "getCode",
network: reviewedOn,
url: ownNode(reviewedOn),
},
{
method: "getTransactionCount",
network: reviewedOn,
url: ownNode(reviewedOn),
},
{
method: "getFeeData",
network: reviewedOn,
url: ownNode(reviewedOn),
},
]);
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([
explorerUrl + "/address/" + RECIPIENT,
]);
expect(el("btn-confirm-send").disabled).toBe(false);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(shown("confirm-network-changed-error")).toBe(true);
});
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
await review(reviewedOn);
await switchReopenAndGoBack(reviewedOn, switchedTo);
// The Settings gear, the reviewed network chosen, and Back, which
// only unhides the screen as it was drawn.
pushCurrentView();
showView("settings");
await onChainSwitch(reviewedOn);
goBack();
expect(state.currentView).toBe("confirm-tx");
expectNativeTransferOn(reviewedOn);
await signAndSend();
const chainId = BigInt(NETWORKS[reviewedOn].chainId);
expect(mockNode.broadcast).toHaveLength(1);
expect(mockNode.broadcast[0].chainId).toBe(chainId);
expect(mockNode.broadcast[0].url).toBe(ownNode(reviewedOn));
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
chainId,
);
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
});
test("for more than the balance, says so in that network's native currency", async () => {
await review(reviewedOn, { amount: "10" });
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(shown("confirm-balance-error")).toBe(true);
expect(el("confirm-balance-error").textContent).toBe(
"Your " +
symbol +
" balance does not cover this amount. Please go back and" +
" send a smaller amount.",
);
});
test("states and checks that network's balance as it is then, which no longer covers the send", async () => {
await review(reviewedOn);
expect(sendDisabled()).toBe(false);
mockNode.balanceWei = {
[reviewedOn]: parseEther("0.5"),
[switchedTo]: parseEther("5"),
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe(
"0.5000 " + symbol + usdOn(reviewedOn, "$1,000.00"),
);
expect(shown("confirm-balance-error")).toBe(true);
expect(sendDisabled()).toBe(true);
});
test("states and checks that network's balance as it is then, which now covers the send, and refuses to send it", async () => {
mockNode.balanceWei[reviewedOn] = parseEther("0.5");
await review(reviewedOn);
expect(sendDisabled()).toBe(true);
mockNode.balanceWei = {
[reviewedOn]: parseEther("5"),
[switchedTo]: parseEther("0.5"),
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(reviewedOn, "$10,000.00"),
);
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-amount-fee-error")).toBe(false);
expect(sendDisabled()).toBe(false);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(shown("confirm-network-changed-error")).toBe(true);
});
const tokenSend = {
token: TOKEN,
tokenSymbol: "TOK",
tokenDecimals: 6,
};
test("of a token, states and checks that network's token balance as it is then, which no longer covers the send", async () => {
mockNode.tokenUnits[reviewedOn] = 10000000n;
await review(reviewedOn, tokenSend);
expect(sendDisabled()).toBe(false);
mockNode.tokenUnits = {
[reviewedOn]: 500000n,
[switchedTo]: 10000000n,
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe("0.5000 TOK");
expect(shown("confirm-balance-error")).toBe(true);
expect(el("confirm-balance-error").textContent).toBe(
"Your TOK balance does not cover this amount. Please go back" +
" and send a smaller amount.",
);
expect(sendDisabled()).toBe(true);
});
test("of a token, states and checks that network's token balance as it is then, which now covers the send, and refuses to send it", async () => {
await review(reviewedOn, tokenSend);
expect(sendDisabled()).toBe(true);
mockNode.tokenUnits = {
[reviewedOn]: 10000000n,
[switchedTo]: 0n,
};
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-balance").textContent).toBe("10.0000 TOK");
expect(errors()).toBe("");
expect(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-gas-error")).toBe(false);
expect(sendDisabled()).toBe(false);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(shown("confirm-network-changed-error")).toBe(true);
});
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
await review(reviewedOn);
mockNode.legacyFees = true;
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-fee-amount").textContent).toBe(
"0.0002 " + symbol + usdOn(reviewedOn, "$0.42"),
);
});
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
mockNode.tokenUnits[reviewedOn] = 50000000n;
await review(reviewedOn, {
token: TOKEN,
tokenSymbol: "USDC",
tokenDecimals: 6,
amount: "10",
});
await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-type").textContent).toBe(
"ERC-20 token transfer (USDC)",
);
expect(el("confirm-token-contract").innerHTML).toContain(
`href="${explorerUrl}/address/${TOKEN}"`,
);
expect(el("confirm-amount").textContent).toBe(
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
);
expect(el("confirm-balance").textContent).toBe(
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
);
});
},
@@ -257,3 +677,101 @@ test("the send is refused by a signer on another network's node", async () => {
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
});
describe("the balance read", () => {
test("leaves the balance line Loading... and Sign & Send disabled until it returns", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
expect(el("confirm-balance").textContent).toBe("Loading...");
expect(el("confirm-fee-amount").textContent).toBe("Estimating...");
expect(el("confirm-errors").style.visibility).toBe("hidden");
expect(shown("confirm-balance-error")).toBe(false);
expect(shown("confirm-balance-read-error")).toBe(false);
expect(sendDisabled()).toBe(true);
answer(parseEther("5"));
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH");
expect(sendDisabled()).toBe(false);
});
// The Chrome suite measures that nothing moves; this checks the message
// is already written, only hidden, before the read returns.
test("that finds the amount not covered only makes a message written before it visible", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
const message =
"Your SepoliaETH balance does not cover this amount. Please go" +
" back and send a smaller amount.";
expect(el("confirm-balance-error").textContent).toBe(message);
expect(shown("confirm-balance-error")).toBe(false);
answer(parseEther("0.5"));
await settle();
expect(el("confirm-balance-error").textContent).toBe(message);
expect(shown("confirm-balance-error")).toBe(true);
expect(errors()).toBe("");
expect(sendDisabled()).toBe(true);
});
// The fee is estimated once the read returns, after the switch, and still
// asks the node of the network the screen was drawn for.
test("still in flight when Sepolia is chosen in Settings leaves the balance and the fee on mainnet", async () => {
let answer;
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
try {
await review("mainnet");
} finally {
getBalance.mockRestore();
}
const feeAskedOn = () =>
mockNode.asked
.filter((q) => q.method === "getFeeData")
.map((q) => q.network);
expect(feeAskedOn()).toEqual([]);
await onChainSwitch("sepolia");
answer(parseEther("5"));
await settle();
expect(feeAskedOn()).toEqual(["mainnet"]);
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
// 21000 gas at 10 gwei expected, at 20 gwei reserved.
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
expect(el("confirm-fee-reserve").textContent).toBe(
"up to 0.0004 ETH reserved",
);
});
test("that fails states the balance as unknown, not zero, and blocks sending", async () => {
const getBalance = jest
.spyOn(JsonRpcProvider.prototype, "getBalance")
.mockRejectedValueOnce(new Error("the node did not answer"));
try {
await review("sepolia");
} finally {
getBalance.mockRestore();
}
expect(el("confirm-balance").textContent).toBe("unknown (SepoliaETH)");
expect(shown("confirm-balance-read-error")).toBe(true);
expect(shown("confirm-balance-error")).toBe(false);
expect(errors()).toBe("");
expect(sendDisabled()).toBe(true);
});
});
+354
View File
@@ -0,0 +1,354 @@
// A site allowed without "Remember my choice for this site" stays connected
// when the background worker is stopped and started again
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
//
// Chrome stops the worker after about 30 seconds idle and evaluates the
// module afresh on the next event, so nothing held at module scope survives
// it. Such a connection used to be held exactly there, and so lasted until the
// first idle spell. Each test here makes the connection in one load of the
// background, loads the module afresh over the same storage, as the browser
// does, and asks the new one. The paths that end a connection are driven after
// that restart, so each is shown to end what the earlier worker stored.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const {
removeAddressFromState,
removeWalletFromState,
} = require("../src/shared/walletDelete");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const SITE = "https://dapp.example";
const OTHER_SITE = "https://other.example";
const EXT_URL = "chrome-extension://autistmask/";
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
// One wallet with two addresses, so that one of them can be removed, and no
// remembered sites, so that every connection here is one made without
// "Remember".
function storedProfile() {
const mainnet = networkById("mainnet");
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
{ address: OTHER_ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: mainnet.defaultRpcUrl,
blockscoutUrl: mainnet.defaultBlockscoutUrl,
allowedSites: {},
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
jest.resetModules();
});
// Load the background module afresh, as the browser does when it starts the
// worker, over `storage`. The storage stub stands in for the browser's own
// storage, local and session, so it outlives each load.
function startWorker(storage) {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
let messageListener = null;
let connectListener = null;
// Every URL the toolbar popup was set to open: a site's connection prompt
// is shown there, so this is where its approval id appears.
const toolbarPopups = [];
const windowsCreated = [];
const sentToTabs = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
getPlatformInfo: (cb) => cb({ os: "linux" }),
// What the popup sends reaches the worker from the extension's own
// page.
sendMessage: (msg, callback) => {
messageListener(msg, FROM_POPUP, callback || (() => {}));
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => {
windowsCreated.push(options);
cb({ id: windowsCreated.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push(msg);
if (cb) cb();
},
},
action: {
setPopup: (o) => {
toolbarPopups.push(o.popup);
},
openPopup: () => Promise.resolve(),
},
};
require("../src/background/index");
// A request from a site, answered once the worker has had time to answer.
async function rpc(method, origin, params) {
let response = null;
messageListener(
{ type: "AUTISTMASK_RPC", method, params: params || [] },
{ origin },
(r) => {
response = r;
},
);
await settle();
return response;
}
// A message from the popup, and the worker's answer to it.
async function fromPopup(msg) {
let response;
messageListener(msg, FROM_POPUP, (r) => {
response = r;
});
await settle();
return response;
}
// The site asks to connect, and the user allows it in the prompt with
// "Remember my choice for this site" left unchecked.
async function connectWithoutRemember(origin) {
let response = null;
messageListener(
{ type: "AUTISTMASK_RPC", method: "eth_requestAccounts" },
{ origin },
(r) => {
response = r;
},
);
await settle();
const id = new URL(toolbarPopups.at(-1), EXT_URL).searchParams.get(
"approval",
);
const decisionListeners = [];
const port = {
name: "approval:" + id,
sender: { url: EXT_URL + "src/popup/index.html?approval=" + id },
onMessage: { addListener: (fn) => decisionListeners.push(fn) },
onDisconnect: { addListener: () => {} },
};
connectListener(port);
for (const fn of decisionListeners) {
fn(
{
type: "AUTISTMASK_APPROVAL_DECISION",
approved: true,
remember: false,
},
port,
);
}
await settle();
return response;
}
return {
rpc,
fromPopup,
connectWithoutRemember,
windowsCreated,
sentToTabs,
};
}
// A site connected without "Remember" in one worker, and the worker started
// again over the same storage, where the site is still connected.
async function connectedThenRestarted() {
const storage = makeStorageStub({ autistmask: storedProfile() });
const first = startWorker(storage);
expect(await first.connectWithoutRemember(SITE)).toEqual({
result: [ADDRESS],
});
const worker = startWorker(storage);
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
return { storage, worker };
}
describe("a site allowed without Remember, after the worker restarts", () => {
test("is still told the address, and is not asked again", async () => {
const { worker } = await connectedThenRestarted();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
expect(await worker.rpc("eth_requestAccounts", SITE)).toEqual({
result: [ADDRESS],
});
});
test("is still listed under Connected Sites in Settings", async () => {
const { worker } = await connectedThenRestarted();
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([SITE]);
});
test("may still ask for a signature", async () => {
// Refused with 4100 before the fix. Allowed, it raises the signing
// prompt and waits on the user.
const { worker } = await connectedThenRestarted();
const response = await worker.rpc("personal_sign", SITE, [
"0x48656c6c6f",
ADDRESS,
]);
expect(response).toBeNull();
expect(worker.windowsCreated).toHaveLength(1);
});
test("is not connected after the browser restarts", async () => {
// The browser empties session storage when it closes; the wallet's
// own storage keeps nothing of the connection.
const { storage } = await connectedThenRestarted();
const afterBrowserRestart = startWorker(
makeStorageStub({ autistmask: storage.read("autistmask") }),
);
expect(await afterBrowserRestart.rpc("eth_accounts", SITE)).toEqual({
result: [],
});
});
});
describe("each way a connection allowed without Remember ends, after the worker restarts", () => {
test("removing the site in Settings", async () => {
const { worker } = await connectedThenRestarted();
expect(await worker.connectWithoutRemember(OTHER_SITE)).toEqual({
result: [ADDRESS],
});
await worker.fromPopup({
type: "AUTISTMASK_REMOVE_SITE",
origin: SITE,
});
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
expect(worker.sentToTabs).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
]);
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([OTHER_SITE]);
expect(await worker.rpc("eth_accounts", OTHER_SITE)).toEqual({
result: [ADDRESS],
});
});
test("switching to another address", async () => {
// Switched away and back again, so the answer turns on the connection
// having ended rather than on which address is active.
const { storage, worker } = await connectedThenRestarted();
const switched = { ...storage.read("autistmask") };
switched.activeAddress = OTHER_ADDRESS;
storage.write("autistmask", switched);
await worker.fromPopup({ type: "AUTISTMASK_ACTIVE_CHANGED" });
switched.activeAddress = ADDRESS;
storage.write("autistmask", switched);
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([]);
});
// The two below ask while storage still names the removed address as
// active, because the popup has not saved yet, so the answer turns on the
// connection alone.
test("removing the connected address", async () => {
const { storage, worker } = await connectedThenRestarted();
const popupState = storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
});
test("removing the wallet holding the connected address", async () => {
const { storage, worker } = await connectedThenRestarted();
removeWalletFromState(storage.read("autistmask"), 0);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
});
test("removing a different address leaves it connected", async () => {
const { storage, worker } = await connectedThenRestarted();
const popupState = storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
});
});
+30
View File
@@ -259,6 +259,36 @@ describe("reaching the screen", () => {
});
});
// Both screens tell the user what they need to get the wallet back, and a
// key or xprv wallet has no recovery phrase: telling its owner to rely on
// one sends them looking for words that do not exist
// (https://git.eeqj.de/sneak/AutistMask/issues/529).
describe("what the screens say the wallet's backup is", () => {
test.each([
["hd", "recovery phrase"],
["key", "private key"],
["xprv", "extended private key"],
])(
"a wallet of type %s is told about its %s",
async (type, keyMaterial) => {
const { deleteWallet, state } = load();
state.wallets[1].type = type;
deleteWallet.show(1);
expect(state.currentView).toBe("delete-wallet-confirm");
expect(node("delete-wallet-key-material").textContent).toBe(
keyMaterial,
);
await click("btn-delete-wallet-lost-password");
expect(state.currentView).toBe(VIEW);
expect(node("delete-wallet-lost-key-material").textContent).toBe(
keyMaterial,
);
},
);
});
describe("the typed confirmation", () => {
test("a name that is not the wallet's deletes nothing", async () => {
const { deleteWallet, state, storage } = load();
+35
View File
@@ -394,6 +394,26 @@ async function waitForApprovalWindow(d, timeout = 30000) {
}
}
// Firefox keeps one zoom per site, so a zoom set on the popup's tab holds for
// every page of the extension, the approval windows included.
async function setExtensionZoom(env, zoom) {
const d = env.driver;
await d.switchToWindow(env.popupWindow);
const outcome = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const zoom = arguments[0];
browser.tabs
.getCurrent()
.then((tab) => browser.tabs.setZoom(tab.id, zoom))
.then(() => done({}), (e) => done({ error: String(e) }));`,
[zoom],
);
assert(
outcome && !outcome.error,
"could not set the extension's zoom: " + (outcome && outcome.error),
);
}
function startRequest(d, key, method, params) {
return d.execute(
"window.__dapp.start(arguments[0], arguments[1], arguments[2]);" +
@@ -633,6 +653,14 @@ step(
"personal_sign returns a signature that recovers to the address",
async (env) => {
const d = env.driver;
// The window is opened as wide as the page, and widened once the
// screen is up if it still scrolls sideways
// (https://git.eeqj.de/sneak/AutistMask/issues/518). This suite is
// where that is checked: the Chrome suite's approval windows open at
// 1280x720 whatever width the extension asks for. At 170%, one of
// Firefox's zoom steps, the page is drawn a little larger than the
// 1.7 Firefox reports, so a single widening leaves it a pixel short.
await setExtensionZoom(env, 1.7);
await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign", "personal_sign", [SIGN_HEX, env.address]);
@@ -640,6 +668,12 @@ step(
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign");
await d.waitFor(
"the sign prompt to fit its window without scrolling sideways",
"return document.documentElement.scrollWidth <=" +
" document.documentElement.clientWidth;",
);
const screen = await d.execute(
`return {
origin: document.getElementById("approve-sign-origin").textContent,
@@ -674,6 +708,7 @@ step(
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "sign");
await setExtensionZoom(env, 1);
assert(
outcome.settled === "resolved",
"personal_sign did not resolve: " + JSON.stringify(outcome),
+30 -13
View File
@@ -226,8 +226,9 @@ const RPC_RESULTS = {
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the
// reserve and the estimate collapse to the same number, and the gate tests
// stop being able to distinguish them.
function latestBlock() {
// stop being able to distinguish them. opts.baseFeeWei replaces its base fee,
// read at request time like every other fixture switch.
function latestBlock(opts) {
return {
hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32),
@@ -239,21 +240,26 @@ function latestBlock() {
gasUsed: "0xf4240",
miner: STUB_COUNTERPARTY,
extraData: "0x",
baseFeePerGas: hex(BASE_FEE_WEI),
baseFeePerGas: opts.baseFeeWei || hex(BASE_FEE_WEI),
transactions: [],
};
}
// keccak("decimals()")[0:4], and the same for symbol() and name().
// keccak("decimals()")[0:4], and the same for symbol(), name() and
// balanceOf(address).
const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03";
const SELECTOR_BALANCE_OF = "0x70a08231";
// Every eth_call still answers with a zero word except decimals(), symbol()
// and name() on the stub token. The wallet reads decimals() back at signing
// time to compare with the scale the confirmation screen rendered (issue
// #305). Adding the token by its contract address reads all three (issue
// #295); symbol() and name() answer what the explorer reports for it.
// Every eth_call still answers with a zero word except decimals(), symbol(),
// name() and balanceOf() on the stub token. The wallet reads decimals() back
// at signing time to compare with the scale the confirmation screen rendered
// (issue #305). Adding the token by its contract address reads the first three
// (issue #295); symbol() and name() answer what the explorer reports for it.
// The confirmation screen reads balanceOf()
// (https://git.eeqj.de/sneak/AutistMask/issues/558), which answers the holding
// the explorer reports, or zero when no holding is seeded.
//
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through
@@ -278,6 +284,9 @@ function ethCallResult(req, opts) {
if (data.startsWith(SELECTOR_NAME)) {
return abi.encode(["string"], [tokenObject(opts).name]);
}
if (data.startsWith(SELECTOR_BALANCE_OF) && opts.seedTokenBalance) {
return word(tokenBalanceItems(opts)[0].value);
}
return ZERO_WORD;
}
@@ -420,9 +429,10 @@ const HOLD_MAX_MS = 30000;
// Hold a reply open for as long as the test asks: until opts[name] is false.
//
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read
// here rather than captured, so a test flips it on the same options object the
// route was registered with — the same pattern as seedTokenTransfer. This is
// The switch (holdBalance, holdGasEstimate, holdTransactionCount or
// holdBlockscout) is read here rather than captured, so a test flips it on the
// same options object the route was registered with — the same pattern as
// seedTokenTransfer. This is
// the only way to observe a screen while its request is genuinely in flight;
// sampling the screen and hoping to win a race against the network would
// assert nothing on a slow machine.
@@ -472,7 +482,7 @@ function rpcReply(req, opts, report) {
});
}
if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() });
return Object.assign(envelope, { result: latestBlock(opts) });
}
// The end of the dApp transaction round trip: the raw signed transaction
// the background hands to the node. It is recorded verbatim so a test can
@@ -557,6 +567,9 @@ async function handleRpc(route, postData, opts, report) {
report("unstubbed request: POST " + route.request().url());
return route.abort();
}
if (batch.some((req) => req.method === "eth_getBalance")) {
await awaitRelease(opts, "holdBalance", report);
}
if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, "holdGasEstimate", report);
}
@@ -613,8 +626,12 @@ function traceEnabled(raw) {
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
* defaults to zero, which is what every test that predates the funded
* fixture expects.
* @param {string} [opts.baseFeeWei] hex wei answered as the latest block's
* baseFeePerGas; defaults to BASE_FEE_WEI. Read at request time.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal.
* @param {boolean} [opts.holdBalance] hold every batch containing an
* eth_getBalance until this is cleared again.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again.
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an
+306 -36
View File
@@ -2232,7 +2232,8 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, and how tall the whole view is.
// fee block says, how tall the whole view is, and where the password field and
// Sign & Send sit in it.
async function confirmState(page) {
return page.evaluate(() => {
const el = (id) => document.getElementById(id);
@@ -2245,6 +2246,12 @@ async function confirmState(page) {
};
return {
height: el("view-confirm-tx").getBoundingClientRect().height,
passwordTop:
el("confirm-tx-password").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
sendTop:
el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
type: el("confirm-type").textContent.trim(),
balance: el("confirm-balance").textContent.trim(),
fee: el("confirm-fee-amount").textContent.trim(),
@@ -2253,9 +2260,18 @@ async function confirmState(page) {
errors: shown("confirm-errors")
? el("confirm-errors").textContent.trim()
: "",
balanceError: shown("confirm-balance-error")
? el("confirm-balance-error").textContent.trim()
: "",
amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"),
// Its sentence is wrapped across lines in index.html.
feeBoundError: shown("confirm-fee-bound-error")
? el("confirm-fee-bound-error")
.textContent.replace(/\s+/g, " ")
.trim()
: "",
sendDisabled: el("btn-confirm-send").disabled,
};
});
@@ -2271,6 +2287,16 @@ async function waitForEstimate(page) {
);
}
async function waitForBalance(page) {
await page.waitForFunction(
() =>
document.getElementById("confirm-balance").textContent.trim() !==
"Loading...",
null,
{ timeout: 60000 },
);
}
async function backToAddress(page) {
if (await page.isVisible("#view-confirm-tx")) {
await page.click("#btn-confirm-back");
@@ -2282,13 +2308,14 @@ async function backToAddress(page) {
await openAddressDetail(page);
}
// Drive the popup to the confirmation screen for one send.
// Drive the popup to the confirmation screen for one send, and return as soon
// as the screen is shown, its balance read still in flight.
//
// It waits for the send screen to be showing `balance`, the fixture's balance
// as that screen displays it, before filling anything in. Waiting for it —
// rather than for a refresh to have probably landed — is what keeps every
// assertion below deterministic after a fixture change.
async function goToConfirm(page, { token, balance, amount }) {
async function openConfirm(page, { token, balance, amount }) {
await backToAddress(page);
await page.click("#btn-send");
await visible(page, "#view-send");
@@ -2303,6 +2330,14 @@ 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");
}
// The same, returning once the screen has read the balance. It reads it
// before it estimates the fee, so every state below is read with the balance
// in place, the pending fee included.
async function goToConfirm(page, send) {
await openConfirm(page, send);
await waitForBalance(page);
await assertAddressesFit(page, "the confirmation screen");
}
@@ -2378,7 +2413,10 @@ test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async
"the estimate-failed message is shown for an estimate that is merely pending",
);
assert(
!st.amountFeeError && !st.gasError && st.errors === "",
!st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
"a balance message is shown before the fee is known",
);
});
@@ -2409,6 +2447,7 @@ test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238
);
assert(
st.errors === "" &&
st.balanceError === "" &&
!st.amountFeeError &&
!st.gasError &&
!st.feeUnknownError,
@@ -2475,30 +2514,54 @@ test("ConfirmTx gates on the fee RESERVE, not the displayed estimate (#238)", as
);
});
test("ConfirmTx refuses a send that exceeds the balance outright (#238)", async (env) => {
await goToConfirm(env.page, {
// The screen is first measured with its balance read held, so the message the
// read brings must already hold its space when the read returns (#558).
test("ConfirmTx refuses a send that exceeds the balance outright, and nothing moves when the balance arrives (#238)", async (env) => {
env.routeOpts.holdBalance = true;
let reading;
try {
await openConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: OVER_BALANCE_AMOUNT,
});
reading = await confirmState(env.page);
} finally {
env.routeOpts.holdBalance = false;
}
assert(
reading.balance === "Loading...",
"the balance read was not held: " + JSON.stringify(reading.balance),
);
await waitForBalance(env.page);
await waitForEstimate(env.page);
const st = await confirmState(env.page);
const want =
"Insufficient balance. You have " +
FUNDED_ETH_TEXT +
" ETH but are trying to send " +
OVER_BALANCE_AMOUNT +
" ETH.";
assert(
st.errors === want,
"wrong over-balance message: " + JSON.stringify(st.errors),
st.balance === FUNDED_ETH_TEXT + " ETH",
"the confirmation screen shows the wrong balance: " +
JSON.stringify(st.balance),
);
const want =
"Your ETH balance does not cover this amount. Please go back and" +
" send a smaller amount.";
assert(
st.balanceError === want,
"wrong over-balance message: " + JSON.stringify(st.balanceError),
);
assert(st.sendDisabled, "Send is enabled for a send that exceeds balance");
assert(
!st.amountFeeError,
"the amount-plus-fee message is shown for an amount that alone exceeds the balance",
);
assert(
st.sendTop === reading.sendTop,
"Sign & Send moved when the balance read returned: " +
reading.sendTop +
"px -> " +
st.sendTop +
"px",
);
});
test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => {
@@ -2542,7 +2605,10 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
"the estimate-failed message is not shown after a failed estimate",
);
assert(
!st.amountFeeError && !st.gasError && st.errors === "",
!st.amountFeeError &&
!st.gasError &&
st.errors === "" &&
st.balanceError === "",
"a balance message is shown for an estimate that simply failed",
);
assert(
@@ -2633,21 +2699,19 @@ test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", a
await waitForEstimate(env.page);
const st = await confirmState(env.page);
const want =
"Insufficient " +
STUB_TOKEN.symbol +
" balance. You have " +
TOKEN_BALANCE_TEXT +
" " +
STUB_TOKEN.symbol +
" but are trying to send " +
OVER_TOKEN_AMOUNT +
" " +
STUB_TOKEN.symbol +
".";
assert(
st.errors === want,
"wrong over-token-balance message: " + JSON.stringify(st.errors),
st.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"the confirmation screen shows the wrong token balance: " +
JSON.stringify(st.balance),
);
const want =
"Your " +
STUB_TOKEN.symbol +
" balance does not cover this amount. Please go back and send a" +
" smaller amount.";
assert(
st.balanceError === want,
"wrong over-token-balance message: " + JSON.stringify(st.balanceError),
);
assert(
st.sendDisabled,
@@ -2703,9 +2767,9 @@ test("ConfirmTx gates the ERC-20 fee on the RESERVE, with the ERC-20 message (#2
"the native-ETH over-budget message is shown on an ERC-20 transfer",
);
assert(
st.errors === "",
st.errors === "" && st.balanceError === "",
"a token-balance message is shown for a transfer the token balance covers: " +
JSON.stringify(st.errors),
JSON.stringify(st.errors + st.balanceError),
);
assert(
st.height === env.erc20PendingHeight,
@@ -2851,9 +2915,10 @@ async function goToTokenConfirm(env) {
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
const shown = await shownAmount(env.page);
assert(
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
shown === "0.2500 " + STUB_TOKEN.symbol,
"the confirmation screen is not showing the amount that was entered: " +
JSON.stringify(shown),
);
@@ -3003,6 +3068,210 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
}
});
// ------------------------- a send Sign & Send refuses moves nothing (#583)
//
// README, No Layout Shift. Sign & Send refuses a send on the confirmation
// screen when the active network changed since review and when its gas limit
// or fee is over its bound, and each refusal's sentence sits in a box that held
// its space from the first paint. This drives the total fee refusal, the longer
// of the two sentences.
// The base fee the stub node quotes once Sign & Send is pressed. ethers asks
// twice it plus the 1 gwei priority fee per gas, 50,001 gwei, so the 21,000-gas
// send would allow a fee of about 1.05 ETH, over the 1 ETH this wallet signs
// for (MAX_TOTAL_FEE in src/shared/approvalVerify.js).
const OVER_BOUND_BASE_FEE_WEI = 25000n * 10n ** 9n;
test("a send refused for its fee leaves the password field and Sign & Send where they were (#583)", async (env) => {
env.errors.expect("confirmTx logging the refused send", /send refused/);
try {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await waitForEstimate(env.page);
const before = await confirmState(env.page);
assert(
!before.sendDisabled && before.feeBoundError === "",
"the screen is not ready to send: " + JSON.stringify(before),
);
// Only now, with the screen drawn and its fee estimated, does the
// node quote a fee over the bound.
env.routeOpts.baseFeeWei = toHexWei(OVER_BOUND_BASE_FEE_WEI);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await env.page.waitForFunction(
() =>
getComputedStyle(
document.getElementById("confirm-fee-bound-error"),
).visibility === "visible",
null,
{ timeout: 60000 },
);
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused send: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send over the fee bound reached the RPC",
);
assert(
after.feeBoundError ===
"The gas limit or fee the RPC endpoint quoted for this" +
" transaction is more than this wallet will sign for, so" +
" it was not sent. Please go back and try again.",
"wrong fee refusal: " + JSON.stringify(after.feeBoundError),
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
} finally {
env.routeOpts.baseFeeWei = undefined;
await backToAddress(env.page);
}
});
// --------- a send from an address the selected wallet does not hold (#609)
//
// The selection is saved apart from the confirmation screen, so the popup can
// reopen onto the screen with a wallet selected that does not hold its From
// address, and Sign & Send then refuses the send. Another popup selecting the
// key wallet would save that selection; here it is written into storage, and
// the popup reopened.
test("a send from an address the selected wallet does not hold leaves the password field and Sign & Send where they were (#609)", async (env) => {
try {
// From the HD wallet's address.
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
// The screen's own save lands first, so it cannot write over the
// selection below.
await waitForPersisted(
env.page,
"currentView",
"confirm-tx",
"before selecting the key wallet",
);
await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
const record = {
...r.autistmask,
selectedWallet: 1,
selectedAddress: 0,
};
chrome.storage.local.set(
{ autistmask: record },
resolve,
);
});
}),
);
await reopenPopup(env, "confirm-tx");
await waitForEstimate(env.page);
// The refusal shown anywhere on the screen, not only in its own box:
// shown on the password error line instead, the test fails on the
// moves checked below rather than on the wait for it.
const refusal = env.page
.locator("#view-confirm-tx")
.getByText("does not hold the From address above")
.filter({ visible: true });
const before = await confirmState(env.page);
assert(
!before.sendDisabled && (await refusal.count()) === 0,
"the screen is not ready to send: " + JSON.stringify(before),
);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await refusal.waitFor({ timeout: 60000 });
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused From address: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send from an address the selected wallet does not hold" +
" reached the RPC",
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
// Only now, where the refusal is shown. Its sentence is wrapped across
// lines in index.html.
const shownIn = await refusal.evaluate((el) => ({
id: el.id,
text: el.textContent.replace(/\s+/g, " ").trim(),
}));
assert(
shownIn.id === "confirm-from-address-error" &&
shownIn.text ===
"The selected wallet does not hold the From address above," +
" so nothing was sent. Please go back and send again.",
"wrong From address refusal: " + JSON.stringify(shownIn),
);
} finally {
// A reopened popup restores no Send screen beneath this one, so Back
// lands on the selected address. Then Home, and the HD wallet's
// address, which the later tests send from.
if (await env.page.isVisible("#view-confirm-tx")) {
await env.page.click("#btn-confirm-back");
}
if (await env.page.isVisible("#view-send")) {
await env.page.click("#btn-send-back");
}
await visible(env.page, "#view-address");
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await openAddressDetail(env.page);
}
});
// ------------------------------ the wait for a receipt ending in error (#315)
//
// README.md (WaitTx) documents two ways the wait ends on the error screen: a
@@ -3861,10 +4130,8 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
// all; this measures that the whole address is visible (#517).
await assertAddressesFit(popup, "the connection prompt");
// Remembered, so the connection survives a background worker that MV3
// terminates after 30 seconds idle. The in-memory connectedSites map
// does not, and the sign and transaction tests below all require the
// origin to still be authorized.
// Remembered, because the sign and transaction tests below all
// require the origin to still be authorized.
await popup.check("#approve-remember");
await clickAndClose(popup, "#btn-approve");
@@ -4861,6 +5128,9 @@ async function main() {
seedTokenBalance: false,
ethBalanceWei: null,
failGasEstimate: false,
// Whether a JSON-RPC batch asking for a balance is held unanswered:
// the confirmation screen's own balance read (#558).
holdBalance: false,
holdGasEstimate: false,
// Whether a JSON-RPC batch asking for a transaction count is held
// unanswered: the address scan after creating a wallet (#475).
+22 -3
View File
@@ -22,6 +22,11 @@
// Before the approvals are raised it watches the worker stop on its own, so a
// run in which something else keeps the worker running cannot pass.
//
// The site is connected without "Remember", and once the worker has stopped
// the program requires the site still to be told the address: such a
// connection used to be held in the worker's memory and ended with it
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
//
// The dApp page and the stub node are the loopback ones the Firefox suite also
// uses (tests/e2e/dapp.js). The site is connected and the node chosen
// through the extension's own screens, so nothing is written to its storage
@@ -311,8 +316,8 @@ async function main() {
);
await send("Target.closeTarget", { targetId: popup.targetId });
// The site, connected through the real prompt with "Remember", so
// the connection is stored rather than held by the worker.
// The site, connected through the real prompt without "Remember", so
// the connection is one that has to outlive the worker stopping below.
const dapp = await openPage(node.url);
await until("the injected provider", () =>
dapp.evaluate("!!window.ethereum && !!window.__dapp"),
@@ -329,7 +334,7 @@ async function main() {
inPrompt(shows("view-approve-site")),
);
await inPrompt(
"document.getElementById('approve-remember').checked = true",
"document.getElementById('approve-remember').checked = false",
);
// The prompt closes itself on the click.
await inPrompt(click("btn-approve")).catch(() => {});
@@ -365,6 +370,20 @@ async function main() {
"s",
);
// The site's request starts the worker again, and the new worker still
// has the connection allowed without "Remember"
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
const stillConnected = await dapp.evaluate(
"window.ethereum.request({ method: 'eth_accounts' })",
);
check(
"a site allowed without Remember is still connected after the worker stopped",
Array.isArray(stillConnected) &&
stillConnected.length === 1 &&
getAddress(stillConnected[0]) === address,
"eth_accounts answered " + JSON.stringify(stillConnected),
);
// A transaction approval and a signature approval, both open at once.
await dapp.evaluate(
"window.__dapp.start('tx', 'eth_sendTransaction', [" +
+1 -1
View File
@@ -136,7 +136,7 @@ function erc20AmountLine(data, tokenAddress) {
}
function swapAmountLine(data) {
return decodeCalldata(data, ROUTER).details.find(
return decodeCalldata(data, ROUTER, "0x1").details.find(
(d) => d.label === "Amount",
).value;
}
+141
View File
@@ -0,0 +1,141 @@
// Adding the first wallet left no address active until the popup was reopened,
// so Send and Receive on Home only said "No active address selected."
// (https://git.eeqj.de/sneak/AutistMask/issues/512). Each test adds a wallet
// through one of the three import tabs and checks which address is active by
// opening Receive from Home in the same popup.
const { HDNodeWallet, Mnemonic, Wallet } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PRIVATE_KEY = "0x" + "11".repeat(32);
// The master key of PHRASE, so its first address is the phrase's.
const XPRV = HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(PHRASE).computeSeed(),
).extendedKey;
const PASSWORD = "correct horse battery staple";
const PHRASE_ADDRESS = HDNodeWallet.fromPhrase(PHRASE).address;
// Addresses 1 and 2 of PHRASE used, address 0 not: a scan that answers these
// replaces the wallet's list with one that leaves out its first address.
const account = HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0");
const used = [1, 2].map((index) => ({
address: account.deriveChild(index).address,
index,
}));
const TABS = [
{
tab: "tab-mnemonic",
field: "wallet-mnemonic",
secret: PHRASE,
address: PHRASE_ADDRESS,
},
{
tab: "tab-privkey",
field: "import-private-key",
secret: PRIVATE_KEY,
address: new Wallet(PRIVATE_KEY).address,
},
{
tab: "tab-xprv",
field: "import-xprv-key",
secret: XPRV,
address: PHRASE_ADDRESS,
},
];
// Fills in the open AddWallet screen and presses "Import".
async function importOn(page, { tab, field, secret }) {
await page.click(tab);
page.node(field).value = secret;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
afterEach(() => {
cleanupPopup();
});
describe("the first wallet's first address is active right away", () => {
test.each(TABS)("added from $tab, Receive opens on it", async (entry) => {
const page = await bootPopup(undefined);
expect(page.pageErrors).toEqual([]);
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
await page.click("btn-main-receive");
expect(page.visibleViews()).toEqual(["receive"]);
expect(page.node("receive-address-block").dataset.full).toBe(
entry.address,
);
});
});
describe("a scan that leaves out the first address", () => {
// The key tab runs no scan.
test.each(TABS.filter((t) => t.tab !== "tab-privkey"))(
"added from $tab, the scan's first address is active",
async (entry) => {
const page = await bootPopup(undefined);
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockResolvedValueOnce({
addresses: used,
nextIndex: 3,
});
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.storage.read("autistmask").activeAddress).toBe(
used[0].address,
);
expect(globalThis.chrome.runtime.sendMessage).toHaveBeenCalledWith({
type: "AUTISTMASK_ACTIVE_CHANGED",
});
await page.click("btn-main-receive");
expect(page.node("receive-address-block").dataset.full).toBe(
used[0].address,
);
},
);
});
describe("adding a wallet while an address is active", () => {
test.each(TABS)(
"added from $tab, the active address is unchanged",
async (entry) => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
// The phrase and xprv scans leave out the new wallet's first
// address, which was never active here. The key tab runs no scan.
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockResolvedValueOnce({
addresses: used,
nextIndex: 3,
});
await page.click("btn-add-wallet-bottom");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.storage.read("autistmask").activeAddress).toBe(ADDRESS);
expect(
globalThis.chrome.runtime.sendMessage,
).not.toHaveBeenCalledWith({ type: "AUTISTMASK_ACTIVE_CHANGED" });
await page.click("btn-main-receive");
expect(page.node("receive-address-block").dataset.full).toBe(
ADDRESS,
);
},
);
});
+135
View File
@@ -0,0 +1,135 @@
// A popup that opened with no wallet never started the ten-second refresh, so
// after the first wallet was added Home went on showing the balance it had then
// until the popup was reopened
// (https://git.eeqj.de/sneak/AutistMask/issues/530). Each test boots the real
// popup, adds wallets through AddWallet, and runs the intervals the popup set
// with tick(). The balance stand-in writes what the chain holds onto every
// address, as the real refreshBalances() does.
const { HDNodeWallet, Mnemonic } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PRIVATE_KEY = "0x" + "11".repeat(32);
const XPRV = HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(PHRASE).computeSeed(),
).extendedKey;
const PASSWORD = "correct horse battery staple";
const TABS = [
{ tab: "tab-mnemonic", field: "wallet-mnemonic", secret: PHRASE },
{ tab: "tab-privkey", field: "import-private-key", secret: PRIVATE_KEY },
{ tab: "tab-xprv", field: "import-xprv-key", secret: XPRV },
];
// A record this build cannot read: its wallets are a string.
const UNREADABLE = {
hasWallet: true,
wallets: ADDRESS,
activeAddress: ADDRESS,
};
// The ETH balance the chain holds for every address, in ether.
let chainBalance = "0.0";
// Boots the popup over `stored`, with the balance stand-in reading the chain.
async function boot(stored) {
const page = await bootPopup(stored);
const { refreshBalances } = require("../src/shared/balances");
refreshBalances.mockImplementation(async (wallets) => {
for (const wallet of wallets) {
for (const addr of wallet.addresses) addr.balance = chainBalance;
}
});
return { page, refreshBalances };
}
// Fills in the open AddWallet screen and presses "Import".
async function importOn(page, { tab, field, secret }) {
await page.click(tab);
page.node(field).value = secret;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
beforeEach(() => {
chainBalance = "0.0";
});
afterEach(() => {
cleanupPopup();
});
describe("a popup that opened on Welcome", () => {
test.each(TABS)(
"after the first wallet is added from $tab, funds that arrive show on Home",
async (entry) => {
const { page } = await boot(undefined);
expect(page.pageErrors).toEqual([]);
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 ETH");
chainBalance = "2.0";
await page.tick();
expect(page.text("total-value")).toBe("2.0000 ETH");
},
);
test("a second wallet starts no second refresh", async () => {
const { page, refreshBalances } = await boot(undefined);
await page.click("btn-welcome-add");
await importOn(page, TABS[1]);
await page.click("btn-add-wallet-bottom");
await importOn(page, TABS[0]);
const calls = refreshBalances.mock.calls.length;
await page.tick();
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
});
test("a wallet added as the recovery screen goes up starts no refresh", async () => {
const { page, refreshBalances } = await boot(undefined);
// The stored record becomes unreadable during the address scan that
// follows the import, and a save finds it.
const { scanForAddresses } = require("../src/shared/balances");
const { saveState } = require("../src/shared/state");
scanForAddresses.mockImplementationOnce(async () => {
page.storage.write("autistmask", UNREADABLE);
await saveState().catch(() => {});
return { addresses: [], nextIndex: 1 };
});
await page.click("btn-welcome-add");
await importOn(page, TABS[0]);
expect(page.visibleViews()).toEqual(["state-recovery"]);
await page.tick();
expect(refreshBalances).not.toHaveBeenCalled();
});
});
describe("a popup that opened with a wallet", () => {
test("adding a wallet starts no second refresh", async () => {
const { page, refreshBalances } = await boot(unversionedValidProfile());
await page.click("btn-add-wallet-bottom");
await importOn(page, TABS[1]);
const calls = refreshBalances.mock.calls.length;
await page.tick();
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
});
});
+1
View File
@@ -80,6 +80,7 @@ describe.each([
jest.doMock("../src/shared/state", () => ({
state: { trackedTokens: [] },
saveState,
currentNetwork: () => ({ chainId: 1 }),
}));
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
+18 -10
View File
@@ -17,6 +17,10 @@ jest.mock("ethers", () => {
async lookupAddress() {
return null;
}
// The 1.5 of the native token the address holds on either network.
async getBalance() {
return 1500000000000000000n;
}
// 10 gwei expected, 20 gwei reserved per gas.
async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
@@ -241,7 +245,7 @@ describe.each([
test("the value", async () => {
await confirmSend("0.1");
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
expect(text("confirm-amount")).toBe("0.1 " + symbol);
expect(text("confirm-amount")).toBe("0.1000 " + symbol);
await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
});
@@ -267,6 +271,13 @@ describe.each([
);
});
test("the warning for a recipient that has never sent a transaction", async () => {
await confirmSend("0.1");
expect(text("confirm-recipient-warning")).toContain(
"has never sent a transaction and holds no " + symbol + ".",
);
});
// A token reports whatever symbol it likes. One reporting the label the
// wallet shows its native token under, on this network or any other, is
// a fake, exactly as one reporting `ETH` always was.
@@ -332,14 +343,11 @@ describe.each([
test("the insufficient-balance error", async () => {
await confirmSend("2");
expect(
global.document.getElementById("confirm-errors").innerHTML,
).toContain(
"You have 1.5000 " +
expect(text("confirm-balance-error")).toBe(
"Your " +
symbol +
" but are trying to send 2 " +
symbol +
".",
" balance does not cover this amount. Please go back and send" +
" a smaller amount.",
);
});
});
@@ -421,10 +429,10 @@ describe.each([
await global.document
.getElementById("btn-confirm-send")
.handlers.get("click")();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
state.networkId = activeNetworkId;
txStatus.restoreWait();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
});
test("the transaction detail screen's type line and fee", async () => {
+51 -1
View File
@@ -97,6 +97,7 @@ const {
normalizePersisted,
} = require("../src/shared/persistedState");
const { stateProblem } = require("../src/shared/stateSchema");
const { isKnownNetworkId } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const {
bootPopup,
@@ -193,6 +194,29 @@ const CONTRACT = [
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
},
{
field: "networkTrackedTokens",
kind: KIND.ENTRIES,
hostile: [42, "notarecord", { sepolia: "notalist" }],
floorOnly: [
[1, 2],
{ sepolia: [1, 2] },
{ mainnet: [null, {}, [TOKEN_ADDRESS]] },
{ notanetwork: [{ address: TOKEN_ADDRESS }] },
JSON.parse('{"__proto__":[{"address":"0xabc"}]}'),
],
// Each list is put straight back onto trackedTokens by
// applyChainSwitchFields() when its network becomes the active one, so
// it is floored the same way; a key that names no network is dropped.
holds: (v) =>
isRecord(v) &&
Object.getPrototypeOf(v) === Object.prototype &&
Object.keys(v).every(
(id) =>
isKnownNetworkId(id) &&
everyEntry(v[id], (t) => isRecord(t) && isText(t.address)),
),
},
{
field: "allowedSites",
kind: KIND.ENTRIES,
@@ -453,6 +477,33 @@ const CONTRACT = [
views: ["confirm-tx"],
restored: false,
},
// The amount line splits the amount to cut it to four decimals.
{
value: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: 42,
},
},
views: ["confirm-tx"],
restored: false,
},
// A chain id naming no network has no explorer to link to.
{
value: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
chainId: "0x5",
},
},
views: ["confirm-tx"],
restored: false,
},
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked
// the fields below it since it was written, and this is the
// regression guard.
@@ -767,7 +818,6 @@ const WELL_FORMED_DATA = {
from: ADDRESS,
to: ADDRESS,
amount: "1",
balance: "2",
},
pendingWait: {
hash: "0x1",
+87
View File
@@ -0,0 +1,87 @@
// A timer the popup started and that fires after cleanupPopup() has removed
// the page throws "document is not defined", and the throw fails whatever test
// the jest worker is running by then, in this file or another
// (https://git.eeqj.de/sneak/AutistMask/issues/578). The flash message's
// timer is the one that did it. A throw from a timer during a test fails that
// test, so each test here passes only if nothing throws.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const FLASH_MS = 10;
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
afterEach(() => {
cleanupPopup();
});
test("a flash message clears while the page is up", async () => {
const page = await bootPopup(unversionedValidProfile());
const { showFlash } = require("../src/popup/views/helpers");
showFlash("Copied.", FLASH_MS);
expect(page.text("flash-msg")).toBe("Copied.");
await wait(FLASH_MS * 5);
expect(page.text("flash-msg")).toBe("");
});
test("a flash message's timer does not fire after cleanupPopup()", async () => {
await bootPopup(unversionedValidProfile());
const { showFlash } = require("../src/popup/views/helpers");
showFlash("Copied.", FLASH_MS);
cleanupPopup();
await wait(FLASH_MS * 5);
});
// A boot onto a screen that sends a request no mocked module answers: that
// request reaches the boot's fetch() stub, never the internet
// (https://git.eeqj.de/sneak/AutistMask/issues/559).
function restoringOnto(view, viewData) {
return unversionedValidProfile({
currentView: view,
selectedWallet: 0,
selectedAddress: 0,
viewStack: ["main"],
viewData,
});
}
test("the transaction screen's Blockscout request reaches the stub", async () => {
const page = await bootPopup(
restoringOnto("transaction", {
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS },
}),
);
expect(page.visibleViews()).toEqual(["transaction"]);
expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([
"https://eth.blockscout.com/api/v2/transactions/0x1",
]);
});
test("the confirmation screen's Etherscan lookup reaches the stub", async () => {
const page = await bootPopup(
restoringOnto("confirm-tx", {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
},
}),
);
expect(page.visibleViews()).toEqual(["confirm-tx"]);
expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([
"https://etherscan.io/address/" + ADDRESS,
]);
});
+282
View File
@@ -0,0 +1,282 @@
// What a page is told when a request fails
// (https://git.eeqj.de/sneak/AutistMask/issues/533).
//
// EIP-1193 gives every rejected request a numeric `code`, and lets it carry
// `data`. A node's error used to reach the page as its message alone: a
// reverting eth_call lost code 3 and the revert data a site needs to decode
// the revert, and a site could not tell that failure from any other.
//
// Each request here goes from a page through the real background to a fake
// node, and what is checked is the answer the background gives the page. The
// content script relays that answer as it is, and src/content/inpage.js turns
// its code and data into the error the page catches (tests/inpageErrors.test.js).
//
// The background reaches a node two ways, and the fake node answers both:
// fetch(), which the read-only methods are proxied through, and ethers' own
// HTTP transport, which a transaction is prepared through.
const { AbiCoder } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ORIGIN = "https://dapp.example";
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TOKEN = "0x1111111111111111111111111111111111111111";
const SEPOLIA = networkById("sepolia");
// Error("bal"), which a token whose transfer() does require(..., "bal")
// reverts with when the balance is too low.
const REVERT_DATA =
"0x08c379a0" +
AbiCoder.defaultAbiCoder().encode(["string"], ["bal"]).slice(2);
// What a node answers for a call that reverts, and for an address that is not
// one.
const REVERTED = {
code: 3,
message: "execution reverted: bal",
data: REVERT_DATA,
};
const INVALID_ADDRESS = { code: -32602, message: "invalid string length" };
const WITH_ADDRESS = {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
xpub: "xpub-1",
addresses: [
{ address: ADDRESS, balance: "0.0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "sepolia",
rpcUrl: SEPOLIA.defaultRpcUrl,
blockscoutUrl: SEPOLIA.defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [ORIGIN] },
deniedSites: {},
trackedTokens: [],
};
const NO_ADDRESS = {
...WITH_ADDRESS,
hasWallet: false,
wallets: [],
activeAddress: null,
allowedSites: {},
};
const realFetch = globalThis.fetch;
afterEach(() => {
delete global.chrome;
globalThis.fetch = realFetch;
});
// The background, over a stored profile, with a node that answers each
// JSON-RPC method from `node`: a method's entry is the result, or
// `{ error }` for the error the node answers with. A method not in `node`
// is answered "method not found", so a test that reaches one fails on it.
// `node` null is a node that cannot be reached at all.
function loadBackground(profile, node) {
jest.resetModules();
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
function reply({ id, method }) {
const entry = node[method];
if (entry === undefined) {
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: "method not found: " + method },
};
}
if (entry && entry.error)
return { jsonrpc: "2.0", id, error: entry.error };
return { jsonrpc: "2.0", id, result: entry };
}
globalThis.fetch = jest.fn(async (url, opts) => {
if (!node) throw new TypeError("fetch failed");
return { status: 200, json: async () => reply(JSON.parse(opts.body)) };
});
// The ethers the background is about to load: jest.resetModules() above
// means it is a fresh copy, not the one this file required at the top.
require("ethers").FetchRequest.registerGetUrl(async (req) => {
if (!node) throw new Error("connect ECONNREFUSED");
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
let messageListener = null;
global.chrome = {
storage: makeStorageStub({ autistmask: profile }),
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: { onRemoved: { addListener: () => {} } },
action: { setPopup: () => {} },
};
require("../src/background/index");
// The answer the page gets to one request.
return function request(method, params) {
return new Promise((resolve) => {
messageListener(
{ type: "AUTISTMASK_RPC", method, params },
{ origin: ORIGIN },
resolve,
);
});
};
}
const TRANSFER = {
from: ADDRESS,
to: TOKEN,
// transfer(ADDRESS, 1)
data:
"0xa9059cbb" +
AbiCoder.defaultAbiCoder()
.encode(["address", "uint256"], [ADDRESS, 1])
.slice(2),
};
describe("a node's error reaches the page with the node's code and data", () => {
test("a reverting eth_call", async () => {
const request = loadBackground(WITH_ADDRESS, {
eth_call: { error: REVERTED },
});
const answer = await request("eth_call", [TRANSFER, "latest"]);
expect(answer).toEqual({ error: REVERTED });
});
test("an eth_getBalance for an address that is not one", async () => {
const request = loadBackground(WITH_ADDRESS, {
eth_getBalance: { error: INVALID_ADDRESS },
});
const answer = await request("eth_getBalance", ["0x1234", "latest"]);
expect(answer).toEqual({ error: INVALID_ADDRESS });
});
// The wallet's own sentence, with the node's code and revert data, so the
// site can decode why its transaction would revert.
test("an eth_sendTransaction whose gas estimate reverts", async () => {
const request = loadBackground(WITH_ADDRESS, {
eth_getTransactionCount: "0x7",
eth_estimateGas: { error: REVERTED },
});
const answer = await request("eth_sendTransaction", [TRANSFER]);
expect(answer).toEqual({
error: {
code: REVERTED.code,
message: expect.stringMatching(
/^The transaction could not be prepared: execution reverted/,
),
data: REVERT_DATA,
},
});
});
});
// One request for each way a request is refused without the node's code: by
// the wallet itself, or because the node could not be reached.
describe("no rejected request reaches the page without a numeric code", () => {
test.each([
[
"eth_requestAccounts with no address",
NO_ADDRESS,
{},
"eth_requestAccounts",
[],
4100,
],
[
"personal_sign with no address",
NO_ADDRESS,
{},
"personal_sign",
["0x00", ADDRESS],
4100,
],
[
"eth_signTypedData_v4 with no address",
NO_ADDRESS,
{},
"eth_signTypedData_v4",
[ADDRESS, "{}"],
4100,
],
[
"eth_sendTransaction with no address",
NO_ADDRESS,
{},
"eth_sendTransaction",
[TRANSFER],
4100,
],
[
"eth_sendTransaction with two different gas limits",
WITH_ADDRESS,
{},
"eth_sendTransaction",
[{ ...TRANSFER, gas: "0x5208", gasLimit: "0x5209" }],
-32602,
],
[
"eth_call to a node that cannot be reached",
WITH_ADDRESS,
null,
"eth_call",
[TRANSFER, "latest"],
-32603,
],
[
"eth_sendTransaction to a node that cannot be reached",
WITH_ADDRESS,
null,
"eth_sendTransaction",
[TRANSFER],
-32603,
],
])("%s", async (_name, profile, node, method, params, code) => {
const request = loadBackground(profile, node);
const answer = await request(method, params);
expect(answer.error.code).toBe(code);
expect(answer.error.message).toEqual(expect.any(String));
});
});
+1 -1
View File
@@ -74,7 +74,7 @@ test("ethers puts the URL in the error message, not in the short message", async
test("the recipient checks before a send", async () => {
await getFullWarnings(ADDRESS, getProvider(RPC_URL, "mainnet"));
expectFailureLoggedWithoutKey("contract check failed");
expectFailureLoggedWithoutKey("tx count check failed");
expectFailureLoggedWithoutKey("tx count or balance check failed");
});
test("the ENS reverse lookup", async () => {
+220
View File
@@ -0,0 +1,220 @@
// The Send screen refuses every amount the confirmation screen would refuse,
// by the same check (https://git.eeqj.de/sneak/AutistMask/issues/532).
//
// Send used to accept anything parseFloat() read as above zero, so "1e-3",
// "1abc" and an amount with more decimal places than the token has went on to
// a confirmation screen that then refused them. Both screens now call
// isValidAmount() in src/shared/txValidation.js.
//
// Driven through the real Send review handler and the real confirmation
// screen, with only the node and the DOM stubbed.
"use strict";
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
const { AbiCoder } = require("ethers");
// What the confirmation screen asks the node for. The address holds the 1 ETH
// and 250 TOK the wallet below records.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
gasPrice: 1000000000n,
}),
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 1000000000000000000n,
// The one contract call the screen makes, balanceOf(), answered at the
// token's 6 decimal places.
call: async () =>
AbiCoder.defaultAbiCoder().encode(["uint256"], [250000000n]),
};
jest.mock("../src/shared/balances", () => {
const actual = jest.requireActual("../src/shared/balances");
return { ...actual, getProvider: () => mockProvider };
});
// The confirmation screen's Etherscan label lookup fails, as it does offline.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// A token with 6 decimal places, as USDC has.
const TOKEN = "0x" + "d".repeat(40);
const TOKEN_BY_SYMBOL = { ETH: "ETH", TOK: TOKEN };
function el(id) {
return global.document.getElementById(id);
}
// Press Review on the Send screen with `amount` entered, for a send of `token`
// ("ETH" or TOKEN), and return what it handed the confirmation screen, or null
// when it handed nothing over.
async function review(amount, token) {
let handed = null;
send.init({ showConfirmTx: (info) => (handed = info) });
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = amount;
await el("btn-send-review").handlers.get("click")();
return handed;
}
// Show the confirmation screen for the send Review hands over for 0.5, with
// `amount` in its place, and let its fee estimate settle.
async function confirm(amount, token) {
const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
beforeEach(() => {
elements.clear();
state.wallets = [
{
name: "Wallet 1",
addresses: [
{
address: HOLDER,
balance: "1.0",
tokenBalances: [
{
address: TOKEN,
name: "Token",
symbol: "TOK",
decimals: 6,
balance: "250.0",
holders: 50000,
},
],
},
],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 6 },
];
state.fraudContracts = [];
state.selectedToken = null;
state.currentView = null;
});
describe.each([
["1e-3", "ETH"],
["1e-3", "TOK"],
["1abc", "ETH"],
// 19 decimal places; ETH has 18.
["0.0000000000000000001", "ETH"],
// 7 decimal places; the token has 6.
["0.0000001", "TOK"],
["0", "ETH"],
["-1", "ETH"],
])("%s %s", (amount, symbol) => {
const token = TOKEN_BY_SYMBOL[symbol];
test("Send refuses it with a flash message and opens nothing", async () => {
expect(await review(amount, token)).toBe(null);
expect(el("flash-msg").textContent).toBe(
"Please enter a valid amount.",
);
});
test("the confirmation screen refuses it too", async () => {
await confirm(amount, token);
expect(el("confirm-errors").innerHTML).toContain(
"Please enter a valid amount to send.",
);
expect(el("btn-confirm-send").disabled).toBe(true);
});
});
describe.each([
["0.5", "ETH"],
["0.5", "TOK"],
// Exactly the 6 decimal places the token has.
["0.000001", "TOK"],
// Only zeros past the token's 6 places, and past ETH's 18.
["1.0000000", "TOK"],
["0.5000000000000000000", "ETH"],
])("%s %s", (amount, symbol) => {
const token = TOKEN_BY_SYMBOL[symbol];
test("Send hands it to the confirmation screen", async () => {
expect((await review(amount, token)).amount).toBe(amount);
expect(el("flash-msg").textContent).toBe("");
});
test("the confirmation screen lets it be sent", async () => {
await confirm(amount, token);
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("btn-confirm-send").disabled).toBe(false);
});
});
+27 -8
View File
@@ -18,6 +18,7 @@
// What the stub node answers. Each test sets what it needs.
const mockNode = {
balanceWei: 0n,
tokenUnits: 0n,
feeData: { maxFeePerGas: 1n, gasPrice: 1n },
};
@@ -30,6 +31,13 @@ jest.mock("ethers", () => {
async getBalance() {
return mockNode.balanceWei;
}
// balanceOf(), the one contract call these screens make.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() {
return null;
}
@@ -195,10 +203,19 @@ function errors() {
return global.document.getElementById("confirm-errors").innerHTML;
}
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = global.document.getElementById("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
// The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them.
// fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0;
@@ -300,9 +317,10 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
);
});
@@ -316,6 +334,7 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("1.5", TOKEN);
expect(text("confirm-balance")).toBe("1.5000 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
});
test("with more than 18 decimals and a holding below 10^-18", async () => {
@@ -329,9 +348,8 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
);
await confirmSend("0.000000000000000001", TOKEN);
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
expect(errors()).toContain(
"You have 0.000000000000000000000001 TOK but are trying to send" +
" 0.000000000000000001 TOK.",
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
);
});
});
@@ -356,9 +374,10 @@ describe("an untracked token holding below 0.000001 never renders as zero", () =
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
);
});
});
+25 -7
View File
@@ -17,6 +17,7 @@
// What the stub node answers, and the signed transactions it was handed.
const mockNode = {
balanceWei: 0n,
tokenUnits: 0n,
feeData: null,
broadcast: [],
};
@@ -30,6 +31,14 @@ jest.mock("ethers", () => {
async getBalance() {
return mockNode.balanceWei;
}
// balanceOf(), the one contract call these screens make before Sign
// & Send.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() {
return null;
}
@@ -187,9 +196,12 @@ function text(id) {
}
// The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them.
// fetched and stored exactly where the popup stores them. The node reports the
// token holding the explorer does.
async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems;
state.wallets = [
{
@@ -294,7 +306,8 @@ describe("Max on an ETH send", () => {
openSend();
await pressMax();
await review();
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
expect(confirmed.amount).toBe(maxAfter(20n * GWEI));
expect(text("confirm-amount")).toBe("1.2341 ETH");
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(canSend()).toBe(true);
@@ -310,14 +323,16 @@ describe("Max on an ETH send", () => {
// Kept, the Send screen's amount would be refused for want of funds.
mockNode.feeData = fees(30n * GWEI);
await review();
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
expect(confirmed.amount).toBe(maxAfter(30n * GWEI));
expect(text("confirm-amount")).toBe("1.2339 ETH");
expect(canSend()).toBe(true);
// And falls when the screen is shown again, as on reopening the popup.
mockNode.feeData = fees(10n * GWEI);
confirmTx.restore();
await settle();
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
expect(confirmed.amount).toBe(maxAfter(10n * GWEI));
expect(text("confirm-amount")).toBe("1.2343 ETH");
expect(canSend()).toBe(true);
});
@@ -514,7 +529,8 @@ describe("Max on an ETH send", () => {
el("send-amount").handlers.get("input")();
mockNode.feeData = fees(30n * GWEI);
await review();
expect(text("confirm-amount")).toBe("0.5 ETH");
expect(confirmed.amount).toBe("0.5");
expect(text("confirm-amount")).toBe("0.5000 ETH");
});
});
@@ -528,7 +544,8 @@ describe("Max on a token send", () => {
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(confirmed.amount).toBe("1234.567890123456789012");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(canSend()).toBe(true);
});
@@ -541,7 +558,8 @@ describe("Max on a token send", () => {
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(confirmed.amount).toBe("1234.567890123456789012");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(canSend()).toBe(true);
});
+72 -4
View File
@@ -43,6 +43,13 @@ jest.doMock("ethereum-blockies-base64", () => makeBlockie);
// Taken before any boot replaces them; see bootPopup().
const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval;
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
const realFetch = globalThis.fetch;
// Every timeout set since the last cleanupPopup() that has neither fired nor
// been cleared. cleanupPopup() clears them all.
const pendingTimeouts = new Set();
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
@@ -242,13 +249,26 @@ async function settle() {
* "?approval=" and an id for the popup opened as an approval window.
* @param {object} [options.runtime] members of chrome.runtime that replace the
* stub's own, for a test that has to answer the background's messages.
* @param {object} [options.windows] chrome.windows, for a test of the approval
* window resizing itself.
* @param {object} [options.tabs] chrome.tabs, for the same test: the page's
* tab and its zoom.
* @param {object} [options.documentElement] members of document.documentElement
* that replace the stub's own: scrollWidth and clientWidth, which are equal
* unless a test says otherwise, so the page does not scroll sideways.
* @returns {Promise<object>} handles onto the booted page.
*/
async function bootPopup(stored, options) {
jest.resetModules();
// The three modules that reach the network. None is on the path under
// test; all would make the suite hit the internet.
// The RPC requests made through src/shared/balances.js go through Node's
// own http and https modules, not fetch(), so the fetch() stub below would
// not stop them, which is why that module stays mocked.
// src/shared/prices.js and src/shared/transactions.js send their requests
// through fetch(), and their mocks answer with no prices and no
// transactions. Every request the page still sends, such as the
// transaction screen's Blockscout lookup and the Etherscan lookup in
// src/shared/etherscanLabels.js, meets the fetch() stub.
jest.doMock("../../src/shared/prices", () => ({
prices: {},
refreshPrices: jest.fn(async () => {}),
@@ -258,12 +278,17 @@ async function bootPopup(stored, options) {
formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }),
}));
// scanForAddresses() answers in the real one's shape, a scan that found
// no used address: AddWallet reads `addresses` from it.
jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}),
lookupTokenInfo: jest.fn(async () => null),
getProvider: () => ({}),
scanForAddresses: jest.fn(async () => []),
scanForAddresses: jest.fn(async () => ({
addresses: [],
nextIndex: 1,
})),
}));
// filterTransactions() answers in the real one's shape: Home,
// AddressDetail and AddressToken read both fields, and a bare list makes
@@ -277,7 +302,13 @@ async function bootPopup(stored, options) {
(options && options.storage) ||
makeStorageStub(stored === undefined ? {} : { autistmask: stored });
const document = makeDocument(POPUP_HTML);
Object.assign(
document.documentElement,
{ scrollWidth: 396, clientWidth: 396 },
options && options.documentElement,
);
const reloads = [];
const windowListeners = {};
globalThis.chrome = {
storage: { local: storage.local },
@@ -287,6 +318,8 @@ async function bootPopup(stored, options) {
onMessage: { addListener: () => {} },
...(options && options.runtime),
},
windows: options && options.windows,
tabs: options && options.tabs,
};
globalThis.document = document;
globalThis.window = {
@@ -300,8 +333,20 @@ async function bootPopup(stored, options) {
addEventListener: () => {},
removeEventListener: () => {},
}),
addEventListener: () => {},
// Recorded, as the document's are, so a test that widens the approval
// window can call the page's resize listeners.
listeners: windowListeners,
addEventListener: (name, fn) => {
windowListeners[name] = windowListeners[name] || [];
windowListeners[name].push(fn);
},
};
// Every fetch() the page makes fails, as it does elsewhere in the suite,
// instead of reaching the internet. A new stub per boot, so the calls it
// records start with that boot. Put back by cleanupPopup().
globalThis.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// The ten-second refresh init() starts, and a transaction wait's timers,
// would outlive the test. So every interval is recorded rather than
// started, clearInterval() removes it as a browser would, and tick() below
@@ -316,6 +361,24 @@ async function bootPopup(stored, options) {
globalThis.clearInterval = (id) => {
intervals.delete(id);
};
// Timeouts, such as the one that clears a flash message, run for real, but
// each is recorded until it fires or is cleared, so cleanupPopup() can
// clear the ones still pending. One that fired after the page was removed
// would throw "document is not defined" into whatever test the worker runs
// by then. jest.useFakeTimers() in a test does not reach them. Put back by
// cleanupPopup().
globalThis.setTimeout = (fn, ms, ...args) => {
const timer = realSetTimeout(() => {
pendingTimeouts.delete(timer);
fn(...args);
}, ms);
pendingTimeouts.add(timer);
return timer;
};
globalThis.clearTimeout = (timer) => {
pendingTimeouts.delete(timer);
realClearTimeout(timer);
};
require("../../src/popup/index");
@@ -381,6 +444,11 @@ function cleanupPopup() {
delete globalThis.window;
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
for (const timer of pendingTimeouts) realClearTimeout(timer);
pendingTimeouts.clear();
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
globalThis.fetch = realFetch;
}
module.exports = {
+23 -15
View File
@@ -1,4 +1,4 @@
// A chrome.storage.local stub that behaves like the real one.
// A chrome.storage stub that behaves like the real one.
//
// The real extension storage API is a serialization boundary: `set` writes a
// structured clone of what it is given, and `get` hands back a structured
@@ -17,13 +17,8 @@
// So every test that drives real persistence uses this, and nothing rebuilds
// a storage stub by hand.
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }.
// `onOp` runs before each operation, for a test that needs to advance a clock
// or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
// One storage area: get, set and remove over its own store, cloning both ways.
function makeArea(store, tick) {
const get = jest.fn(async (key) => {
tick();
if (key === undefined || key === null) return structuredClone(store);
@@ -49,15 +44,28 @@ function makeStorageStub(initial, onOp) {
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
});
return { get, set, remove };
}
// `initial` is the starting contents of `storage.local`, keyed as storage is:
// { autistmask: {...} }. `storage.session` starts empty.
// `onOp` runs before each operation on either area, for a test that needs to
// advance a clock or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const local = makeArea(store, tick);
return {
// Drop this straight in as chrome.storage.
local: { get, set, remove },
get,
set,
remove,
// What is stored, cloned on the way out: a test can neither observe a
// later write through an object it read nor reach into the store by
// mutating one.
local,
session: makeArea({}, tick),
get: local.get,
set: local.set,
remove: local.remove,
// What is stored in `storage.local`, cloned on the way out: a test can
// neither observe a later write through an object it read nor reach
// into the store by mutating one.
read: (key) =>
key === undefined
? structuredClone(store)
+372
View File
@@ -0,0 +1,372 @@
// Tracked tokens are kept per network, and the two add-token screens offer the
// bundled token list, which holds Ethereum Mainnet contracts, on Ethereum
// Mainnet only (https://git.eeqj.de/sneak/AutistMask/issues/523), including on
// the screen "Back" lands on after a switch made from the Settings gear and for
// a contract lookup that answers after one. Driven through the real popup over
// a stored profile; the same switch made by the background is in
// tests/coldWorkerChainSwitch.test.js.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { NETWORKS } = require("../src/shared/networks");
const SEPOLIA_TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const MAINNET_TOKEN = {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
decimals: 18,
name: "Dai Stablecoin",
};
// Tracked on neither network.
const NEW_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
const NETWORK_CHANGED_FLASH =
"The active network changed before the lookup finished, so the token was" +
" not added.";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto SettingsAddToken, on `networkId`.
function bootOnAddToken(networkId, extra) {
return bootPopup(
unversionedValidProfile({
networkId,
currentView: "settings-addtoken",
...extra,
}),
);
}
// The popup reopened onto AddressDetail on Ethereum Mainnet, with DAI tracked
// there and TST tracked on Sepolia.
function bootOnAddress() {
return bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
}),
);
}
// The popup reopened onto AddressToken for `token`, with AddressDetail beneath
// it, over the same tracked tokens as bootOnAddress().
function bootOnToken(token) {
return bootPopup(
unversionedValidProfile({
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: token,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
}),
);
}
// AddressToken for `token` opened from AddressDetail, as tapping the token's
// row there does.
async function openToken(boot, token) {
const { state } = require("../src/shared/state");
state.selectedToken = token;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await boot.settle();
}
// Presses `addButton` with a contract lookup that answers only after the
// Settings gear and a switch to `networkId`.
async function addAcrossSwitch(boot, addButton, networkId) {
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
const adding = boot.click(addButton);
await boot.click("btn-settings");
await chooseNetwork(boot, networkId);
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await adding;
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(boot, networkId) {
const select = boot.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await boot.settle();
}
// The Settings gear, a switch to Sepolia, and Back.
async function gearSwitchBack(boot) {
await boot.click("btn-settings");
expect(boot.visibleViews()).toEqual(["settings"]);
await chooseNetwork(boot, "sepolia");
await boot.click("btn-settings-back");
}
describe.each([
["mainnet", false],
["sepolia", true],
])("on %s", (networkId, hidden) => {
test(`the common tokens are hidden: ${hidden}`, async () => {
const boot = await bootOnAddToken(networkId);
expect(boot.pageErrors).toEqual([]);
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
expect(boot.hidden("settings-addtoken-common")).toBe(hidden);
require("../src/popup/views/addToken").show();
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.hidden("add-token-common")).toBe(hidden);
});
});
test("a token added by contract address on Sepolia is tracked on Sepolia only", async () => {
const boot = await bootOnAddToken("sepolia", {
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
const stored = () => boot.storage.read("autistmask");
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: SEPOLIA_TOKEN.symbol,
decimals: SEPOLIA_TOKEN.decimals,
name: SEPOLIA_TOKEN.name,
});
boot.node("settings-addtoken-address").value = TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
expect(lookupTokenInfo).toHaveBeenCalledWith(
TOKEN_ADDRESS,
expect.any(String),
"sepolia",
expect.anything(),
);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
expect(onMainnet).toContain("DAI");
expect(onMainnet).not.toContain("TST");
await chooseNetwork(boot, "sepolia");
expect(stored().networkId).toBe("sepolia");
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
expect(onSepolia).toContain("TST");
expect(onSepolia).not.toContain("DAI");
});
describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.hidden("settings-addtoken-common")).toBe(false);
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
expect(boot.hidden("settings-addtoken-common")).toBe(true);
});
test("AddressDetail lists the new network's tracked tokens", async () => {
const boot = await bootOnAddress();
expect(boot.node("address-balances").innerHTML).toContain("DAI");
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["address"]);
const balances = boot.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).not.toContain("DAI");
});
// Neither can be rendered from saved state, so neither is landed on.
test.each([
["btn-add-token", "add-token"],
["btn-send", "send"],
])("from over %s, it lands on AddressDetail", async (button, view) => {
const boot = await bootOnAddress();
await boot.click(button);
expect(boot.visibleViews()).toEqual([view]);
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["address"]);
expect(boot.node("address-balances").innerHTML).not.toContain("DAI");
});
// DAI was picked on Ethereum Mainnet, where alone it is tracked.
test.each([
[
"opened from AddressDetail",
async () => {
const boot = await bootOnAddress();
await openToken(boot, MAINNET_TOKEN.address);
return boot;
},
],
["in a reopened popup", () => bootOnToken(MAINNET_TOKEN.address)],
])(
"from AddressToken for DAI %s, it lands on AddressDetail",
async (_how, open) => {
const boot = await open();
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).toContain("DAI");
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["address"]);
const balances = boot.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).not.toContain("DAI");
},
);
// The stack is saved cut with the switch, so a popup closed straight after
// it and reopened onto Settings lands where this one would. Loading a
// saved stack cuts it at AddToken and Send as well, but not at
// AddressToken.
test.each([
[
"AddressToken for DAI",
(boot) => openToken(boot, MAINNET_TOKEN.address),
],
["AddToken", (boot) => boot.click("btn-add-token")],
["Send", (boot) => boot.click("btn-send")],
])(
"from %s, a popup reopened after the switch lands on AddressDetail",
async (_view, open) => {
const boot = await bootOnAddress();
await open(boot);
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const reopened = await bootPopup(boot.storage.read("autistmask"));
expect(reopened.visibleViews()).toEqual(["settings"]);
await reopened.click("btn-settings-back");
expect(reopened.visibleViews()).toEqual(["address"]);
const balances = reopened.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).not.toContain("DAI");
},
);
test("AddressToken for the native token is drawn again for the new network", async () => {
const boot = await bootOnToken("ETH");
expect(boot.visibleViews()).toEqual(["address-token"]);
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).toContain("SepoliaETH");
});
// A popup reopened onto ConfirmTx has no Send beneath it, so "Back" draws
// ConfirmTx again from the saved transaction. It is drawn for the network
// the transaction was reviewed on, and Sign & Send refuses it on the new
// one.
test("ConfirmTx reviewed on Sepolia is not sent on Ethereum Mainnet", async () => {
const boot = await bootPopup(
unversionedValidProfile({
networkId: "sepolia",
currentView: "confirm-tx",
selectedWallet: 0,
selectedAddress: 0,
viewData: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
chainId: NETWORKS.sepolia.chainId,
},
},
}),
);
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet");
globalThis.fetch.mockClear();
await boot.click("btn-settings-back");
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
// Drawn again: its Etherscan label check asks Sepolia's explorer.
expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([
NETWORKS.sepolia.explorerUrl + "/address/" + ADDRESS,
]);
expect(boot.text("confirm-type")).toBe("Native SepoliaETH transfer");
boot.node("confirm-tx-password").value = "any password";
await boot.click("btn-confirm-send");
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
expect(
boot.node("confirm-network-changed-error").style.visibility,
).toBe("visible");
// Refused before the password is even tried.
expect(boot.text("confirm-tx-password-error")).toBe("");
});
test("without a wallet, it lands on Welcome", async () => {
const boot = await bootPopup(undefined);
expect(boot.visibleViews()).toEqual(["welcome"]);
await gearSwitchBack(boot);
expect(boot.visibleViews()).toEqual(["welcome"]);
});
test("a later Back onto Settings keeps what was typed there", async () => {
const boot = await bootOnAddress();
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
boot.node("settings-rpc").value = "https://typed.example";
await boot.click("btn-settings-add-token");
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
await boot.click("btn-settings-addtoken-back");
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.value("settings-rpc")).toBe("https://typed.example");
});
});
describe("a contract lookup that answers after a switch adds nothing", () => {
test("from SettingsAddToken", async () => {
const boot = await bootOnAddToken("sepolia", {
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
const stored = () => boot.storage.read("autistmask");
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet");
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
expect(stored().networkTrackedTokens.sepolia).toEqual([]);
});
test("from AddToken", async () => {
const boot = await bootOnAddress();
const stored = () => boot.storage.read("autistmask");
await boot.click("btn-add-token");
boot.node("add-token-address").value = NEW_TOKEN_ADDRESS;
await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia");
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
});
+6
View File
@@ -116,6 +116,12 @@ describe("transferAmountUnits", () => {
expect(() => transferAmountUnits("0.0000001", 6, 6n)).toThrow();
});
test("encodes zeros past the token's scale as the amount typed", () => {
// The Send screen accepts "1.0000000" for a 6-place token, so the
// transfer must move exactly 1 of it.
expect(transferAmountUnits("1.0000000", 6, 6n)).toBe(1000000n);
});
test("every refusal is a full sentence", () => {
const messages = [];
for (const args of [
+65
View File
@@ -6,6 +6,7 @@ const {
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
@@ -33,6 +34,49 @@ describe("toFixedPoint", () => {
});
});
// The one amount check the Send screen and the confirmation screen share.
// The screens themselves are driven in tests/sendAmountRule.test.js.
describe("isValidAmount", () => {
test("accepts a number above zero written as digits and a decimal point", () => {
expect(isValidAmount("0.5")).toBe(true);
expect(isValidAmount("12")).toBe(true);
expect(isValidAmount(".5")).toBe(true);
expect(isValidAmount("0.000000000000000001")).toBe(true);
});
test("refuses an exponent, trailing text, zero and a negative amount", () => {
expect(isValidAmount("1e-3")).toBe(false);
expect(isValidAmount("1abc")).toBe(false);
expect(isValidAmount("0")).toBe(false);
expect(isValidAmount("0.0")).toBe(false);
expect(isValidAmount("-1")).toBe(false);
expect(isValidAmount("")).toBe(false);
expect(isValidAmount(undefined)).toBe(false);
});
test("refuses a digit other than 0 past the token's places", () => {
expect(isValidAmount("0.000001", 6)).toBe(true);
expect(isValidAmount("0.0000001", 6)).toBe(false);
expect(isValidAmount("5", 0)).toBe(true);
expect(isValidAmount("0.5", 0)).toBe(false);
});
test("accepts zeros past the token's places, which leave the amount unchanged", () => {
expect(isValidAmount("1.0000000", 6)).toBe(true);
expect(isValidAmount("5.0", 0)).toBe(true);
expect(isValidAmount("1.0000000000000000000")).toBe(true);
expect(isValidAmount("0.0000000", 6)).toBe(false);
});
test("holds ETH, a token of unknown places, and any token to 18 places", () => {
const nineteen = "0.0000000000000000001";
expect(isValidAmount(nineteen)).toBe(false);
expect(isValidAmount(nineteen, null)).toBe(false);
expect(isValidAmount(nineteen, 24)).toBe(false);
expect(isValidAmount("0.000000000000000001", 24)).toBe(true);
});
});
describe("validateTransfer, native ETH", () => {
const eth = (over) => ({
isErc20: false,
@@ -134,6 +178,17 @@ describe("validateTransfer, native ETH", () => {
).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("rejects a zero amount and an exponent", () => {
expect(validateTransfer(eth({ amount: "0" }))).toEqual({
canSend: false,
codes: [CODES.AMOUNT_INVALID],
});
expect(validateTransfer(eth({ amount: "1e-3" }))).toEqual({
canSend: false,
codes: [CODES.AMOUNT_INVALID],
});
});
test("treats a missing balance as zero, not as unlimited", () => {
const r = validateTransfer(eth({ ethBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
@@ -205,6 +260,16 @@ describe("validateTransfer, ERC-20", () => {
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("rejects more decimal places than the token has", () => {
const r = validateTransfer(
erc20({ amount: "0.0000001", tokenDecimals: 6 }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
expect(
validateTransfer(erc20({ amount: "0.000001", tokenDecimals: 6 })),
).toEqual({ canSend: true, codes: [] });
});
test("treats a missing token balance as zero", () => {
const r = validateTransfer(erc20({ tokenBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
+818 -74
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+298
View File
@@ -0,0 +1,298 @@
// Which transactions are shown as a swap through the Uniswap Universal Router
// (https://git.eeqj.de/sneak/AutistMask/issues/520).
//
// The decoder used to recognise the router's execute() call by its selector
// alone, so the same calldata sent to an ordinary address was shown as a swap
// through the router, with that address as its Protocol. It is now decoded
// only when the transaction is sent to a Universal Router deployment on the
// network it is for; anywhere else it is left to the raw data.
//
// The transaction detail screen and the approval screen are driven against a
// minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the
// approval screen opened the way tests/swapStatusAmount.test.js opens it.
const { AbiCoder, Interface, getAddress, solidityPacked } = require("ethers");
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: async (url) => ({
ok: true,
json: async () => mockExplorerAnswer(url),
}),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const { decodeCalldata } = approval;
const transactionDetail = require("../src/popup/views/transactionDetail");
const MAINNET = "0x1";
const SEPOLIA = "0xaa36a7";
// From Uniswap's deployment files: one router on Ethereum Mainnet only, one on
// Sepolia only, and one at the same address on both.
const MAINNET_ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const SEPOLIA_ROUTER = "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b";
const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
// The address with no code from the issue.
const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const TOKEN = "0xE2E0000000000000000000000000000000000E2e";
const HUNDREDTH_ETH = 10000000000000000n;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// The calldata from the issue: wrap 0.01 ETH, then a V3 swap of it into a
// token.
const SWAP_DATA = routerIface.encodeFunctionData("execute", [
solidityPacked(["uint8", "uint8"], [0x0b, 0x00]),
[
coder.encode(["address", "uint256"], [RECIPIENT, HUNDREDTH_ETH]),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
RECIPIENT,
HUNDREDTH_ETH,
1n,
solidityPacked(
["address", "uint24", "address"],
[SEPOLIA_WETH, 3000, TOKEN],
),
false,
],
),
],
9999999999n,
]);
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("execute() calldata on the approval screen's decoder", () => {
test("sent to a router on the transaction's network, it is a swap", () => {
const onMainnet = decodeCalldata(SWAP_DATA, MAINNET_ROUTER, MAINNET);
expect(onMainnet.description).toBe("Swap via Uniswap Universal Router");
expect(onMainnet.details[0]).toMatchObject({
label: "Protocol",
address: MAINNET_ROUTER,
});
expect(
decodeCalldata(SWAP_DATA, SEPOLIA_ROUTER, SEPOLIA).description,
).toBe("Swap via Uniswap Universal Router");
});
test("sent to an ordinary address, it is not decoded", () => {
expect(decodeCalldata(SWAP_DATA, ORDINARY, MAINNET)).toBeNull();
expect(decodeCalldata(SWAP_DATA, ORDINARY, SEPOLIA)).toBeNull();
});
test("sent to a router of the other network only, it is not decoded", () => {
expect(decodeCalldata(SWAP_DATA, SEPOLIA_ROUTER, MAINNET)).toBeNull();
expect(decodeCalldata(SWAP_DATA, MAINNET_ROUTER, SEPOLIA)).toBeNull();
});
test("a router deployed at the same address on both networks is one on each", () => {
expect(
decodeCalldata(SWAP_DATA, ROUTER_ON_BOTH, MAINNET),
).not.toBeNull();
expect(
decodeCalldata(SWAP_DATA, ROUTER_ON_BOTH, SEPOLIA),
).not.toBeNull();
});
test("the router address matches in any letter case", () => {
const upper = "0x" + MAINNET_ROUTER.slice(2).toUpperCase();
for (const address of [getAddress(MAINNET_ROUTER), upper]) {
expect(decodeCalldata(SWAP_DATA, address, MAINNET)).not.toBeNull();
}
});
});
// The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`.
const TX_HASH = "0x" + "5".repeat(64);
let mockSentTo = null;
function mockExplorerAnswer(url) {
if (url.endsWith("/transactions/" + TX_HASH)) {
return {
to: { hash: mockSentTo },
raw_input: SWAP_DATA,
method: "execute",
};
}
return {};
}
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
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: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// 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 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);
}
// Open the transaction detail screen with Sepolia active, for the
// transaction the explorer reports sent to `sentTo`, and wait for its
// decoded details.
async function showDetailSentTo(sentTo) {
mockSentTo = sentTo;
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.networkId = "sepolia";
state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {};
state.viewStack = [];
state.currentView = null;
transactionDetail.show({
hash: TX_HASH,
from: RECIPIENT,
to: sentTo,
value: "",
symbol: "SepoliaETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: SEPOLIA,
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
describe("execute() calldata on the transaction detail screen", () => {
test("sent to a router on the active network, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER);
expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router",
);
});
test("sent to an ordinary address, it is not decoded", async () => {
await showDetailSentTo(ORDINARY);
expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
});
test("sent to a router of the other network only, it is not decoded", async () => {
await showDetailSentTo(MAINNET_ROUTER);
expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
});
});
// Open the approval screen with Ethereum Mainnet active, for a site's Sepolia
// transaction that sends SWAP_DATA to `sentTo`.
async function showApprovalSentTo(sentTo) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (_msg, reply) =>
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: RECIPIENT,
approvedTx: {
type: 2,
from: RECIPIENT,
chainId: SEPOLIA,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: sentTo,
value: "0x" + HUNDREDTH_ETH.toString(16),
data: SWAP_DATA,
accessList: [],
},
}),
};
state.networkId = "mainnet";
state.currentView = null;
await approval.show(1);
}
// The router is on Sepolia's list only, so the swap is shown only if the
// screen decodes for the transaction's network: on Ethereum Mainnet, the
// active network, that address is not a router.
describe("execute() calldata on the approval screen, for a Sepolia transaction while Ethereum Mainnet is active", () => {
test("sent to a router on Sepolia, it is a swap", async () => {
await showApprovalSentTo(SEPOLIA_ROUTER);
expect(node("approve-tx-decoded").classList.contains("hidden")).toBe(
false,
);
expect(node("approve-tx-decoded-details").innerHTML).toContain(
"Swap via Uniswap Universal Router",
);
});
test("sent to an ordinary address, it is shown as raw data", async () => {
await showApprovalSentTo(ORDINARY);
expect(node("approve-tx-decoded").classList.contains("hidden")).toBe(
true,
);
expect(node("approve-tx-decoded-details").innerHTML).toBe("");
expect(node("approve-tx-data").textContent).toBe(SWAP_DATA);
expect(
node("approve-tx-data-section").classList.contains("hidden"),
).toBe(false);
});
});
+8 -1
View File
@@ -53,7 +53,14 @@ function unwrapData() {
}
function detail(data, label, sources) {
const decoded = uniswap.decode(data, ROUTER, sources || {});
// Sent by the address the swap pays, so the output is the user's.
const decoded = uniswap.decode(
data,
ROUTER,
"0x1",
sources || {},
RECIPIENT,
);
return decoded.details.find((d) => d.label === label);
}
+14 -6
View File
@@ -71,6 +71,8 @@ function exactInParams(currencyIn, path, amountIn, amountOutMin) {
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
// uint256 minBalance). It names the output token and nothing about the input.
// Its owner is USER, the transaction's sender: a check of any other address
// names no output token.
function balanceCheck(token, minBalance) {
return coder.encode(
["address", "address", "uint256"],
@@ -78,8 +80,13 @@ function balanceCheck(token, minBalance) {
);
}
// A transaction from USER.
function decode(data) {
return uniswap.decode(data, ROUTER, "0x1", {}, USER);
}
function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, {});
const decoded = decode(data);
expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label);
}
@@ -87,7 +94,7 @@ function detail(data, label) {
describe("an execute() whose input currency never decoded", () => {
// A V4_SWAP whose sub-action params do not decode — an action encoding
// this decoder does not know — leaves every V4 token null. The
// BALANCE_CHECK still names the output, so the screen has a Min. received
// BALANCE_CHECK still names the output, so the screen has a minimum
// figure and a Token Out, and previously claimed the user was paying ETH
// for them.
const data = () =>
@@ -113,7 +120,7 @@ describe("an execute() whose input currency never decoded", () => {
});
test("does not name ETH in the swap title either", () => {
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap");
expect(decode(data()).name).toBe("Uniswap Swap");
});
});
@@ -123,12 +130,13 @@ describe("an execute() that names only an output token", () => {
test("refuses the input rather than defaulting it to ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL);
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap");
expect(decode(data()).name).toBe("Uniswap Swap");
});
test("still states the output it did establish", () => {
expect(detail(data(), "Token Out").value).toContain("USDT");
expect(detail(data(), "Min. received").value).toBe("2.0000 USDT");
// No step pays anyone, so the minimum is not said to be received.
expect(detail(data(), "Min. paid out").value).toBe("2.0000 USDT");
});
});
@@ -170,7 +178,7 @@ describe("native ETH in, which V4 spells as the zero address", () => {
expect(detail(data, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
expect(uniswap.decode(data, ROUTER, {}).name).toBe("Swap ETH → USDT");
expect(decode(data).name).toBe("Swap ETH → USDT");
});
test("WRAP_ETH is still ETH at 18 decimals", () => {
+8 -4
View File
@@ -105,7 +105,7 @@ function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) {
}
function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, {});
const decoded = uniswap.decode(data, ROUTER, "0x1", {}, USER);
expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label);
}
@@ -134,13 +134,15 @@ describe("a V4 step that states a Min. received but no output currency", () => {
});
test("refuses to scale Min. received rather than assuming 18", () => {
expect(detail(data(), "Min. received").value).toBe(
// With no TAKE, no step pays the user either, so the line is not
// labelled received.
expect(detail(data(), "Min. paid out").value).toBe(
unknownDecimalsAmount(MIN_OUT),
);
});
test("does not name ETH in the swap title either", () => {
const decoded = uniswap.decode(data(), ROUTER, {});
const decoded = uniswap.decode(data(), ROUTER, "0x1", {});
expect(decoded.name).toBe("Uniswap Swap");
});
});
@@ -206,6 +208,8 @@ describe("native ETH out, which V4 spells as the zero address", () => {
test("is still named ETH and still scaled at 18 decimals", () => {
expect(detail(data(), "Token Out").value).toBe("ETH");
expect(detail(data(), "Min. received").value).toBe("0.5000 ETH");
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Swap USDT → ETH");
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Swap USDT → ETH",
);
});
});
+2 -1
View File
@@ -82,7 +82,8 @@ function walletsHolding(token, decimals) {
// decodeCalldata() rather than uniswap.decode() directly, because the scale
// sources the screen supplies are part of what is under test.
function swapDetail(data, label) {
const decoded = decodeCalldata(data, ROUTER);
// Sent by the address the swap pays, so the output is the user's.
const decoded = decodeCalldata(data, ROUTER, "0x1", RECIPIENT);
return decoded.details.find((d) => d.label === label);
}
+51 -38
View File
@@ -39,7 +39,8 @@ jest.mock("../src/shared/log", () => ({
// Everything the confirmation screen would reach the network for. The gas
// estimate is the point: with a usable scale it must succeed, so that a failure
// in these tests is a failure of the scale and not of the stub.
// in these tests is a failure of the scale and not of the stub. The address
// holds the 1 ETH the wallets below record, and `tokenUnits` of the token.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
@@ -48,7 +49,14 @@ const mockProvider = {
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
getBalance: async () => 1000000000000000000n,
tokenUnits: 0n,
// The one contract call the screen makes, balanceOf().
call: async () =>
AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockProvider.tokenUnits],
),
};
jest.mock("../src/shared/balances", () => {
@@ -114,7 +122,7 @@ global.document = {
};
global.navigator = { clipboard: { writeText() {} } };
const { parseUnits } = require("ethers");
const { AbiCoder, parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state");
@@ -155,8 +163,9 @@ function row(token = {}, value = FIVE_WETH) {
}
// Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them.
// refreshBalances() puts them. The node reports the holding the explorer does.
async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
@@ -181,6 +190,7 @@ async function fetchOnto(items) {
// is from the first. Two addresses is what it takes to reach
// explorerDecimals()'s disagreement check, which is only reachable across rows.
async function fetchOntoBoth(itemsA, itemsB) {
mockProvider.tokenUnits = BigInt(itemsA[0].value);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
@@ -239,6 +249,12 @@ function errors() {
return el("confirm-errors").innerHTML;
}
// The message for an amount the balance does not cover, while it is shown.
function balanceError() {
const box = el("confirm-balance-error");
return box.style.visibility === "visible" ? box.textContent : "";
}
function sendDisabled() {
return el("btn-confirm-send").disabled;
}
@@ -270,7 +286,6 @@ describe("the Send screen resolves the scale rather than reading the stored one"
const txInfo = await reviewSend(WETH, "1.5");
expect(txInfo.tokenDecimals).toBe(18);
expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals);
expect(txInfo.tokenBalance).toBe("5.0");
});
test("that scale estimates a fee and leaves Send enabled", async () => {
@@ -307,11 +322,10 @@ describe("the Send screen resolves the scale rather than reading the stored one"
// No fallback was introduced: resolution answers null here, and the
// null is what goes forward.
expect(txInfo.tokenDecimals).toBeNull();
expect(txInfo.tokenBalance).toBeNull();
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(balanceError()).toContain("This token's balance is unknown");
expect(sendDisabled()).toBe(true);
});
});
@@ -355,7 +369,8 @@ describe("a scale the explorer's own rows disagree about", () => {
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBeNull();
// The regression this closes: null scale alongside a non-null balance.
expect(txInfo.tokenBalance).toBeNull();
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
});
test("so the user is told the balance is unknown, not only that the fee failed", async () => {
@@ -367,8 +382,7 @@ describe("a scale the explorer's own rows disagree about", () => {
// confirm-fee-unknown-error — "the network fee could not be
// estimated... please go back and try again" — as the only explanation
// for a screen that can never proceed.
expect(errors()).not.toBe("");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(balanceError()).toContain("This token's balance is unknown");
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(sendDisabled()).toBe(true);
// The fee line still reports the estimate as unavailable, because it
@@ -434,31 +448,20 @@ describe("a scale the explorer's own rows disagree about", () => {
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
expect(sendDisabled()).toBe(false);
});
});
describe("the confirmation screen tells an unknown balance from a zero one", () => {
function txInfo(tokenBalance) {
return {
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
balance: "1.0",
tokenSymbol: "NOVEL",
tokenBalance,
tokenDecimals: tokenBalance === null ? null : 18,
};
}
async function render(tokenBalance) {
// A send of 1.5 NOVEL from an address the node reports none of, with the
// token's scale `tokenDecimals`, or null for a scale nobody knows.
async function render(tokenDecimals) {
mockProvider.tokenUnits = 0n;
state.wallets = [
{
name: "Wallet 1",
@@ -469,30 +472,40 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.show(txInfo(tokenBalance));
confirmTx.show({
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
tokenSymbol: "NOVEL",
tokenDecimals,
});
await settle();
return { balance: text("confirm-balance"), errors: errors() };
return { balance: text("confirm-balance"), message: balanceError() };
}
test("the balance line states unknown rather than a quantity of zero", async () => {
const unknown = await render(null);
const zero = await render("0.0");
const zero = await render(18);
expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0000 NOVEL");
});
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
// spend from, which is the fail-closed side — but "you have 0.0" is a claim
// about the holding, and this one has no established quantity to claim.
test("the insufficient-balance message names the reason, not a figure", async () => {
// spend from, which is the fail-closed side — but "your balance does not
// cover this amount" is a claim about the holding, and this one has no
// established quantity to claim.
test("the insufficient-balance message names the reason, not a shortfall", async () => {
const unknown = await render(null);
const zero = await render("0.0");
expect(unknown.errors).not.toBe(zero.errors);
expect(unknown.errors).toContain("This token&#39;s balance is unknown");
expect(unknown.errors).not.toContain("You have");
expect(zero.errors).toContain("You have 0.0000 NOVEL");
expect(zero.errors).not.toContain("balance is unknown");
const zero = await render(18);
expect(unknown.message).toContain("This token's balance is unknown");
expect(unknown.message).not.toContain("does not cover");
expect(zero.message).toBe(
"Your NOVEL balance does not cover this amount. Please go back and" +
" send a smaller amount.",
);
});
});