19 Commits
Author SHA1 Message Date
clawbot ca9cd438d9 fix: transaction screens name a token from the tokens of the transaction's network (closes #629)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
decodeCalldata() looked a token's symbol and decimals up in the active
network's tracked tokens and balances, whatever chain id it was given, so
TransactionDetail and TxApproval showed a token tracked on another network
by address only, with its amount in base units. The lookup now uses what the
wallet knows of the tokens on the transaction's network: the active
network's tracked tokens and balances, or for another network the tokens
tracked on it and no balances. TxApproval's token labels, the symbol it
hands the wait, success and error screens, and SuccessTx's decoded token
label use the same lookup.

Model: opus-5-5
2026-10-20 04:16:04 +02:00
clawbot 3aed2bb5f0 fix: TransactionDetail reads a transaction from the Blockscout of the network it was sent on (closes #623)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
TransactionDetail read the block, network details and calldata from the
active network's Blockscout and decoded the calldata for the active network,
so after a network switch a transaction sent on the other network showed none
of them. It now reads them from the Blockscout Settings has for the network
the transaction was sent on, and decodes the calldata for that network.
endpointsByChainId() in the shared view helpers gives a network's RPC and
Blockscout URLs, picked as reviewedNetworkProvider() picked its node;
reviewedNetworkProvider() now uses it. Restore refuses a stored transaction
whose chain id names no network, as it does for WaitTx.

Model: opus-5-5
2026-10-20 03:10:30 +02:00
clawbot 5376723c41 fix: SuccessTx and ErrorTx link to the explorer of the network the transaction was sent on (closes #622)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
SuccessTx and ErrorTx built their recipient, block, hash and decoded-action
address links for the active network. After a network switch, WaitTx now finds
a transaction on the network it was sent on and moves on to these screens, whose
links then opened an explorer where the transaction does not exist. Both
screens now keep the transaction's chain id with their view data and build
every link for that network, as WaitTx does. A stored SuccessTx or ErrorTx
whose chain id names no network is refused on restore, as a stored transaction
already is.

Model: opus-5-5
2026-10-20 00:49:05 +02:00
clawbot 671b4d3216 fix: each PERMIT2_TRANSFER_FROM shows the amount it moves and where to (closes #611)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The swap decoder now gives three lines for every PERMIT2_TRANSFER_FROM
it reads but a move into the router, after the swap's Amount line: the
amount moved from the sender, the token with its address, and where it
goes (the V2 pair the next swap spends, the sender, or the address
itself on a line of its own). A move into the router keeps its Paid to
and Token moved into the router lines, which already give its amount.
Before, a move to another address or to the next V2 pair showed no
amount at all. Who counts as paid, and so Min. received against Min.
paid out, is unchanged. tokenMovedLine() moves to module level and
takes its label as an argument.

Model: opus-5-5
2026-10-20 00:23:57 +02:00
clawbot e46d894527 fix: WaitTx and TransactionDetail use the network the transaction was sent on (closes #584)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
WaitTx asked the active network's node for the receipt, so after a network
switch a transaction sent on the other network was never found and was
reported as timed out. It now asks the node of the network its chain id
names, through reviewedNetworkProvider(), moved from ConfirmTx into the
shared view helpers so both screens use one function. WaitTx and
TransactionDetail build their explorer links for that network too. A stored
wait or transaction whose chain id names no network is refused on restore,
as a stored ConfirmTx transaction already is.

Model: opus-5-5
2026-10-19 23:38:10 +02:00
clawbot 96446f4661 fix: a network switch in Settings updates the testnet banner at once (closes #511)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The Settings network handler now redraws the red banner once the switch
is saved, so it appears on a switch to Sepolia and goes on a switch back
to mainnet while Settings is still on screen. Before, the banner changed
only when the next screen came up. The Chrome browser suite switches
both ways on Settings and reads the banner each time.

Model: opus-5-5
2026-10-19 22:53:11 +02:00
clawbot 30dca1454e fix: any token a swap leaves in the router keeps it from reading as received (closes #606)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The decoder checked only the swap's output for being left in the router.
It now records every token a step pays the router, the WETH of a
WRAP_ETH and the rest of a V4 SETTLE taken to the router among them, and
any token still there after the last step gets a Paid to line naming it,
so the minimum reads Min. paid out. Steps that spend all the router holds
of a token count as spending it, and so do swap steps from the router
that together spend what a WRAP_ETH stated, when the router held no WETH
before it. A SETTLE of OPEN_DELTA from the router counts only in the
plain shape of Uniswap's own routes.

Model: opus-5-5
2026-10-19 22:18:32 +02:00
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
47 changed files with 7095 additions and 895 deletions
+350 -135
View File
@@ -418,6 +418,10 @@ Each group of these assertions records a coverage key and a final case demands
the exact set, so a section that silently stopped running reddens the suite the exact set, so a section that silently stopped running reddens the suite
instead of quietly shrinking it. instead of quietly shrinking it.
It also covers the red banner on the Settings screen: switching the network
there to Sepolia must put up the `[TESTNET]` banner, and switching back to
mainnet must take it down, both while Settings is still on screen.
It also covers the confirmation screen, for both a native ETH send and an ERC-20 It also covers the confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands, send: Send disabled while the fee estimate is in flight, enabled once it lands,
the fee block quoting the expected cost and the reserve separately, the distinct the fee block quoting the expected cost and the reserve separately, the distinct
@@ -430,6 +434,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 what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only. 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 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, 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 and six lookups in a row that fail. Each must show its own message, and Done
@@ -1077,42 +1090,49 @@ The rule and its exception live in `src/shared/amountDisplay.js` as
confirmation screens display goes through the floored one — the ERC-20 amount, 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` 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 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 `Current balance` (`src/popup/views/send.js`), and the amount, balance and
on the confirmation screen for the wallet's own send network fee on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`). Both screens render a network fee through (`src/popup/views/confirmTx.js`), which hands the wait, success and error
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD screens the amount as it showed it. The transaction is still signed for the
rather than its truncated figure, so the same fee reads the same on both, USD exact amount, and its USD value is priced from it. Both screens render a network
value included. Balances are stored exactly (`src/shared/balances.js`), whatever fee through `formatFee()` in `src/popup/views/helpers.js`, which prices the
decimals a token declares, so a balance below the floor reaches these screens as exact fee in USD rather than its truncated figure, so the same fee reads the
it is. The history list (`src/shared/transactions.js`) uses the unfloored one: same on both, USD value included. Balances are stored exactly
the transaction detail view is the authoritative record and already shows exact (`src/shared/balances.js`), whatever decimals a token declares, so a balance
precision. The balance lists use neither: they round to four places with below the floor reaches these screens as it is. The history list
`toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal (`src/shared/transactions.js`) uses the unfloored one: the transaction detail
rule is unchanged everywhere else, including for amounts at or above the floor view is the authoritative record and already shows exact precision. The balance
on the approval screens. 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 On the approval screen and the wait, success and error screens that carry its
approval screen states base units instead of a quantity — see Unknown token amount forward, the floor applies only where the token's scale is known. Where
scale below — and no truncation happens at all. 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 **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`. scale, so every amount on the dApp approval screen needs the token's `decimals`.
It is resolved from the bundled token list, then from the tokens the user It is resolved from the bundled token list, then from the tokens the user tracks
tracks, then from what the block explorer reported for the contract on the network the transaction is for, then, when that is the active network,
(`resolveTokenDecimals()` in `src/shared/approvalAmount.js`). Where none of them from what the block explorer reported for the contract in the wallet's balances,
answers, the amount is not formatted: the line reads which are the active network's only (`resolveTokenDecimals()` in
`5000000000 base units (decimals unknown)`. A guessed scale is not an `src/shared/approvalAmount.js`). Where none of them answers, the amount is not
approximation but a different number — 1,000 units of a 6-decimal token formatted: the line reads `5000000000 base units (decimals unknown)`. A guessed
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is scale is not an approximation but a different number — 1,000 units of a
to state what is being authorized. Both amount paths of that screen take this 6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and whose only job is to state what is being authorized. Both amount paths of that
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The screen take this rule: the ERC-20 `transfer`/`approve` line
token permission warning on the signature screen takes the same rule for its (`src/popup/views/approval.js`) and the swap's `Amount` and `Min. received`
amounts. An unbounded allowance or permit needs no scale to describe and is lines (`src/shared/uniswap.js`). The token permission warning on the signature
still shown as `Unlimited`. A source's answer counts only if it is a whole screen takes the same rule for its amounts. An unbounded allowance or permit
number from 0 to 80: `decimals()` returns a `uint8`, but `formatUnits()` cannot needs no scale to describe and is still shown as `Unlimited`. A source's answer
format more than 80 decimal places, so a token that reports 81 to 255 is shown counts only if it is a whole number from 0 to 80: `decimals()` returns a
as one whose scale nothing knows. `uint8`, but `formatUnits()` cannot format more than 80 decimal places, so a
token that reports 81 to 255 is shown as one whose scale nothing knows.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be sources are read as authoritative, so a value written into one of them cannot be
@@ -1144,9 +1164,9 @@ fetched, without that cross-address check, and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line **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 of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` when decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless
the output is paid to another address, see Transaction Decoding), do not always only the user is paid, see Transaction Decoding), do not always read as a
read as a number. They can read: number. They can read:
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known: - 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. truncated to four decimals, with the floor and the zero cases described above.
@@ -1193,7 +1213,13 @@ read as a number. They can read:
guarantees nothing: it sets `Token Out` and `Min. received` only when the 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 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 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 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 reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -1498,6 +1524,21 @@ 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 "Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it. 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, and
anything typed there and not saved, which belonged to the previous network, is
dropped. ConfirmTx, WaitTx, SuccessTx, ErrorTx and TransactionDetail are
rendered instead for the network of the transaction they show: the network it
was reviewed on for ConfirmTx, and the network it was sent on for the others.
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 Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every `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 exit from that screen rather than only on its "Back" button, so nothing secret
@@ -1739,42 +1780,59 @@ view would leave a wallet one click from deletion.
- Token contract: full address + etherscan link (ERC-20 only) - Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title - From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name - To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max" - Amount: value + symbol (USD in parentheses), the value to four decimal
filled in is worked out again from this screen's own fee estimate when it places with the nonzero floor, as on TxApproval (see Display Consistency),
arrives, as the balance minus the reserve, and the transaction is signed however Send's amount field wrote it (`.5` and `0.5` both read `0.5000`).
with that estimate's fee fields, so a fee fetched again at signing cannot For a token whose scale is unknown, which cannot be sent, the value is the
exceed what the amount leaves behind. The address keeps whatever part of text from Send's amount field cut the same way (`.5` reads `.5000`). An
the reserve the transaction does not use. If the balance no longer covers ETH amount Send's "Max" filled in is worked out again from this screen's
the fee, the amount is left as it was and the amount-plus-fee error below own fee estimate when it arrives, as the balance minus the reserve, and
blocks the send the transaction is signed with that estimate's fee fields, so a fee
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)` fetched again at signing cannot exceed what the amount leaves behind. The
for a token whose scale is unknown 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", - Network fee: "Estimating..." then two lines, or "Unable to estimate",
fetched async. The first line is what the transfer is expected to cost, fetched async once the balance is read. The first line is what the
`gasLimit * gasPrice` (USD in parentheses); the second is the transfer is expected to cost, `gasLimit * gasPrice` (USD in parentheses);
`gasLimit * maxFeePerGas` reserve the node requires, which is what the the second is the `gasLimit * maxFeePerGas` reserve the node requires,
balance check gates on. The second line is omitted on a network with no which is what the balance check gates on. The second line is omitted on a
type-2 pricing, where the two are the same number, but its space is network with no type-2 pricing, where the two are the same number, but its
reserved either way space is reserved either way
- Warnings: inline warnings from the local checks (scam address, self-send) - Warnings: inline warnings from the local checks (scam address, self-send)
plus four reserved warning boxes made visible by the async checks — plus four reserved warning boxes made visible by the async checks —
recipient that has never sent a transaction and holds no ETH, recipient is 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 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, that has received ETH but never sent a transaction still holds that ETH,
so it gets no warning so it gets no warning
- Errors (insufficient balance), plus three reserved error boxes — the - Errors (an invalid amount), plus five reserved error boxes, so the balance
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to and the fee arriving later move nothing — the amount exceeds the balance
pay the fee for the transfer (ERC-20 transfers), and the fee could not be (for a token whose scale is unknown it says the token's balance is
estimated. The first two are mutually exclusive per transfer type, so only unknown), the balance could not be read (it asks the user to go back and
the applicable one holds space. The last names its cause: for a token try again), the amount plus the fee exceeds the balance (ETH transfers),
whose scale is unknown the fee can never be estimated, and it says the not enough ETH to pay the fee for the transfer (ERC-20 transfers), and the
wallet does not know how many decimal places the token uses and that the fee could not be estimated. The third and fourth are mutually exclusive
transaction cannot be sent; for any other failure it asks the user to go per transfer type, so only the applicable one holds space. The last names
back and try again 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 - Password: an inline field on this screen, not a modal, with its own error
line line
- "Sign & Send" button (disabled if errors, and while the network fee - "Sign & Send" button (disabled if errors, while the balance is being read,
estimate is pending or unavailable) and while the network fee estimate is pending or unavailable)
- **Transitions**: - **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx** - "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx** - "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
@@ -1786,26 +1844,37 @@ view would leave a wallet one click from deletion.
it, and a disagreement is a refusal rather than a preference for either it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`) value (`src/shared/transferAmount.js`)
- "Sign & Send" after the active network was switched in Settings since this - "Sign & Send" after the active network was switched in Settings since this
transaction was reviewed, with this screen open or before the popup transaction was reviewed → nothing is signed; "The active network changed
reopened onto it → nothing is signed; "The active network changed after after this transaction was reviewed, so it was not sent. Please go back
this transaction was reviewed, so it was not sent. Please go back and and review it again." in its own box, no screen change. After a switch,
review it again." in the errors box, no screen change. The transaction "Back" from Settings draws this screen again from the saved transaction,
carries the chain id of the network active when Send showed this screen, as a popup reopened onto it does; when Send is beneath it on the stack, as
and keeps it when the screen is drawn again from the saved transaction, so when Send showed it in the same popup, "Back" lands below Send instead
it cannot be signed for another. The screen describes it on that network, (see the top of Screen Map). The transaction carries the chain id of the
whichever network is active: its amounts are in that network's native network active when Send showed this screen, and keeps it when the screen
currency, its USD values are that network's, so a testnet transaction is drawn again from the saved transaction, so it cannot be signed for
shows none, its From, To and token contract links open that network's another. The screen describes it on that network, whichever network is
explorer, the fee estimate and the contract and never-sent recipient active: its amounts are in that network's native currency, its USD values
checks ask that network's RPC endpoint, and the Etherscan label check asks are that network's, so a testnet transaction shows none, its From, To and
that network's explorer 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 - "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is - "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 selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not screen with another address selected. When the selected wallet does not
hold the From address → full-sentence error on the password error line, hold the From address → nothing is signed; "The selected wallet does not
nothing sent (no screen change) 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, - "Sign & Send" (correct password, but the From address's key is refused,
see Data Model) → nothing is signed → **ErrorTx** saying why see Data Model) → nothing is signed → **ErrorTx** saying why
- "Back" → **Send** - "Back" → **Send**
@@ -1825,14 +1894,17 @@ view would leave a wallet one click from deletion.
no recipient." instead no recipient." instead
- Transaction hash: full hash (tap to copy) + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link
- Count-up timer: "Waiting for confirmation... Ns" - Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is - **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The screen
persisted: closing and reopening the popup resumes the poll, with the elapsed describes the transaction on the network it was sent on, whichever network is
counter and the timeout deadline still measured from the original broadcast. A active: the receipt is asked of that network's RPC endpoint, the one Settings
lookup that fails is retried on the next tick rather than counted as a missing has for it, and the To and hash links open that network's explorer. The wait
receipt, because a failed lookup says nothing about the transaction; but six is persisted: closing and reopening the popup resumes the poll, with the
failures in a row (60 seconds at the poll cadence) end the wait, so an RPC elapsed counter and the timeout deadline still measured from the original
that never answers cannot leave it running indefinitely. Any lookup that broadcast. A lookup that fails is retried on the next tick rather than counted
answers resets that count. as a missing receipt, because a failed lookup says nothing about the
transaction; but six failures in a row (60 seconds at the poll cadence) end
the wait, so an RPC that never answers cannot leave it running indefinitely.
Any lookup that answers resets that count.
- **Transitions**: - **Transitions**:
- Receipt found → **SuccessTx** - Receipt found → **SuccessTx**
- A lookup that answers "no receipt" 60 seconds or more after broadcast → - A lookup that answers "no receipt" 60 seconds or more after broadcast →
@@ -1853,9 +1925,12 @@ view would leave a wallet one click from deletion.
- Amount + symbol - Amount + symbol
- To: color dot + full address + etherscan link, or for a contract creation - To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx the same sentence as on WaitTx
- Block number - Block number + etherscan link
- Transaction hash: full hash (tap to copy) + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button - "Done" button
- **Behavior**: As on WaitTx, every etherscan link on the screen opens the
explorer of the network the transaction was sent on, whichever network is
active.
- **Transitions**: - **Transitions**:
- "Done" in the approval popup → closes the popup window - "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken** - "Done" otherwise → resets the navigation stack, then → **AddressToken**
@@ -1875,6 +1950,9 @@ view would leave a wallet one click from deletion.
- Transaction hash section (hidden if broadcast failed before getting hash): - Transaction hash section (hidden if broadcast failed before getting hash):
full hash (tap to copy) + etherscan link full hash (tap to copy) + etherscan link
- "Done" button - "Done" button
- **Behavior**: As on WaitTx, every etherscan link on the screen opens the
explorer of the network the transaction was sent on, whichever network is
active.
- **Transitions**: - **Transitions**:
- "Done" in the approval popup → closes the popup window - "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken** - "Done" otherwise → resets the navigation stack, then → **AddressToken**
@@ -1897,7 +1975,10 @@ view would leave a wallet one click from deletion.
#### TransactionDetail (`transaction`) #### TransactionDetail (`transaction`)
- **When**: User tapped a transaction row on Home, AddressDetail, or - **When**: User tapped a transaction row on Home, AddressDetail, or
AddressToken. AddressToken. The screen describes the transaction on the network it was sent
on, whichever network is active: its block, network details, decoded details
and raw data are read from that network's Blockscout, the one Settings has for
it, and its etherscan links open that network's explorer.
- **Elements** (grouped into logical blocks using light well containers; field - **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings): labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button - "Transaction" heading, "Back" button
@@ -1936,13 +2017,18 @@ view would leave a wallet one click from deletion.
- Contract address input - Contract address input
- Status line ("Looking up token...", cleared or replaced on failure) - Status line ("Looking up token...", cleared or replaced on failure)
- Common token quick-pick buttons (top 25 by market cap), which fill the - 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 - "Add" button
- **Transitions**: - **Transitions**:
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders - "Add" (valid contract) → tracks the token, pops the stack, and re-renders
**AddressDetail** **AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed - "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change 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) - "Back" → previous screen (AddressDetail)
#### Settings (`settings`) #### Settings (`settings`)
@@ -1953,16 +2039,19 @@ view would leave a wallet one click from deletion.
- Wallets: one row per wallet with its name (tap to rename inline), a - 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, `[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
plus a "+ Add wallet" button plus a "+ Add wallet" button
- Tracked Tokens: one row per tracked token with an `[x]` remove button, - Tracked Tokens: one row per token tracked on the active network with an
plus a "+ Add token" button `[x]` remove button, plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox, "UTC - Display: "Show tracked tokens with zero balance" checkbox, "UTC
Timestamps" checkbox, and a Theme selector (System / Light / Dark) Timestamps" checkbox, and a Theme selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The - Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
network changes only here, or when the user approves a site's request on network changes only here, or when the user approves a site's request on
**NetworkApproval**; either way, switching restores the RPC and Blockscout **NetworkApproval**; either way, switching restores the RPC and Blockscout
endpoints last used on that network, or that network's defaults if it has 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. none, and the tokens tracked on that network, and sends `chainChanged`
Choosing the network already active changes nothing and sends nothing 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 - Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved) `eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against - Blockscout API: endpoint URL input + "Save" button (validated against
@@ -2007,7 +2096,8 @@ view would leave a wallet one click from deletion.
- `[x]` on a denied site → forgets the refusal, in place; it connects - `[x]` on a denied site → forgets the refusal, in place; it connects
nothing and tells the background nothing nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change) - 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`) #### ShowRecoveryPhrase (`show-phrase`)
@@ -2190,7 +2280,8 @@ view would leave a wallet one click from deletion.
#### SettingsAddToken (`settings-addtoken`) #### SettingsAddToken (`settings-addtoken`)
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked - **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**: - **Elements**:
- "Back" button, "Add Token" heading - "Back" button, "Add Token" heading
- Instruction text - Instruction text
@@ -2199,11 +2290,19 @@ view would leave a wallet one click from deletion.
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) + - "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
"Add selected" button "Add selected" button
- "Or enter contract address:" input, a status line, and an "Add" 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**: - **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) "Added SYMBOL" flash message (no screen change)
- A duplicate, a scam-listed address, or a failed contract lookup → flash - A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change 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) - "Back" → previous screen (Settings)
#### SiteApproval (`approve-site`) #### SiteApproval (`approve-site`)
@@ -2280,15 +2379,18 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a - Site origin (bold, scheme and port included) + "wants to send a
transaction" transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline, and for a swap whose output is paid to an amounts, what each `PERMIT2_TRANSFER_FROM` moves and where to, steps,
address other than the From address, each address it is paid to (see deadline, and unless a swap pays only the From address, a `Paid to` line
Transaction Decoding) for each other address it pays, or saying that a token stays in the router
or who it pays is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx known; for a contract creation, the same sentence as on WaitTx
- Value: amount in ETH (4 decimal places, USD in parentheses) - Value: amount in ETH (4 decimal places with the nonzero floor, USD in
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD parentheses)
in parentheses), with the gas limit and the fee per gas in gwei below it - 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 - Network and nonce
- Raw data: full calldata displayed inline (shown if present) - Raw data: full calldata displayed inline (shown if present)
- Password input and an error line - Password input and an error line
@@ -2420,8 +2522,9 @@ view would leave a wallet one click from deletion.
- **Transitions**: - **Transitions**:
- "Switch" → closes popup; the background switches the network as - "Switch" → closes popup; the background switches the network as
**Settings** does, restoring the RPC and Blockscout endpoints last used on **Settings** does, restoring the RPC and Blockscout endpoints last used on
that network (or that network's defaults if it has none), sends that network (or that network's defaults if it has none) and the tokens
`chainChanged` to every open tab, and answers the site with success 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 - "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
nothing changes nothing changes
- Popup window closed without answering → the same as "Reject" - Popup window closed without answering → the same as "Reject"
@@ -2900,41 +3003,153 @@ Currently supported:
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline. "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
The swap summary is shown only for a transaction sent to one of the Universal 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 Router addresses Uniswap publishes for the network: on TxApproval the network
the transaction is for, on TransactionDetail the active network. The same the transaction is for, on TransactionDetail the network it was sent on. The
calldata sent to any other address is shown as raw data. Decodes Permit2, 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 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 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 on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
any other shape is not read, and its step is listed as any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`. Each step is named by its command type, `V4 Swap (could not be decoded)`. Each step is named and read as the command
which the decoder reads from the command byte with the mask the router at that that the release of the router at that address runs for its command type, as
address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for that release's `contracts/libraries/Commands.sol` names it. The decoder reads
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for the type from the command byte with the mask that router uses: `0x1f` for
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag `UniversalRouterV1`, `0x3f` for `UniversalRouterV1_2_V2Support` and
(`0x80`) therefore does not change a step's name. The decoder does not read `UniversalRouterV2`, and `0x7f` for `UniversalRouterV2_1_1` and
the commands inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that `UniversalRouterV2_1_2`. The allow-revert flag (`0x80`) therefore does not
step stands they may spend the user's tokens or pay the user. That step is change the command a step is read as. Releases run different commands for some
listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other types: on `UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` to
steps state, the summary names no swap direction, `Token In` and `Token Out` `0x13` are NFT commands, where the later routers run `V4_SWAP`,
read `Unknown (could not be decoded)`, and there is no `Amount` or `V3_POSITION_MANAGER_PERMIT`, `V3_POSITION_MANAGER_CALL` and
`Min. received` line. Each swap step, `SWEEP`, `UNWRAP_WETH` and V4 `TAKE` `V4_INITIALIZE_POOL`. The decoder does not read those routers' NFT commands,
names the address it pays, and `TAKE_ALL` pays the transaction's sender. The and lists each by its name there, for example `Seaport` for `SEAPORT` (`0x10`
router reads two of those addresses as placeholders: `address(1)` is the on `UniversalRouterV1`). A type a release names no command for is listed by
transaction's sender, and `address(2)` is the router itself, which holds what its number, for example `Command 0x0e` on `UniversalRouterV1`, which has no
it is paid until a later step pays it out. The output is paid to the address `BALANCE_CHECK_ERC20` and reverts on it. The router reverts the whole
of the step that sets the output side (for a V4 step, of every `TAKE` and transaction when a step fails, unless the step's command byte has that flag
`TAKE_ALL` of its output currency), or, when no step sets it, of the set and its command is one the router lets fail, as each release's
`UNWRAP_WETH` that makes `Token Out` ETH. While the router holds it, the next `contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`,
`SWEEP` of the output token, or `UNWRAP_WETH` of a WETH output, pays it to `UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`, `PERMIT2_PERMIT`,
that step's address. A `BALANCE_CHECK_ERC20` step pays nothing and leaves the `PERMIT2_PERMIT_BATCH`, `BALANCE_CHECK_ERC20`, `V3_POSITION_MANAGER_PERMIT`,
address as it was. An output nothing pays out is paid to the router. When the `V3_POSITION_MANAGER_CALL`, `V4_INITIALIZE_POOL`, `V4_POSITION_MANAGER_CALL`
output is paid to any address other than the transaction's sender (the From and `EXECUTE_SUB_PLAN`; on `UniversalRouterV1_2_V2Support`,
address on TxApproval, the sender the explorer reports on TransactionDetail), `BALANCE_CHECK_ERC20`, `EXECUTE_SUB_PLAN` and its NFT market calls and NFT
a `Paid to` line after `Token Out` names each address it is paid to, the owner checks; on `UniversalRouterV1`, its NFT market calls and NFT owner
sender's included, and `Min. received` reads `Min. paid out`. A swap paid to checks. Such a step may then fail while the transaction goes on to the next
the sender, directly or through the router, has neither. The decoder does not step, and it is listed with ` (allowed to fail)` after its name, for example
read `TRANSFER`, `PAY_PORTION` or V4 `TAKE_PORTION`, each of which pays an `Balance Check (allowed to fail)`. On every other command, among them each V2,
amount or a share to an address of its own. 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 any token, the swap's output or another, a
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
paid it into ETH, that token stays in the router unless a later step pays out
or spends all the router holds of it: a `SWEEP` of that token to anyone but
the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of
`CONTRACT_BALANCE` when it is ETH, a `TRANSFER` of `CONTRACT_BALANCE`, a
`PAY_PORTION` of 10,000 bips or a `PAY_PORTION_FULL_PRECISION` of 1e18 of that
token to anyone but the router, an `ACROSS_V4_DEPOSIT_V3` of
`CONTRACT_BALANCE` of that token, or of ETH when it sets `useNative`, or a V2
or V3 exact-in swap step or V4 `SETTLE` that pays `CONTRACT_BALANCE` of that
token from the router (`payerIsUser` false). V2 and V3 exact-in swap steps and
V4 `SETTLE`s that pay WETH from the router also spend all the WETH a
`WRAP_ETH` paid it once together they spend the amount the `WRAP_ETH` states,
as a swap paid in ETH does, but only when that amount is not
`CONTRACT_BALANCE` and the router held no WETH from an earlier step when the
`WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1
WETH from it reads as leaving the WETH there. The router pays a `SETTLE` of
`OPEN_DELTA` (zero) only what its step still owes of that token when the
`SETTLE` runs, so one from the router counts as spending the amounts the
step's swap actions state they spend of that token only when every action in
the step could be read, every swap action names the token it spends and the
token it buys (a swap action with an empty path swaps nothing), the `SETTLE`
is the step's only `SETTLE` or `SETTLE_ALL` of that token and comes after
every swap action, no swap action buys that token, and the swap actions that
spend it state a quantity together; otherwise it counts as spending nothing.
So the rest of a V4 `SETTLE` of more than its swap spends, taken to the
router, the WETH of a `WRAP_ETH`, and a token a swap pays the router on the
way to the output, each stays in the router when later steps do not spend all
of it, where anyone can take it in a later transaction. 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)` for each token
that stays in the router, followed, for any token but the output, by a
`Token moved into the router` line giving that token in the same way (ETH by
name); 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`. After the `Amount` line (after `Token In` when
there is none), each `PERMIT2_TRANSFER_FROM` but one into the router, in step
order, gives three lines: `Moved from the sender`, the amount it moves, for
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the
`Token In` line does; and `Moved to`, which reads
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and
`Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. These lines change neither the
`Paid to` lines nor the minimum's label. 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 ### Non-Goals Forever
+72 -28
View File
@@ -664,6 +664,20 @@
id="confirm-errors" id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]" class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
></div> ></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 <div
id="confirm-amount-fee-error" id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible" class="mb-2 border border-border border-dashed p-2 text-xs invisible"
@@ -683,6 +697,33 @@
id="confirm-fee-unknown-error" id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible" class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div> ></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"> <div class="mb-2">
<label class="block mb-1 text-xs">Password</label> <label class="block mb-1 text-xs">Password</label>
<input <input
@@ -838,7 +879,7 @@
id="add-token-info" id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible" class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
></div> ></div>
<div class="mb-2"> <div id="add-token-common" class="mb-2">
<label class="block mb-1 text-xs text-muted" <label class="block mb-1 text-xs text-muted"
>Or pick a common token:</label >Or pick a common token:</label
> >
@@ -1322,36 +1363,39 @@
<h2 class="font-bold mb-2">Add Token</h2> <h2 class="font-bold mb-2">Add Token</h2>
<p class="text-xs text-muted mb-3"> <p class="text-xs text-muted mb-3">
Pick a common token or enter a contract address manually. Pick a common token or enter a contract address manually.
Common tokens are offered on Ethereum Mainnet only.
</p> </p>
<!-- top 10 quick-pick buttons --> <div id="settings-addtoken-common">
<div class="mb-3"> <!-- top 10 quick-pick buttons -->
<label class="block mb-1 text-xs text-muted" <div class="mb-3">
>Top tokens:</label <label class="block mb-1 text-xs text-muted"
> >Top tokens:</label
<div >
id="settings-addtoken-top10" <div
class="flex flex-wrap gap-1" id="settings-addtoken-top10"
></div> class="flex flex-wrap gap-1"
</div> ></div>
</div>
<!-- top 100 dropdown --> <!-- top 100 dropdown -->
<div class="mb-3"> <div class="mb-3">
<label class="block mb-1 text-xs text-muted" <label class="block mb-1 text-xs text-muted"
>Or pick from top 100:</label >Or pick from top 100:</label
> >
<select <select
id="settings-addtoken-select" id="settings-addtoken-select"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
> >
<option value="">-- select --</option> <option value="">-- select --</option>
</select> </select>
<button <button
id="btn-settings-addtoken-select" id="btn-settings-addtoken-select"
class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 mt-1 hover:bg-fg hover:text-bg cursor-pointer"
> >
Add selected Add selected
</button> </button>
</div>
</div> </div>
<!-- manual contract address --> <!-- manual contract address -->
+26 -10
View File
@@ -38,9 +38,11 @@ function markViewRendered(view) {
if (view) renderedViews.add(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 // unit tests, which simulate several page loads against one module
// instance, ask for one. // instance, ask for one too.
function resetRenderedViews() { function resetRenderedViews() {
renderedViews.clear(); renderedViews.clear();
} }
@@ -125,28 +127,33 @@ function isRenderableDecoded(value) {
} }
// The pending transaction confirmTx.show() renders: `token` reaches // The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach // renderAddressHtml() when it is not "ETH", `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which // addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
// show() fills in when it is absent, picks the network whose explorer the // truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in
// address links open, so it has to name one. // when it is absent, picks the network whose explorer the address links open,
// so it has to name one.
function isRenderablePendingTx(value) { function isRenderablePendingTx(value) {
return ( return (
isRecord(value) && isRecord(value) &&
isText(value.token) && isText(value.token) &&
isAddressText(value.from) && isAddressText(value.from) &&
isAddressText(value.to) && isAddressText(value.to) &&
isText(value.amount) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId)) (!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
); );
} }
// The stored transaction transactionDetail.render() shows. contractAddress is // The stored transaction transactionDetail.render() shows. contractAddress is
// optional on an ETH transfer, and reaches addressDotHtml() when it is there. // optional on an ETH transfer, and reaches addressDotHtml() when it is there.
// `chainId` picks the network whose explorer the links open and whose
// Blockscout the transaction is read from, so it has to name one.
function isRenderableTx(value) { function isRenderableTx(value) {
return ( return (
isRecord(value) && isRecord(value) &&
isAddressText(value.from) && isAddressText(value.from) &&
isAddressText(value.to) && isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress)) (!value.contractAddress || isAddressText(value.contractAddress)) &&
SUPPORTED_CHAIN_IDS.has(value.chainId)
); );
} }
@@ -198,11 +205,19 @@ function renderView(view, state, views) {
if (!data.hash) return false; if (!data.hash) return false;
if (!isAddressText(data.to)) return false; if (!isAddressText(data.to)) return false;
if (!isRenderableDecoded(data.decoded)) return false; if (!isRenderableDecoded(data.decoded)) return false;
// `chainId` picks the network whose explorer the links open, so
// when it is there it has to name one. Likewise on error-tx.
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
return false;
}
views.txStatus.renderSuccess(); views.txStatus.renderSuccess();
return true; return true;
case "error-tx": case "error-tx":
if (!data.message) return false; if (!data.message) return false;
if (!isAddressText(data.to)) return false; if (!isAddressText(data.to)) return false;
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
return false;
}
views.txStatus.renderError(); views.txStatus.renderError();
return true; return true;
default: default:
@@ -217,9 +232,10 @@ function renderView(view, state, views) {
// in the two cases where the view is known to be on the page already: // 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 // - It is not one the popup renders from persisted state. The restored
// stack is filtered against RESTORABLE_VIEWS, so such a view can only // stack is filtered against RESTORABLE_VIEWS, and a network switch cuts
// be on the stack from this page load, where forward navigation // it the same way (views/settings.js), so such a view can only be on the
// rendered it on the way in. // 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 // - This page load has rendered it. Re-rendering would re-fetch and
// clobber what it holds; Home is rendered anyway, see above. // 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 { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -9,6 +9,12 @@ function show() {
$("add-token-address").value = ""; $("add-token-address").value = "";
$("add-token-info").textContent = ""; $("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden"; $("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"); const list = $("common-token-list");
list.innerHTML = getTopTokens(25) list.innerHTML = getTopTokens(25)
.map( .map(
@@ -46,6 +52,9 @@ function init(ctx) {
infoEl.textContent = "Looking up token..."; infoEl.textContent = "Looking up token...";
infoEl.style.visibility = "visible"; infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", contractAddr); 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 { try {
const info = await lookupTokenInfo( const info = await lookupTokenInfo(
contractAddr, contractAddr,
@@ -53,6 +62,15 @@ function init(ctx) {
state.networkId, state.networkId,
ctx.pageClosed, 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); log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({ state.trackedTokens.push({
address: contractAddr, address: contractAddr,
+12 -14
View File
@@ -11,6 +11,7 @@ const {
onViewLeave, onViewLeave,
formatFee, formatFee,
tokenLabel, tokenLabel,
tokenSourcesByChainId,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { const {
@@ -85,13 +86,12 @@ function tokenAmountText(rawAmount, decimals, symbol) {
function decodeCalldata(data, toAddress, chainId, fromAddress) { function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null; if (!data || data === "0x" || data.length < 10) return null;
// Where a token's scale is looked for, for every decoder below: the ERC-20 // Where a token's name and scale are looked for, for every decoder below:
// amount line and the swap's Amount and Min. received lines resolve it the // what the wallet knows of the tokens on the network with `chainId`, which
// same way, and refuse to format the same way when it is nowhere. // need not be the active one. The ERC-20 amount line and the swap's Amount
const decimalsSources = { // and Min. received lines resolve the scale the same way, and refuse to
trackedTokens: state.trackedTokens, // format the same way when it is nowhere.
wallets: state.wallets, const decimalsSources = tokenSourcesByChainId(chainId);
};
// Try ERC-20 (approve / transfer) // Try ERC-20 (approve / transfer)
try { try {
@@ -261,10 +261,7 @@ function showTxApproval(details) {
const toAddr = approvedTx.to; const toAddr = approvedTx.to;
const ethValue = formatEther(approvedTx.value || "0"); const ethValue = formatEther(approvedTx.value || "0");
const sources = { const sources = tokenSourcesByChainId(approvedTx.chainId);
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Build txInfo for status screens // Build txInfo for status screens
pendingTxDetails = { pendingTxDetails = {
@@ -344,8 +341,9 @@ function showTxApproval(details) {
$("approve-tx-origin").textContent = details.origin; $("approve-tx-origin").textContent = details.origin;
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom); $("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
// Show token symbol next to contract address if known // Show token symbol next to contract address if known on the network the
const symbol = toAddr ? tokenLabel(toAddr) : null; // transaction is for
const symbol = toAddr ? tokenLabel(toAddr, approvedTx.chainId) : null;
if (toAddr) { if (toAddr) {
let toHtml = ""; let toHtml = "";
if (symbol) { if (symbol) {
@@ -388,7 +386,7 @@ function showTxApproval(details) {
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`; detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`;
} }
detailsHtml += approvalAddressHtml(d.address); detailsHtml += approvalAddressHtml(d.address);
} else { } else {
+206 -146
View File
@@ -2,7 +2,13 @@
// Shows transaction details, warnings, errors. On Sign & Send, // Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts. // reads inline password, decrypts secret, signs and broadcasts.
const { parseEther, parseUnits, formatEther, Contract } = require("ethers"); const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const { const {
$, $,
showError, showError,
@@ -17,12 +23,10 @@ const {
goBack, goBack,
onViewLeave, onViewLeave,
formatFee, formatFee,
reviewedNetworkProvider,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { const { nativeCurrencyByChainId } = require("../../shared/networks");
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
@@ -35,11 +39,12 @@ const {
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants"); const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const { const {
displayedDecimals, displayedDecimals,
toDecimals,
transferAmountUnits, transferAmountUnits,
} = require("../../shared/transferAmount"); } = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify"); const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The balance lines, the fee reserve and the insufficient-balance messages go // The amount, the balance line and the fee reserve go through it, as the
// through it, as the approval screen's amounts do. // approval screen's amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { const {
CODES, CODES,
@@ -63,6 +68,13 @@ let feeWei = null;
// amount was derived from. Null for any other send, which ethers prices from // amount was derived from. Null for any other send, which ethers prices from
// the node at signing time. // the node at signing time.
let maxSendFees = null; 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() { function restore() {
const d = state.viewData; const d = state.viewData;
@@ -86,8 +98,32 @@ function valueWithUsd(text, usdAmount) {
return text; return text;
} }
// The Amount line, with its USD value. A max ETH send's line is drawn again // The amount as this screen and the wait, success and error screens show it,
// once its amount is re-derived from the fee estimate. // 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) { function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 const rawSymbol = isErc20
@@ -96,23 +132,53 @@ function renderAmount(txInfo) {
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId); const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null; const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd( $("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol), shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
amountUsd, 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) { function show(txInfo) {
pendingTx = txInfo; pendingTx = txInfo;
feeStatus = FEE_PENDING; feeStatus = FEE_PENDING;
feeWei = null; feeWei = null;
maxSendFees = null; maxSendFees = null;
balances = null;
// The network this screen is drawn for, and the only one it sends on: the // 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 // one active when Send handed the transaction over. Drawn again from the
// saved transaction, as when the popup reopens onto this screen, it keeps // saved transaction, as when the popup reopens onto this screen, it keeps
// that network, so Sign & Send refuses once Settings has switched to // that network, so Sign & Send refuses once Settings has switched to
// another. Everything this screen shows is that network's: native // another. Everything this screen shows is that network's: native
// currency, USD values, explorer links, and the node and explorer its // currency, USD values, explorer links, the balance, and the node and
// checks ask. The wait, success and error screens label amounts by it. // explorer its checks ask. The wait, success and error screens label
// amounts by it.
if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId; if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
@@ -165,33 +231,7 @@ function show(txInfo) {
$("confirm-to-ens").classList.add("hidden"); $("confirm-to-ens").classList.add("hidden");
renderAmount(txInfo); renderAmount(txInfo);
renderBalance(txInfo);
// Balance (with inline USD)
const ethPrice = getPrice("ETH", txInfo.chainId);
const tokenPrice = getPrice(rawSymbol, txInfo.chainId);
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,
);
}
// Check for warnings (synchronous local checks) // Check for warnings (synchronous local checks)
const localWarnings = getLocalWarnings(txInfo.to, { const localWarnings = getLocalWarnings(txInfo.to, {
@@ -257,11 +297,29 @@ function show(txInfo) {
" cannot be checked against your balance. Please go back and" + " cannot be checked against your balance. Please go back and" +
" try again."; " 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); renderValidation(txInfo);
// Reset password field and error // Reset password field and error, and any refusal of an earlier Sign &
// Send.
$("confirm-tx-password").value = ""; $("confirm-tx-password").value = "";
hideError("confirm-tx-password-error"); 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 // Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible"; $("confirm-fee").style.visibility = "visible";
@@ -282,69 +340,78 @@ function show(txInfo) {
$("confirm-burn-warning").style.visibility = "visible"; $("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); 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 // 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 // show(), again when the balances are read, and again when the fee estimate
// touches already occupies its space, so re-running it never moves anything. // resolves or fails. Every element it touches already occupies its space, so
// re-running it never moves anything.
function renderValidation(txInfo) { function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?") // Null while the balances are being read and after a read that failed.
: nativeCurrencyByChainId(txInfo.chainId); // 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({ const { canSend, codes } = validateTransfer({
isErc20, isErc20,
amount: txInfo.amount, amount: txInfo.amount,
tokenDecimals: txInfo.tokenDecimals, tokenDecimals: txInfo.tokenDecimals,
ethBalance: txInfo.balance, ethBalance,
tokenBalance: txInfo.tokenBalance, tokenBalance,
feeStatus, feeStatus,
feeWei, feeWei,
}); });
// Messages carrying the user's own numbers are built here; the fixed // An invalid amount is known before the first paint, so its message is
// sentences live in the reserved elements in index.html, except the // written here. Every message a later result brings has a reserved
// gas and fee-unknown ones, which show() sets. // 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 = []; const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) { if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send."); 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"); const errorsEl = $("confirm-errors");
if (messages.length > 0) { if (messages.length > 0) {
@@ -357,13 +424,20 @@ function renderValidation(txInfo) {
errorsEl.style.visibility = "hidden"; 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( setVisible(
"confirm-amount-fee-error", "confirm-amount-fee-error",
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE), balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
); );
setVisible( setVisible(
"confirm-gas-error", "confirm-gas-error",
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE), balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
); );
setVisible( setVisible(
"confirm-fee-unknown-error", "confirm-fee-unknown-error",
@@ -382,20 +456,6 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden"; $(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) { async function estimateGas(txInfo) {
try { try {
const provider = reviewedNetworkProvider(txInfo); const provider = reviewedNetworkProvider(txInfo);
@@ -472,14 +532,14 @@ async function estimateGas(txInfo) {
} }
feeStatus = FEE_KNOWN; feeStatus = FEE_KNOWN;
feeWei = gasCostWei; feeWei = gasCostWei;
// A max ETH send is the balance minus this estimate's reserve, not the // A max ETH send is the balance readBalances() read minus this
// Send screen's, and is signed with this estimate's fee fields: fees // estimate's reserve, not the Send screen's, and is signed with this
// fetched again at signing could exceed the reserve it leaves, and the // estimate's fee fields: fees fetched again at signing could exceed
// node would refuse it for want of funds. Where the balance no longer // the reserve it leaves, and the node would refuse it for want of
// covers the fee, the amount is left as it is and the balance check // funds. Where the balance is unknown or no longer covers the fee, the
// below says so. // amount is left as it is and the balance check below says so.
if (txInfo.max && txInfo.token === "ETH") { if (txInfo.max && txInfo.token === "ETH") {
const amount = maxEthAmount(txInfo.balance, gasCostWei); const amount = maxEthAmount(balances?.eth, gasCostWei);
if (amount !== null) { if (amount !== null) {
txInfo.amount = amount; txInfo.amount = amount;
renderAmount(txInfo); renderAmount(txInfo);
@@ -508,16 +568,17 @@ async function estimateGas(txInfo) {
} }
} }
// Populate the transaction this send describes, enforce the fee bound against // Populate the transaction this send describes, enforce the bounds against the
// the fees that were actually filled in, then sign and broadcast it. Apart // gas limit and fees that were actually filled in, then sign and broadcast it.
// from a max ETH send, which passes its estimate's fee fields as `fees`, the // Apart from a max ETH send, which passes its estimate's fee fields as `fees`,
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from // the send pins no fee fields, so ethers fills maxFeePerGas and the gas limit
// what the configured RPC node answers, with nothing otherwise bounding what a // from what the configured RPC node answers, with nothing otherwise bounding
// hostile node can set — the dApp path's ceilings never reached this one. // what a hostile node can set — the dApp path's ceilings never reached this
// Populating before the check is what makes assertWithinCeilings() see the // one. Populating before the check is what makes assertWithinCeilings() see
// same numbers that would be signed; it throws an ApprovalMismatchError when // the same numbers that would be signed; it throws an ApprovalMismatchError
// the product gasLimit × maxFeePerGas is over the bound, which the caller // when the gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
// shows in the reserved error area rather than sending. // 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) { async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request; let request;
if (tx.token === "ETH") { if (tx.token === "ETH") {
@@ -545,15 +606,6 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
return connectedSigner.sendTransaction(populated); return connectedSigner.sendTransaction(populated);
} }
// Show a full-sentence send failure in the reserved errors box, the same
// element and markup renderValidation() uses for messages carrying the user's
// own numbers, so it never moves anything on the screen.
function showSendError(message) {
const el = $("confirm-errors");
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
el.style.visibility = "visible";
}
async function checkRecipientHistory(txInfo) { async function checkRecipientHistory(txInfo) {
try { try {
const provider = reviewedNetworkProvider(txInfo); const provider = reviewedNetworkProvider(txInfo);
@@ -561,6 +613,9 @@ async function checkRecipientHistory(txInfo) {
fromAddress: txInfo.from, fromAddress: txInfo.from,
chainId: txInfo.chainId, 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) { for (const w of asyncWarnings) {
if (w.type === "contract") { if (w.type === "contract") {
$("confirm-contract-warning").style.visibility = "visible"; $("confirm-contract-warning").style.visibility = "visible";
@@ -589,6 +644,11 @@ function init(_ctx) {
onViewLeave("confirm-tx", clearPassword); onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => { $("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]; const wallet = state.wallets[state.selectedWallet];
// Every Send button refuses a defective wallet before this screen, // Every Send button refuses a defective wallet before this screen,
// but the popup also reopens onto it from a saved view. // but the popup also reopens onto it from a saved view.
@@ -599,15 +659,11 @@ function init(_ctx) {
} }
// The screen describes the network the transaction was reviewed for, // The screen describes the network the transaction was reviewed for,
// which Settings may have switched away from since: with this screen // which Settings may have switched away from since: "Back" from
// open, where Back only unhides it, or before the popup reopened onto // Settings after a switch draws this screen again from the saved
// it. // transaction, as a popup reopened onto it does.
if (currentNetwork().chainId !== pendingTx.chainId) { if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError( setVisible("confirm-network-changed-error", true);
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
return; return;
} }
@@ -616,11 +672,7 @@ function init(_ctx) {
// address selected. // address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from); const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) { if (!addr) {
showError( setVisible("confirm-from-address-error", true);
"confirm-tx-password-error",
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
return; return;
} }
@@ -652,6 +704,10 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true; $("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted"); $("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; let tx;
try { try {
const signer = getSignerForAddress(wallet, addr, decryptedSecret); const signer = getSignerForAddress(wallet, addr, decryptedSecret);
@@ -668,19 +724,23 @@ function init(_ctx) {
// Note: JS strings are immutable; this nulls the reference but // Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC. // the original string may persist in memory until GC.
decryptedSecret = null; decryptedSecret = null;
txStatus.showWait(pendingTx, tx.hash); txStatus.showWait(shownTx, tx.hash);
} catch (e) { } catch (e) {
decryptedSecret = null; decryptedSecret = null;
// A fee over the bound is refused before anything is broadcast, so // A gas limit or fee over its bound is refused before anything is
// there is no transaction that may have reached the network to warn // broadcast, so there is no transaction that may have reached the
// about: the message stays on the confirmation screen where the // network to warn about: the refusal stays on the confirmation
// user can go back, rather than routing to the sent/failed screen. // 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) { if (e && e.approvalMismatch) {
showSendError(e.message); log.errorf("send refused:", e.message);
setVisible("confirm-fee-bound-error", true);
return; return;
} }
const hash = tx ? tx.hash : null; const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message); txStatus.showError(shownTx, hash, e.shortMessage || e.message);
} finally { } finally {
$("btn-confirm-send").disabled = false; $("btn-confirm-send").disabled = false;
$("btn-confirm-send").classList.remove("text-muted"); $("btn-confirm-send").classList.remove("text-muted");
+58 -11
View File
@@ -1,4 +1,7 @@
// Shared DOM helpers used by all views. // Helpers the views in this directory share: showing a view and going back,
// error and flash lines and the warning banners, formatting of amounts, fees,
// balances, addresses and dates, the RPC and Blockscout URLs, node and tokens
// of the network a chain id names, explorer links, and click-to-copy.
// //
// Escaping rule for every view in this directory, since they all build // Escaping rule for every view in this directory, since they all build
// markup by concatenation: any VALUE interpolated into an innerHTML string // markup by concatenation: any VALUE interpolated into an innerHTML string
@@ -19,6 +22,7 @@ const {
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount"); const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
@@ -128,7 +132,8 @@ function showView(name) {
} }
// Create or update the debug/insecure warning banner. // Create or update the debug/insecure warning banner.
// Called on every view switch and after the settings debug toggle changes. // Called on every view switch, after the settings debug toggle changes, and
// after a network switch in Settings.
// The banner is shown when the compile-time DEBUG constant is true OR when // The banner is shown when the compile-time DEBUG constant is true OR when
// the user has enabled runtime debug mode via the settings easter egg, OR // the user has enabled runtime debug mode via the settings easter egg, OR
// when the active network is a testnet. // when the active network is a testnet.
@@ -295,15 +300,30 @@ function nativeCurrency() {
return currentNetwork().nativeCurrency; return currentNetwork().nativeCurrency;
} }
// The symbol shown for a token line, resolved from the bundled list, the // What the wallet knows of the tokens on the network with this chain id, or
// tokens the user tracks, and the explorer's report — the same chain the // on the active network when no chain id is given, as the `sources` that
// amount line's scale comes from. Null when no source names one, so the token // resolveTokenSymbol() and resolveTokenDecimals() take. A contract address
// lines keep saying `Unknown token` for a token nothing knows. // names a token on one network only, and every token balance held is the
function tokenLabel(address) { // active network's, so another network gets the tokens tracked on it
return resolveTokenSymbol(address, { // (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
trackedTokens: state.trackedTokens, function tokenSourcesByChainId(chainId) {
wallets: state.wallets, const net = chainId ? networkByChainId(chainId) : currentNetwork();
}); if (net && net.id === state.networkId) {
return { trackedTokens: state.trackedTokens, wallets: state.wallets };
}
return {
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
}
// The symbol shown for a token line on the network with `chainId`, or on the
// active network when none is given: from the bundled list, the tokens the
// user tracks, and the explorer's report, the sources the amount line's scale
// comes from. Null when no source names one, so the token lines keep saying
// `Unknown token` for a token nothing knows.
function tokenLabel(address, chainId) {
return resolveTokenSymbol(address, tokenSourcesByChainId(chainId));
} }
// A network fee in wei as the confirmation and approval screens both show it: // A network fee in wei as the confirmation and approval screens both show it:
@@ -606,6 +626,30 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` + `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`; `</svg></span>`;
// The RPC and Blockscout URLs Settings has for the network with this chain id,
// which need not be the active one: ConfirmTx, WaitTx and TransactionDetail
// use the network their transaction's chain id names, and the popup can reopen
// onto them after Settings switched the network. The active network's are
// state.rpcUrl and state.blockscoutUrl, any other's the ones remembered for it,
// or its defaults (see applyChainSwitchFields()).
function endpointsByChainId(chainId) {
const net = networkByChainId(chainId);
if (net.id === state.networkId) {
return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl };
}
const remembered = state.networkEndpoints[net.id] || {};
return {
rpcUrl: remembered.rpcUrl || net.defaultRpcUrl,
blockscoutUrl: remembered.blockscoutUrl || net.defaultBlockscoutUrl,
};
}
// The node of the network a transaction was reviewed for.
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
return getProvider(endpointsByChainId(txInfo.chainId).rpcUrl, net.id);
}
// Block-explorer URLs. The origin is a per-network constant from // Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js, the explorer of the network with `chainId`, or of // 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 // the active network when no chain id is given; only the path segment is
@@ -746,6 +790,7 @@ module.exports = {
keyMaterialName, keyMaterialName,
unknownableAmount, unknownableAmount,
nativeCurrency, nativeCurrency,
tokenSourcesByChainId,
tokenLabel, tokenLabel,
formatFee, formatFee,
addressDotHtml, addressDotHtml,
@@ -761,6 +806,8 @@ module.exports = {
attachCopyHandlers, attachCopyHandlers,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
endpointsByChainId,
reviewedNetworkProvider,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
isoDate, isoDate,
+9 -16
View File
@@ -164,8 +164,9 @@ function renderSendTokenSelect(addr) {
} }
} }
// The token balance and scale the Send screen states and hands the // The token balance the Send screen states, and the scale it hands the
// confirmation screen, so the two screens describe the holding the same way. // 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 // 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 // DISPLAYED this token's balance at: bundled list, then the user's tracked
@@ -188,13 +189,11 @@ function renderSendTokenSelect(addr) {
// //
// The two resolutions can therefore differ, and where they do, the stored // 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 // `balance` is a quantity computed at a scale this screen has just declined to
// stand behind. Stating it would leave validateTransfer() checking the amount // stand behind, so it is withdrawn: unknown scale means unknown balance. It is
// against a number the wallet does not vouch for, so it is withdrawn: unknown // null rather than "0": this screen states it as unknown, as the confirmation
// scale means unknown balance. It is null rather than "0": both screens state // screen does, and Max fills nothing in for it. Only a stored quantity is
// an unknown balance as unknown, and validateTransfer() treats it as no balance // withdrawn: the "0" for a token that has no row at all is an absence of
// to spend from, which is the fail-closed side of an amount nobody can check. // holdings, which is true at every scale.
// 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) { function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(), (t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -378,7 +377,6 @@ function init(_ctx) {
const addr = currentAddress(); const addr = currentAddress();
let tokenSymbol = null; let tokenSymbol = null;
let tokenBalance = null;
let tokenDecimals = null; let tokenDecimals = null;
if (token !== "ETH") { if (token !== "ETH") {
tokenSymbol = resolveSymbol( tokenSymbol = resolveSymbol(
@@ -386,10 +384,7 @@ function init(_ctx) {
addr.tokenBalances, addr.tokenBalances,
state.trackedTokens, state.trackedTokens,
); );
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals( ({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
addr,
token,
));
} }
// The confirmation screen's own amount check, so it is never shown // The confirmation screen's own amount check, so it is never shown
@@ -424,9 +419,7 @@ function init(_ctx) {
ensName: ensName, ensName: ensName,
amount: amount, amount: amount,
token: token, token: token,
balance: addr.balance,
tokenSymbol: tokenSymbol, tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals, tokenDecimals: tokenDecimals,
max: amountIsMax, max: amountIsMax,
}); });
+25
View File
@@ -16,6 +16,8 @@ const {
} = require("../dustThreshold"); } = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { restorableStack } = require("../../shared/persistedState");
const { resetRenderedViews, markViewRendered } = require("../viewRouter");
const { const {
log, log,
debugFetch, debugFetch,
@@ -319,12 +321,35 @@ function init(ctx) {
networkSelect.addEventListener("change", async () => { networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value; const newId = networkSelect.value;
if (newId === state.networkId) return; 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); const net = await onChainSwitch(newId);
// Open pages are told by the background, as after a site's approved // Open pages are told by the background, as after a site's approved
// switch request. // switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
resetRenderedViews();
// Settings itself, and the testnet banner above it, are brought up
// to date for the new network here.
markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
updateDebugBanner(state.currentView);
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
+19 -1
View File
@@ -1,6 +1,6 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers"); const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -75,6 +75,12 @@ function show() {
$("settings-addtoken-address").value = ""; $("settings-addtoken-address").value = "";
$("settings-addtoken-info").textContent = ""; $("settings-addtoken-info").textContent = "";
$("settings-addtoken-info").style.visibility = "hidden"; $("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(); renderTop10();
renderDropdown(); renderDropdown();
showView("settings-addtoken"); showView("settings-addtoken");
@@ -130,6 +136,9 @@ function init(_ctx) {
infoEl.textContent = "Looking up token..."; infoEl.textContent = "Looking up token...";
infoEl.style.visibility = "visible"; infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", addr); 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 { try {
const info = await lookupTokenInfo( const info = await lookupTokenInfo(
addr, addr,
@@ -137,6 +146,15 @@ function init(_ctx) {
state.networkId, state.networkId,
ctx.pageClosed, 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); log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({ state.trackedTokens.push({
address: addr, address: addr,
+32 -19
View File
@@ -18,10 +18,11 @@ const {
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
endpointsByChainId,
displaySymbol, displaySymbol,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
@@ -48,16 +49,16 @@ function getTransactionType(tx) {
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer"; return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
} }
function txAddressHtml(address, ensName, title) { function txAddressHtml(address, ensName, title, chainId) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( return (
`<div class="mb-1">${blockie}</div>` + `<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName }) renderAddressHtml(address, { title, ensName, chainId })
); );
} }
function txHashHtml(hash) { function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash); const link = explorerUrl("tx", hash, chainId);
const extLink = etherscanLinkHtml(link); const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink; return copyableHtml(hash, "break-all") + extLink;
} }
@@ -83,10 +84,11 @@ function show(tx) {
method: tx.method || null, method: tx.method || null,
contractAddress: tx.contractAddress || null, contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and // The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's: // the fee are in its native currency, not the active network's,
// the active network can change, in Settings or when the user // the links open its explorer, and the block, network details and
// approves a site's request, before a later popup shows this // calldata are read from its Blockscout: the active network can
// screen again. // change, in Settings or when the user approves a site's request,
// before a later popup shows this screen again.
chainId: tx.chainId, chainId: tx.chainId,
}, },
}; };
@@ -96,18 +98,24 @@ function show(tx) {
function render() { function render() {
const tx = state.viewData.tx; const tx = state.viewData.tx;
if (!tx) return; if (!tx) return;
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash); $("tx-detail-hash").innerHTML = txHashHtml(tx.hash, tx.chainId);
const fromTitle = addressTitle(tx.from, state.wallets); const fromTitle = addressTitle(tx.from, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml( $("tx-detail-from").innerHTML = txAddressHtml(
tx.from, tx.from,
tx.fromEns, tx.fromEns,
fromTitle, fromTitle,
tx.chainId,
); );
// A contract creation has no recipient: transactions.js gives it `to: ""`. // A contract creation has no recipient: transactions.js gives it `to: ""`.
if (tx.to) { if (tx.to) {
const toTitle = addressTitle(tx.to, state.wallets); const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle); $("tx-detail-to").innerHTML = txAddressHtml(
tx.to,
tx.toEns,
toTitle,
tx.chainId,
);
} else { } else {
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT); $("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
} }
@@ -146,7 +154,7 @@ function render() {
if (tokenContractSection && tokenContractEl) { if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) { if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress); const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress); const link = explorerUrl("token", tx.contractAddress, tx.chainId);
// Hand-rolled rather than renderAddressHtml() because the // Hand-rolled rather than renderAddressHtml() because the
// link goes to the explorer's /token/ page, not /address/. // link goes to the explorer's /token/ page, not /address/.
// Same two-row shape though: dot and link on the strip, the // Same two-row shape though: dot and link on the strip, the
@@ -204,7 +212,11 @@ function showDetailField(sectionId, contentId, value) {
function populateOnChainDetails(txData, chainId) { function populateOnChainDetails(txData, chainId) {
// Block number // Block number
if (txData.block_number != null) { if (txData.block_number != null) {
const blockLink = explorerUrl("block", String(txData.block_number)); const blockLink = explorerUrl(
"block",
String(txData.block_number),
chainId,
);
const blockSection = $("tx-detail-block-section"); const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block"); const blockEl = $("tx-detail-block");
if (blockSection && blockEl) { if (blockSection && blockEl) {
@@ -301,8 +313,9 @@ async function loadFullTxDetails(txHash, chainId) {
if (!section || !actionEl || !detailsEl) return; if (!section || !actionEl || !detailsEl) return;
try { try {
const { blockscoutUrl } = endpointsByChainId(chainId);
const resp = await debugFetch( const resp = await debugFetch(
state.blockscoutUrl + "/transactions/" + txHash, blockscoutUrl + "/transactions/" + txHash,
); );
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
@@ -316,12 +329,12 @@ async function loadFullTxDetails(txHash, chainId) {
// Decoded against the address the transaction was sent to, as the // Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token // explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract. // transfer that is the transfer's recipient, not the token contract.
// On the active network, the one whose explorer was just asked, and // On the network the transaction was sent on, whose Blockscout was
// from the sender it reports. // just asked, and from the sender it reports.
const decoded = decodeCalldata( const decoded = decodeCalldata(
inputData, inputData,
txData.to?.hash || "", txData.to?.hash || "",
currentNetwork().chainId, chainId,
txData.from?.hash || "", txData.from?.hash || "",
); );
if (decoded) { if (decoded) {
@@ -340,9 +353,9 @@ async function loadFullTxDetails(txHash, chainId) {
if (tokenSymbol) { if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
} }
detailsHtml += renderAddressHtml(d.address); detailsHtml += renderAddressHtml(d.address, { chainId });
} else if (d.address) { } else if (d.address) {
detailsHtml += renderAddressHtml(d.address); detailsHtml += renderAddressHtml(d.address, { chainId });
} else { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
+39 -29
View File
@@ -14,10 +14,13 @@ const {
displaySymbol, displaySymbol,
clearViewStack, clearViewStack,
tokenLabel, tokenLabel,
reviewedNetworkProvider,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const {
const { getProvider } = require("../../shared/balances"); SUPPORTED_CHAIN_IDS,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
// Receipt poll cadence and the deadline after which the wait is reported as // Receipt poll cadence and the deadline after which the wait is reported as
@@ -61,21 +64,22 @@ function endWait() {
} }
// A contract creation reaches these screens with `to` as "" (approval.js // A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`). // writes `to: toAddr || ""`). The links below open the explorer of the network
function toAddressHtml(address) { // with `chainId`, or of the active network when none is given.
function toAddressHtml(address, chainId) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT); if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title }); return renderAddressHtml(address, { title, chainId });
} }
function txHashHtml(hash) { function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash); const link = explorerUrl("tx", hash, chainId);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link); return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
} }
function blockNumberHtml(blockNumber) { function blockNumberHtml(blockNumber, chainId) {
const num = String(blockNumber); const num = String(blockNumber);
const link = explorerUrl("block", num); const link = explorerUrl("block", num, chainId);
return copyableHtml(num) + etherscanLinkHtml(link); return copyableHtml(num) + etherscanLinkHtml(link);
} }
@@ -91,9 +95,11 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// native currency of txInfo.chainId, the network the transaction was sent // native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: the active network can change, in // on, not the active network's: the active network can change, in
// Settings or when the user approves a site's request, while this screen // Settings or when the user approves a site's request, while this screen
// is open or before a later popup resumes it. A null token is a swap whose // is open or before a later popup resumes it. The links on all three
// amount was not decoded (see showTxApproval() in approval.js): the amount // screens open that network's explorer, and this screen's receipt is
// says so, and no symbol follows it. // asked of that network's node, for the same reason. A null token is a
// swap whose amount was not decoded (see showTxApproval() in
// approval.js): the amount says so, and no symbol follows it.
let symbol = ""; let symbol = "";
if (txInfo.token === "ETH") { if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId); symbol = nativeCurrencyByChainId(txInfo.chainId);
@@ -102,8 +108,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
} }
$("wait-tx-summary").textContent = $("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : ""); txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to); $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash); $("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx"); attachCopyHandlers("view-wait-tx");
// Persisted so closing and reopening the popup resumes this wait // Persisted so closing and reopening the popup resumes this wait
@@ -128,7 +134,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
renderElapsed(); renderElapsed();
}, 1000); }, 1000);
const provider = getProvider(state.rpcUrl, state.networkId); const provider = reviewedNetworkProvider(txInfo);
let consecutiveFailures = 0; let consecutiveFailures = 0;
async function poll() { async function poll() {
@@ -207,10 +213,11 @@ function showWait(txInfo, txHash) {
// an object merely missing one of them throws a TypeError out of // an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup // restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric // init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token, // broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are // the node the receipt is asked of and the explorer the links open, so it has
// compared and coalesced rather than dereferenced, and tokenSymbol is null for // to name a network. txInfo.token and txInfo.tokenSymbol are deliberately
// ETH. // unchecked: they are compared and coalesced rather than dereferenced, and
// tokenSymbol is null for ETH.
function restoreWait() { function restoreWait() {
const d = state.viewData; const d = state.viewData;
if (!d || !d.pendingWait) return false; if (!d || !d.pendingWait) return false;
@@ -226,6 +233,7 @@ function restoreWait() {
// path itself created. // path itself created.
if (typeof info.to !== "string") return false; if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false; if (typeof info.amount !== "string") return false;
if (!SUPPORTED_CHAIN_IDS.has(info.chainId)) return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) { if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
return false; return false;
} }
@@ -250,12 +258,13 @@ function showSuccess(txInfo, txHash, blockNumber) {
hash: txHash, hash: txHash,
blockNumber: blockNumber, blockNumber: blockNumber,
decoded: txInfo.decoded || null, decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
}; };
renderSuccess(); renderSuccess();
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} }
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded, chainId) {
if (!decoded || !decoded.details) return ""; if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`; let html = `<div class="border border-border border-dashed p-2 mb-3">`;
if (decoded.name) { if (decoded.name) {
@@ -271,11 +280,11 @@ function decodedDetailsHtml(decoded) {
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`; html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
const sym = tokenLabel(d.address) || "Unknown token"; const sym = tokenLabel(d.address, chainId) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`; html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += toAddressHtml(d.address); html += toAddressHtml(d.address, chainId);
} else { } else {
html += toAddressHtml(d.address); html += toAddressHtml(d.address, chainId);
} }
} else { } else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`; html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
@@ -304,16 +313,16 @@ function renderSuccess() {
toSection.classList.remove("hidden"); toSection.classList.remove("hidden");
$("success-tx-summary").textContent = $("success-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : ""); d.amount + (d.symbol ? " " + d.symbol : "");
$("success-tx-to").innerHTML = toAddressHtml(d.to); $("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
} }
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber); $("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId);
$("success-tx-hash").innerHTML = txHashHtml(d.hash); $("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
// Show decoded calldata details if present // Show decoded calldata details if present
const decodedEl = $("success-tx-decoded"); const decodedEl = $("success-tx-decoded");
if (decodedEl && hasDecoded) { if (decodedEl && hasDecoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded); decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId);
decodedEl.classList.remove("hidden"); decodedEl.classList.remove("hidden");
} else if (decodedEl) { } else if (decodedEl) {
decodedEl.classList.add("hidden"); decodedEl.classList.add("hidden");
@@ -339,6 +348,7 @@ function showError(txInfo, txHash, message) {
to: txInfo.to, to: txInfo.to,
hash: txHash || null, hash: txHash || null,
message: message, message: message,
chainId: txInfo.chainId,
}; };
renderError(); renderError();
} }
@@ -348,11 +358,11 @@ function renderError() {
if (!d || !d.message) return; if (!d || !d.message) return;
$("error-tx-summary").textContent = $("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : ""); d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-to").innerHTML = toAddressHtml(d.to); $("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-message").textContent = d.message; $("error-tx-message").textContent = d.message;
if (d.hash) { if (d.hash) {
$("error-tx-hash").innerHTML = txHashHtml(d.hash); $("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
$("error-tx-hash-section").classList.remove("hidden"); $("error-tx-hash-section").classList.remove("hidden");
attachCopyHandlers("view-error-tx"); attachCopyHandlers("view-error-tx");
} else { } else {
+10
View File
@@ -38,6 +38,16 @@ function applyChainSwitchFields(s, newNetworkId) {
blockscoutUrl: s.blockscoutUrl, blockscoutUrl: s.blockscoutUrl,
}; };
const remembered = s.networkEndpoints[net.id] || {}; 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.networkId = net.id;
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl; s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl; s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
+19
View File
@@ -23,6 +23,11 @@ const DEFAULT_STATE = {
hasWallet: false, hasWallet: false,
wallets: [], wallets: [],
trackedTokens: [], 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", networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL, rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL, blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
@@ -289,6 +294,20 @@ function normalizePersisted(saved) {
// profile whose wallets are perfectly fine. An empty list is a legitimate // profile whose wallets are perfectly fine. An empty list is a legitimate
// value and survives. // value and survives.
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens)); 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 // The loud refusal for an unknown id is assertStateUsable(); this is the
// floor under it. networkId is an object KEY into networkEndpoints below, // 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 // so a value that is not a network in networks.js must never get that far
+12 -8
View File
@@ -42,9 +42,11 @@ const { log } = require("./log");
// and writes THIS object, never the proxy: the guard is for callers. // and writes THIS object, never the proxy: the guard is for callers.
const rawState = { const rawState = {
...DEFAULT_STATE, ...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields() // Their own objects, not the ones DEFAULT_STATE holds:
// mutates this map in place, and a spread copies the reference. // applyChainSwitchFields() mutates these maps in place, and a spread copies
// the reference.
networkEndpoints: {}, networkEndpoints: {},
networkTrackedTokens: {},
currentView: null, currentView: null,
selectedWallet: null, selectedWallet: null,
selectedAddress: null, selectedAddress: null,
@@ -438,12 +440,14 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// //
// Every other persisted field stays a whole-field diff: // Every other persisted field stays a whole-field diff:
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no // `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// per-element identity to merge by; `tokenHolderCache` is a map shaped like // per-element identity to merge by; `networkTrackedTokens` is diffed whole like
// the ones above, but nothing in src/ ever writes an entry into it — it is // `trackedTokens` and `networkId`, the two fields a network switch moves
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is // together with it; `tokenHolderCache` is a map shaped like the ones above,
// no in-place mutation for a whole-field diff to collide with; `viewData` is // but nothing in src/ ever writes an entry into it — it is only ever reset
// this page's own UI scratch space, not data another page has any reason to // wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place
// share membership of. // 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 // 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 // safe: last write wins on that one leaf, same as before. What it removes
+5 -5
View File
@@ -2,11 +2,11 @@
// encoded with. // encoded with.
// //
// A token amount is a decimal string plus a scale, and the two come from // 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 // different places. The confirmation screen renders the amount, the symbol and
// the symbol from the block explorer's cached metadata (see // the balance it reads from the node with the block explorer's cached metadata
// fetchTokenBalances() in balances.js); the transfer used to be encoded from // (see fetchTokenBalances() in balances.js); the transfer used to be encoded
// decimals() read off the contract at signing time, and nothing compared the // from decimals() read off the contract at signing time, and nothing compared
// two. A token whose on-chain scale differs from the cached one — an // 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 // 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 // entry — therefore signed an amount that was never displayed, off by a power
// of ten for every decimal place of disagreement. // of ten for every decimal place of disagreement.
+2 -2
View File
@@ -111,8 +111,8 @@ function isValidAmount(amount, tokenDecimals = null) {
// The most ETH a send can carry: the exact balance minus the fee reserve from // 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 // feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string // exactly that reserve left behind. `ethBalance` is an exact decimal string as
// balances.js stores, never a rounded one. Null when the balance does not // 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 // leave anything to send once the fee is paid, or when either input is
// unusable. // unusable.
function maxEthAmount(ethBalance, feeWei) { function maxEthAmount(ethBalance, feeWei) {
+951 -199
View File
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -291,7 +291,6 @@ async function confirmSend(from, selected) {
to: RECIPIENT, to: RECIPIENT,
amount: "0.5", amount: "0.5",
token: "ETH", token: "ETH",
balance: "1.0",
}); });
await settle(); await settle();
node("confirm-tx-password").value = PASSWORD; 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); 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 () => { test("nothing is sent when the selected wallet does not hold it", async () => {
expect(await confirmSend(derived(5).address, 0)).toBeUndefined(); expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
expect(node("confirm-tx-password-error").textContent).toBe( expect(node("confirm-from-address-error").style.visibility).toBe(
"The selected wallet does not hold the From address above, " + "visible",
"so nothing was sent. Please go back and send again.", );
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",
); );
}); });
}); });
+220
View File
@@ -0,0 +1,220 @@
// The approval screen for a site's Sepolia token transfer while Ethereum
// Mainnet is active (https://git.eeqj.de/sneak/AutistMask/issues/629).
//
// The screen named the token, and scaled its amount, from the active network's
// tracked tokens and balances, so a token the wallet tracks on Sepolia read as
// an unknown token with its amount in base units, and the screens after it
// labelled the amount `?`. It now uses what the wallet knows of the tokens on
// the network the transaction is for.
//
// Driven through the real approval screen, password and Confirm included,
// against a minimal DOM stub in the shape tests/swapStatusAmount.test.js uses.
// The vault is mocked so that no password has to be hashed.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { decryptWithPassword } = require("../src/shared/vault");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
// A well-known test phrase, and its first address.
const PHRASE = "test test test test test test test test test test test junk";
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the bundled token list does not name.
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
// FROM sends 1234.5 of the 6-decimal token to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
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, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("approve-tx-decoded-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
// Open the approval screen, with Ethereum Mainnet active and FROM holding
// `tokenBalances` there, for the site's Sepolia transaction sending
// TRANSFER_DATA to the token. Confirm answers the signed transaction with a
// failure, which leads to the error screen.
async function showSepoliaTransfer(tokenBalances = []) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
return reply({
error: "The transaction could not be sent.",
retryable: false,
});
}
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId: NETWORKS.sepolia.chainId,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: TOKEN,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
},
};
state.networkId = "mainnet";
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances },
],
},
];
state.currentView = null;
txStatus.init({ doRefreshAndRender: () => {} });
approval.init({});
await approval.show(1);
}
async function confirm() {
decryptWithPassword.mockResolvedValue(PHRASE);
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
}
beforeEach(() => {
state.trackedTokens = [];
state.networkTrackedTokens = {};
});
describe("a token tracked on Sepolia only", () => {
beforeEach(() => {
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
});
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
await showSepoliaTransfer();
expect(node("approve-tx-to").innerHTML).toContain(
'<div class="font-bold mb-1">TDOL</div>',
);
expect(decodedLine("Token")).toContain(
'<div class="font-bold">TDOL</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
test("labels the amount on the error screen that follows", async () => {
await showSepoliaTransfer();
await confirm();
expect(state.currentView).toBe("error-tx");
expect(node("error-tx-summary").textContent).toBe("1234.5000 TDOL");
});
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("a token tracked and held on mainnet only is not named, and its amount is in base units", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
await showSepoliaTransfer([
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
]);
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
expect(decodedLine("Token")).toContain(
'<div class="font-bold">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
+8 -1
View File
@@ -154,7 +154,14 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the transaction detail renders it", () => { test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], { reopenedOn("settings", ["main", "transaction"], {
viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } }, viewData: {
tx: {
hash: "0xdead",
from: ADDRESS,
to: ADDRESS,
chainId: "0x1",
},
},
}); });
goBack(); goBack();
expect(calls).toEqual(["transactionDetail"]); expect(calls).toEqual(["transactionDetail"]);
+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); expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
// And it took nothing else with it. Without the load these come back // 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. // extension gone, encrypted secrets included.
expect(after.wallets).toEqual(walletFixture()); expect(after.wallets).toEqual(walletFixture());
expect(after.hasWallet).toBe(true); expect(after.hasWallet).toBe(true);
expect(after.activeAddress).toBe(ADDRESS); expect(after.activeAddress).toBe(ADDRESS);
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] }); expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
expect(after.trackedTokens).toEqual([ expect(after.networkTrackedTokens).toEqual({
{ address: TOKEN, symbol: "DAI", decimals: 18 }, mainnet: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
]); });
expect(after.theme).toBe("dark"); 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 user's mainnet endpoint is remembered rather than replaced by
// the public default, so switching back returns it. // the public default, so switching back returns it.
expect(after.networkEndpoints.mainnet).toEqual({ 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().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT); expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
expect(bg.persisted().wallets).toEqual(walletFixture()); 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 () => { 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 // The check is driven here with a fake connected signer rather than a real
// one: populateTransaction() returns the fees the node would have produced, // one: populateTransaction() returns the fees the node would have produced,
// and sendTransaction() records whether the send actually happened. The real // and sendTransaction() records whether the send actually happened.
// DOM path around it — reading the fee error into the reserved errors box — is //
// covered by the Chrome e2e suite. // 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 = { // What the stub node quotes for that test: a gas limit of 150,000,000 at a
storage: { local: { get: async () => ({}), set: async () => {} } }, // 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(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); 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 { const {
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
MAX_TOTAL_FEE, MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify"); } = require("../src/shared/approvalVerify");
@@ -98,3 +193,69 @@ describe("populateVerifyAndSend enforces the combined fee bound", () => {
expect(signer.sent[0].gasLimit).toBe(under.gasLimit); 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",
);
});
+272 -33
View File
@@ -15,20 +15,37 @@
// reviewed for, because choosing that network again in Settings and pressing // reviewed for, because choosing that network again in Settings and pressing
// Back only unhides it, and Sign & Send then sends from it. // 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 // Driven through the real confirmation screen, signer and chain switch, with
// only the node, fetch and the DOM stubbed. The stub node is on the network // 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. // 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"; "use strict";
// The questions the stub node was asked for the fee and the recipient checks, // The questions the stub node was asked for the balance, the fee and the
// by method, network name and RPC URL, in order. The signed transactions it // recipient checks, by method, network name and RPC URL, in order. The
// was handed, with its network's chain id and RPC URL. And whether it quotes // balances it reports on each network, by network name: ETH in wei and the
// fees as a network with no type-2 pricing does. // token in its base units. The signed transactions it was handed, with its
const mockNode = { asked: [], broadcast: [], legacyFees: false }; // 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", () => { jest.mock("ethers", () => {
const actual = jest.requireActual("ethers"); const actual = jest.requireActual("ethers");
// The selector of balanceOf(address).
const BALANCE_OF = "0x70a08231";
class StubProvider { class StubProvider {
constructor(url, network) { constructor(url, network) {
this.url = url; this.url = url;
@@ -44,6 +61,22 @@ jest.mock("ethers", () => {
async getNetwork() { async getNetwork() {
return this.network; 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() { async getFeeData() {
this.record("getFeeData"); this.record("getFeeData");
if (mockNode.legacyFees) { if (mockNode.legacyFees) {
@@ -158,7 +191,13 @@ global.document = {
}; };
global.navigator = { clipboard: { writeText() {} } }; 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 { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState, saveState } = require("../src/shared/state"); const { state, loadState, saveState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch"); const { onChainSwitch } = require("../src/shared/chainSwitch");
@@ -198,6 +237,11 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
elements.clear(); elements.clear();
mockNode.asked = []; mockNode.asked = [];
mockNode.balanceWei = {
mainnet: parseEther("5"),
sepolia: parseEther("5"),
};
mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n };
mockNode.broadcast = []; mockNode.broadcast = [];
mockNode.legacyFees = false; mockNode.legacyFees = false;
global.fetch.mockClear(); global.fetch.mockClear();
@@ -212,7 +256,7 @@ afterEach(() => {
// Show the confirmation screen for 1 of the native token on `networkId`, or // 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, // for the transfer `changes` makes of it, as Review on the Send screen does,
// with its fee estimate settled. // with its balance read and its fee estimate settled.
async function review(networkId, changes = {}) { async function review(networkId, changes = {}) {
await onChainSwitch(networkId); await onChainSwitch(networkId);
await reviewOnActiveNetwork(changes); await reviewOnActiveNetwork(changes);
@@ -225,7 +269,7 @@ async function reviewOnActiveNetwork(changes = {}) {
type: "key", type: "key",
name: "Wallet 1", name: "Wallet 1",
encryptedSecret, encryptedSecret,
addresses: [{ address: HOLDER, balance: "5.0" }], addresses: [{ address: HOLDER }],
}, },
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
@@ -237,9 +281,7 @@ async function reviewOnActiveNetwork(changes = {}) {
ensName: null, ensName: null,
amount: "1", amount: "1",
token: "ETH", token: "ETH",
balance: "5.0",
tokenSymbol: null, tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null, tokenDecimals: null,
max: false, max: false,
...changes, ...changes,
@@ -291,7 +333,7 @@ function expectNativeTransferOn(networkId) {
"Native " + symbol + " transfer", "Native " + symbol + " transfer",
); );
expect(el("confirm-amount").textContent).toBe( expect(el("confirm-amount").textContent).toBe(
"1 " + symbol + usdOn(networkId, "$2,000.00"), "1.0000 " + symbol + usdOn(networkId, "$2,000.00"),
); );
expect(el("confirm-balance").textContent).toBe( expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"), "5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
@@ -323,12 +365,24 @@ function expectNativeTransferOn(networkId) {
); );
} }
// show() estimates the fee and checks the recipient without awaiting either; // show() reads the balance and then estimates the fee, and checks the
// this lets both finish. // recipient, without awaiting any of them; this lets all of them finish.
async function settle() { async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); 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() { async function signAndSend() {
el("confirm-tx-password").value = PASSWORD; el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")(); await el("btn-confirm-send").handlers.get("click")();
@@ -359,9 +413,10 @@ test("a transfer reviewed after the active network's RPC endpoint was changed in
await reviewOnActiveNetwork(); await reviewOnActiveNetwork();
expect(mockNode.asked).toEqual([ expect(mockNode.asked).toEqual([
{ method: "getFeeData", network: "sepolia", url: newNode }, { method: "getBalance", network: "sepolia", url: newNode },
{ method: "getCode", network: "sepolia", url: newNode }, { method: "getCode", network: "sepolia", url: newNode },
{ method: "getTransactionCount", network: "sepolia", url: newNode }, { method: "getTransactionCount", network: "sepolia", url: newNode },
{ method: "getFeeData", network: "sepolia", url: newNode },
]); ]);
}); });
@@ -385,12 +440,8 @@ describe.each([
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled(); expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled(); expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").style.visibility).toBe("visible"); expect(shown("confirm-network-changed-error")).toBe(true);
expect(el("confirm-errors").innerHTML).toContain( expect(shown("confirm-fee-bound-error")).toBe(false);
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
}); });
}, },
); );
@@ -411,7 +462,7 @@ describe.each([
expectNativeTransferOn(reviewedOn); expectNativeTransferOn(reviewedOn);
expect(mockNode.asked).toEqual([ expect(mockNode.asked).toEqual([
{ {
method: "getFeeData", method: "getBalance",
network: reviewedOn, network: reviewedOn,
url: ownNode(reviewedOn), url: ownNode(reviewedOn),
}, },
@@ -425,6 +476,11 @@ describe.each([
network: reviewedOn, network: reviewedOn,
url: ownNode(reviewedOn), url: ownNode(reviewedOn),
}, },
{
method: "getFeeData",
network: reviewedOn,
url: ownNode(reviewedOn),
},
]); ]);
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([ expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([
explorerUrl + "/address/" + RECIPIENT, explorerUrl + "/address/" + RECIPIENT,
@@ -437,10 +493,7 @@ describe.each([
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled(); expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled(); expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").innerHTML).toContain( expect(shown("confirm-network-changed-error")).toBe(true);
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
}); });
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => { test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
@@ -472,15 +525,103 @@ describe.each([
await review(reviewedOn, { amount: "10" }); await review(reviewedOn, { amount: "10" });
await switchReopenAndGoBack(reviewedOn, switchedTo); await switchReopenAndGoBack(reviewedOn, switchedTo);
expect(el("confirm-errors").innerHTML).toContain( expect(shown("confirm-balance-error")).toBe(true);
"Insufficient balance. You have 5.0000 " + expect(el("confirm-balance-error").textContent).toBe(
"Your " +
symbol + symbol +
" but are trying to send 10 " + " balance does not cover this amount. Please go back and" +
symbol + " 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 () => { 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); await review(reviewedOn);
mockNode.legacyFees = true; mockNode.legacyFees = true;
@@ -492,10 +633,10 @@ describe.each([
}); });
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => { 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, { await review(reviewedOn, {
token: TOKEN, token: TOKEN,
tokenSymbol: "USDC", tokenSymbol: "USDC",
tokenBalance: "50",
tokenDecimals: 6, tokenDecimals: 6,
amount: "10", amount: "10",
}); });
@@ -508,7 +649,7 @@ describe.each([
`href="${explorerUrl}/address/${TOKEN}"`, `href="${explorerUrl}/address/${TOKEN}"`,
); );
expect(el("confirm-amount").textContent).toBe( expect(el("confirm-amount").textContent).toBe(
"10 USDC" + usdOn(reviewedOn, "$10.00"), "10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
); );
expect(el("confirm-balance").textContent).toBe( expect(el("confirm-balance").textContent).toBe(
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"), "50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
@@ -536,3 +677,101 @@ test("the send is refused by a signer on another network's node", async () => {
expect(signTransaction).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]); 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);
});
});
+1
View File
@@ -192,6 +192,7 @@ describe("the wait, success and error screens", () => {
amount: "0.0000", amount: "0.0000",
token: "ETH", token: "ETH",
tokenSymbol: null, tokenSymbol: null,
chainId: "0x1",
}; };
const transfer = { ...creation, to: RECIPIENT }; const transfer = { ...creation, to: RECIPIENT };
+30 -13
View File
@@ -226,8 +226,9 @@ const RPC_RESULTS = {
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from // The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the // 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 // reserve and the estimate collapse to the same number, and the gate tests
// stop being able to distinguish them. // stop being able to distinguish them. opts.baseFeeWei replaces its base fee,
function latestBlock() { // read at request time like every other fixture switch.
function latestBlock(opts) {
return { return {
hash: "0x" + "11".repeat(32), hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32), parentHash: "0x" + "22".repeat(32),
@@ -239,21 +240,26 @@ function latestBlock() {
gasUsed: "0xf4240", gasUsed: "0xf4240",
miner: STUB_COUNTERPARTY, miner: STUB_COUNTERPARTY,
extraData: "0x", extraData: "0x",
baseFeePerGas: hex(BASE_FEE_WEI), baseFeePerGas: opts.baseFeeWei || hex(BASE_FEE_WEI),
transactions: [], 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_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41"; const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03"; const SELECTOR_NAME = "0x06fdde03";
const SELECTOR_BALANCE_OF = "0x70a08231";
// Every eth_call still answers with a zero word except decimals(), symbol() // 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 // name() and balanceOf() on the stub token. The wallet reads decimals() back
// time to compare with the scale the confirmation screen rendered (issue // at signing time to compare with the scale the confirmation screen rendered
// #305). Adding the token by its contract address reads all three (issue // (issue #305). Adding the token by its contract address reads the first three
// #295); symbol() and name() answer what the explorer reports for it. // (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() // opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through // answers something other than the value this same fixture reports through
@@ -278,6 +284,9 @@ function ethCallResult(req, opts) {
if (data.startsWith(SELECTOR_NAME)) { if (data.startsWith(SELECTOR_NAME)) {
return abi.encode(["string"], [tokenObject(opts).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; 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. // Hold a reply open for as long as the test asks: until opts[name] is false.
// //
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read // The switch (holdBalance, holdGasEstimate, holdTransactionCount or
// here rather than captured, so a test flips it on the same options object the // holdBlockscout) is read here rather than captured, so a test flips it on the
// route was registered with — the same pattern as seedTokenTransfer. This is // 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; // 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 // sampling the screen and hoping to win a race against the network would
// assert nothing on a slow machine. // assert nothing on a slow machine.
@@ -472,7 +482,7 @@ function rpcReply(req, opts, report) {
}); });
} }
if (req.method === "eth_getBlockByNumber") { 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 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 // 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()); report("unstubbed request: POST " + route.request().url());
return route.abort(); return route.abort();
} }
if (batch.some((req) => req.method === "eth_getBalance")) {
await awaitRelease(opts, "holdBalance", report);
}
if (batch.some((req) => req.method === "eth_estimateGas")) { if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, "holdGasEstimate", report); await awaitRelease(opts, "holdGasEstimate", report);
} }
@@ -613,8 +626,12 @@ function traceEnabled(raw) {
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance; * @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
* defaults to zero, which is what every test that predates the funded * defaults to zero, which is what every test that predates the funded
* fixture expects. * 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 * @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal. * 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 * @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an * @param {boolean} [opts.holdTransactionCount] hold every batch containing an
+367 -36
View File
@@ -1824,6 +1824,65 @@ test("the Settings assertions above all ran (#229)", async (env) => {
); );
}); });
// ------------------------------------------------ testnet banner (#511)
// This suite runs a release build, so the red banner at the top reads
// "[TESTNET]" on Sepolia and is absent on mainnet. A network switch in
// Settings must change it while Settings is still on screen; it used to
// change only when the next screen came up
// (https://git.eeqj.de/sneak/AutistMask/issues/511).
// The banner's text, or null when there is none, once the switch to `net`
// has finished. The switch's flash is the last thing its handler does, and
// showView() clears the flash, so the banner read while that flash is up is
// the one the switch left on the screen it was made from.
async function bannerAfterSwitch(page, net) {
await page.waitForFunction(
(text) => document.getElementById("flash-msg").textContent === text,
"Switched to " + net.name + ".",
);
return page.evaluate(() => {
const banner = document.getElementById("debug-banner");
return {
text: banner === null ? null : banner.textContent,
onSettings: !document
.getElementById("view-settings")
.classList.contains("hidden"),
};
});
}
test("a network switch in Settings shows and removes the testnet banner at once (#511)", async (env) => {
const { mainnet, sepolia } = NETWORKS;
await openSettings(env.page);
assert(
(await env.page.locator("#debug-banner").count()) === 0,
"a banner is up on mainnet before the switch",
);
await env.page.selectOption("#settings-network", "sepolia");
let after = await bannerAfterSwitch(env.page, sepolia);
assert(after.onSettings, "Settings left the screen on the switch");
assert(
after.text === "[TESTNET]",
"after the switch to Sepolia, Settings shows the banner " +
JSON.stringify(after.text) +
', expected "[TESTNET]"',
);
await env.page.selectOption("#settings-network", "mainnet");
after = await bannerAfterSwitch(env.page, mainnet);
assert(after.onSettings, "Settings left the screen on the switch back");
assert(
after.text === null,
"after the switch back to mainnet, Settings still shows the banner " +
JSON.stringify(after.text),
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-main");
});
// ------------------------------------------------ dust threshold (#233) // ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this // The popup size README documents the UI as designed for. Pages in this
@@ -2232,7 +2291,8 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in // 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 // 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) { async function confirmState(page) {
return page.evaluate(() => { return page.evaluate(() => {
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
@@ -2245,6 +2305,12 @@ async function confirmState(page) {
}; };
return { return {
height: el("view-confirm-tx").getBoundingClientRect().height, 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(), type: el("confirm-type").textContent.trim(),
balance: el("confirm-balance").textContent.trim(), balance: el("confirm-balance").textContent.trim(),
fee: el("confirm-fee-amount").textContent.trim(), fee: el("confirm-fee-amount").textContent.trim(),
@@ -2253,9 +2319,18 @@ async function confirmState(page) {
errors: shown("confirm-errors") errors: shown("confirm-errors")
? el("confirm-errors").textContent.trim() ? el("confirm-errors").textContent.trim()
: "", : "",
balanceError: shown("confirm-balance-error")
? el("confirm-balance-error").textContent.trim()
: "",
amountFeeError: shown("confirm-amount-fee-error"), amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"), gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-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, sendDisabled: el("btn-confirm-send").disabled,
}; };
}); });
@@ -2271,6 +2346,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) { async function backToAddress(page) {
if (await page.isVisible("#view-confirm-tx")) { if (await page.isVisible("#view-confirm-tx")) {
await page.click("#btn-confirm-back"); await page.click("#btn-confirm-back");
@@ -2282,13 +2367,14 @@ async function backToAddress(page) {
await openAddressDetail(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 // 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 — // 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 // rather than for a refresh to have probably landed — is what keeps every
// assertion below deterministic after a fixture change. // 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 backToAddress(page);
await page.click("#btn-send"); await page.click("#btn-send");
await visible(page, "#view-send"); await visible(page, "#view-send");
@@ -2303,6 +2389,14 @@ async function goToConfirm(page, { token, balance, amount }) {
await page.fill("#send-amount", amount); await page.fill("#send-amount", amount);
await page.click("#btn-send-review"); await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx"); await visible(page, "#view-confirm-tx");
}
// 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"); await assertAddressesFit(page, "the confirmation screen");
} }
@@ -2378,7 +2472,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", "the estimate-failed message is shown for an estimate that is merely pending",
); );
assert( 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", "a balance message is shown before the fee is known",
); );
}); });
@@ -2409,6 +2506,7 @@ test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238
); );
assert( assert(
st.errors === "" && st.errors === "" &&
st.balanceError === "" &&
!st.amountFeeError && !st.amountFeeError &&
!st.gasError && !st.gasError &&
!st.feeUnknownError, !st.feeUnknownError,
@@ -2475,30 +2573,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) => { // The screen is first measured with its balance read held, so the message the
await goToConfirm(env.page, { // read brings must already hold its space when the read returns (#558).
token: "ETH", test("ConfirmTx refuses a send that exceeds the balance outright, and nothing moves when the balance arrives (#238)", async (env) => {
balance: FUNDED_ETH_TEXT + " ETH", env.routeOpts.holdBalance = true;
amount: OVER_BALANCE_AMOUNT, 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); await waitForEstimate(env.page);
const st = await confirmState(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( assert(
st.errors === want, st.balance === FUNDED_ETH_TEXT + " ETH",
"wrong over-balance message: " + JSON.stringify(st.errors), "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.sendDisabled, "Send is enabled for a send that exceeds balance");
assert( assert(
!st.amountFeeError, !st.amountFeeError,
"the amount-plus-fee message is shown for an amount that alone exceeds the balance", "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) => { test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => {
@@ -2542,7 +2664,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", "the estimate-failed message is not shown after a failed estimate",
); );
assert( 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", "a balance message is shown for an estimate that simply failed",
); );
assert( assert(
@@ -2633,21 +2758,19 @@ test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", a
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(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( assert(
st.errors === want, st.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"wrong over-token-balance message: " + JSON.stringify(st.errors), "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( assert(
st.sendDisabled, st.sendDisabled,
@@ -2703,9 +2826,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", "the native-ETH over-budget message is shown on an ERC-20 transfer",
); );
assert( assert(
st.errors === "", st.errors === "" && st.balanceError === "",
"a token-balance message is shown for a transfer the token balance covers: " + "a token-balance message is shown for a transfer the token balance covers: " +
JSON.stringify(st.errors), JSON.stringify(st.errors + st.balanceError),
); );
assert( assert(
st.height === env.erc20PendingHeight, st.height === env.erc20PendingHeight,
@@ -2851,9 +2974,10 @@ async function goToTokenConfirm(env) {
amount: TOKEN_AMOUNT, amount: TOKEN_AMOUNT,
}); });
await waitForEstimate(env.page); await waitForEstimate(env.page);
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
const shown = await shownAmount(env.page); const shown = await shownAmount(env.page);
assert( assert(
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol, shown === "0.2500 " + STUB_TOKEN.symbol,
"the confirmation screen is not showing the amount that was entered: " + "the confirmation screen is not showing the amount that was entered: " +
JSON.stringify(shown), JSON.stringify(shown),
); );
@@ -3003,6 +3127,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) // ------------------------------ the wait for a receipt ending in error (#315)
// //
// README.md (WaitTx) documents two ways the wait ends on the error screen: a // README.md (WaitTx) documents two ways the wait ends on the error screen: a
@@ -4859,6 +5187,9 @@ async function main() {
seedTokenBalance: false, seedTokenBalance: false,
ethBalanceWei: null, ethBalanceWei: null,
failGasEstimate: false, 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, holdGasEstimate: false,
// Whether a JSON-RPC batch asking for a transaction count is held // Whether a JSON-RPC batch asking for a transaction count is held
// unanswered: the address scan after creating a wallet (#475). // unanswered: the address scan after creating a wallet (#475).
+1
View File
@@ -80,6 +80,7 @@ describe.each([
jest.doMock("../src/shared/state", () => ({ jest.doMock("../src/shared/state", () => ({
state: { trackedTokens: [] }, state: { trackedTokens: [] },
saveState, saveState,
currentNetwork: () => ({ chainId: 1 }),
})); }));
jest.doMock("../src/popup/views/helpers", () => ({ jest.doMock("../src/popup/views/helpers", () => ({
$: element, $: element,
+11 -10
View File
@@ -17,6 +17,10 @@ jest.mock("ethers", () => {
async lookupAddress() { async lookupAddress() {
return null; 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. // 10 gwei expected, 20 gwei reserved per gas.
async getFeeData() { async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n }; return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
@@ -241,7 +245,7 @@ describe.each([
test("the value", async () => { test("the value", async () => {
await confirmSend("0.1"); await confirmSend("0.1");
expect(text("confirm-type")).toBe("Native " + symbol + " transfer"); 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); await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol); expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
}); });
@@ -339,14 +343,11 @@ describe.each([
test("the insufficient-balance error", async () => { test("the insufficient-balance error", async () => {
await confirmSend("2"); await confirmSend("2");
expect( expect(text("confirm-balance-error")).toBe(
global.document.getElementById("confirm-errors").innerHTML, "Your " +
).toContain(
"You have 1.5000 " +
symbol + symbol +
" but are trying to send 2 " + " balance does not cover this amount. Please go back and send" +
symbol + " a smaller amount.",
".",
); );
}); });
}); });
@@ -428,10 +429,10 @@ describe.each([
await global.document await global.document
.getElementById("btn-confirm-send") .getElementById("btn-confirm-send")
.handlers.get("click")(); .handlers.get("click")();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol); expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
state.networkId = activeNetworkId; state.networkId = activeNetworkId;
txStatus.restoreWait(); 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 () => { test("the transaction detail screen's type line and fee", async () => {
+76 -3
View File
@@ -97,6 +97,7 @@ const {
normalizePersisted, normalizePersisted,
} = require("../src/shared/persistedState"); } = require("../src/shared/persistedState");
const { stateProblem } = require("../src/shared/stateSchema"); const { stateProblem } = require("../src/shared/stateSchema");
const { isKnownNetworkId } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const { const {
bootPopup, bootPopup,
@@ -193,6 +194,29 @@ const CONTRACT = [
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]], floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.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", field: "allowedSites",
kind: KIND.ENTRIES, kind: KIND.ENTRIES,
@@ -417,6 +441,17 @@ const CONTRACT = [
views: ["error-tx"], views: ["error-tx"],
restored: false, restored: false,
}, },
// A chain id naming no network has no explorer to link to.
{
value: { hash: "0x1", to: ADDRESS, chainId: "0x5" },
views: ["success-tx"],
restored: false,
},
{
value: { message: "boom", to: ADDRESS, chainId: "0x5" },
views: ["error-tx"],
restored: false,
},
// transaction passes on `data.tx`. // transaction passes on `data.tx`.
{ {
value: { tx: { hash: "0x1" } }, value: { tx: { hash: "0x1" } },
@@ -430,6 +465,26 @@ const CONTRACT = [
from: ADDRESS, from: ADDRESS,
to: ADDRESS, to: ADDRESS,
contractAddress: 42, contractAddress: 42,
chainId: "0x1",
},
},
views: ["transaction"],
restored: false,
},
// A transaction whose chain id is missing or names no network has
// no explorer to link to and no Blockscout to be read from.
{
value: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS } },
views: ["transaction"],
restored: false,
},
{
value: {
tx: {
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
chainId: "0x5",
}, },
}, },
views: ["transaction"], views: ["transaction"],
@@ -453,6 +508,19 @@ const CONTRACT = [
views: ["confirm-tx"], views: ["confirm-tx"],
restored: false, 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. // A chain id naming no network has no explorer to link to.
{ {
value: { value: {
@@ -775,17 +843,22 @@ const WELL_FORMED_DATA = {
message: "boom", message: "boom",
to: ADDRESS, to: ADDRESS,
decoded: { details: [{ address: TOKEN_ADDRESS }] }, decoded: { details: [{ address: TOKEN_ADDRESS }] },
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: null }, tx: {
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
contractAddress: null,
chainId: "0x1",
},
pendingTx: { pendingTx: {
token: "ETH", token: "ETH",
from: ADDRESS, from: ADDRESS,
to: ADDRESS, to: ADDRESS,
amount: "1", amount: "1",
balance: "2",
}, },
pendingWait: { pendingWait: {
hash: "0x1", hash: "0x1",
txInfo: { to: ADDRESS, amount: "1" }, txInfo: { to: ADDRESS, amount: "1", chainId: "0x1" },
broadcastTime: BROADCAST_TIME, broadcastTime: BROADCAST_TIME,
}, },
}; };
+45
View File
@@ -9,6 +9,7 @@ const {
bootPopup, bootPopup,
cleanupPopup, cleanupPopup,
unversionedValidProfile, unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot"); } = require("./support/popupBoot");
const FLASH_MS = 10; const FLASH_MS = 10;
@@ -40,3 +41,47 @@ test("a flash message's timer does not fire after cleanupPopup()", async () => {
await wait(FLASH_MS * 5); 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, chainId: "0x1" },
}),
);
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,
]);
});
+9 -2
View File
@@ -23,7 +23,10 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false, isDebug: () => false,
})); }));
// What the confirmation screen asks the node for. 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 = { const mockProvider = {
getFeeData: async () => ({ getFeeData: async () => ({
maxFeePerGas: 2000000000n, maxFeePerGas: 2000000000n,
@@ -32,7 +35,11 @@ const mockProvider = {
estimateGas: async () => 21000n, estimateGas: async () => 21000n,
getCode: async () => "0x", getCode: async () => "0x",
getTransactionCount: async () => 1, getTransactionCount: async () => 1,
getBalance: async () => 0n, 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", () => { jest.mock("../src/shared/balances", () => {
+27 -8
View File
@@ -18,6 +18,7 @@
// What the stub node answers. Each test sets what it needs. // What the stub node answers. Each test sets what it needs.
const mockNode = { const mockNode = {
balanceWei: 0n, balanceWei: 0n,
tokenUnits: 0n,
feeData: { maxFeePerGas: 1n, gasPrice: 1n }, feeData: { maxFeePerGas: 1n, gasPrice: 1n },
}; };
@@ -30,6 +31,13 @@ jest.mock("ethers", () => {
async getBalance() { async getBalance() {
return mockNode.balanceWei; return mockNode.balanceWei;
} }
// balanceOf(), the one contract call these screens make.
async call() {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[mockNode.tokenUnits],
);
}
async lookupAddress() { async lookupAddress() {
return null; return null;
} }
@@ -195,10 +203,19 @@ function errors() {
return global.document.getElementById("confirm-errors").innerHTML; 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, // 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 = []) { async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei; mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems; mockExplorer.items = tokenItems;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }]; state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0; state.selectedWallet = 0;
@@ -300,9 +317,10 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.", "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); await confirmSend("1.5", TOKEN);
expect(text("confirm-balance")).toBe("1.5000 TOK"); expect(text("confirm-balance")).toBe("1.5000 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
}); });
test("with more than 18 decimals and a holding below 10^-18", async () => { 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); await confirmSend("0.000000000000000001", TOKEN);
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK"); expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.000000000000000000000001 TOK but are trying to send" + "Your TOK balance does not cover this amount.",
" 0.000000000000000001 TOK.",
); );
}); });
}); });
@@ -356,9 +374,10 @@ describe("an untracked token holding below 0.000001 never renders as zero", () =
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain( expect(balanceError()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.", "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. // What the stub node answers, and the signed transactions it was handed.
const mockNode = { const mockNode = {
balanceWei: 0n, balanceWei: 0n,
tokenUnits: 0n,
feeData: null, feeData: null,
broadcast: [], broadcast: [],
}; };
@@ -30,6 +31,14 @@ jest.mock("ethers", () => {
async getBalance() { async getBalance() {
return mockNode.balanceWei; 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() { async lookupAddress() {
return null; return null;
} }
@@ -187,9 +196,12 @@ function text(id) {
} }
// The ETH balance the node reports and the token rows the explorer reports, // 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 = []) { async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei; mockNode.balanceWei = balanceWei;
mockNode.tokenUnits =
tokenItems.length > 0 ? BigInt(tokenItems[0].value) : 0n;
mockExplorer.items = tokenItems; mockExplorer.items = tokenItems;
state.wallets = [ state.wallets = [
{ {
@@ -294,7 +306,8 @@ describe("Max on an ETH send", () => {
openSend(); openSend();
await pressMax(); await pressMax();
await review(); 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-errors").innerHTML).toBe("");
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden"); expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(canSend()).toBe(true); 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. // Kept, the Send screen's amount would be refused for want of funds.
mockNode.feeData = fees(30n * GWEI); mockNode.feeData = fees(30n * GWEI);
await review(); 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); expect(canSend()).toBe(true);
// And falls when the screen is shown again, as on reopening the popup. // And falls when the screen is shown again, as on reopening the popup.
mockNode.feeData = fees(10n * GWEI); mockNode.feeData = fees(10n * GWEI);
confirmTx.restore(); confirmTx.restore();
await settle(); 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); expect(canSend()).toBe(true);
}); });
@@ -514,7 +529,8 @@ describe("Max on an ETH send", () => {
el("send-amount").handlers.get("input")(); el("send-amount").handlers.get("input")();
mockNode.feeData = fees(30n * GWEI); mockNode.feeData = fees(30n * GWEI);
await review(); 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(); await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012"); expect(el("send-amount").value).toBe("1234.567890123456789012");
await review(); 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); expect(canSend()).toBe(true);
}); });
@@ -541,7 +558,8 @@ describe("Max on a token send", () => {
await pressMax(); await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012"); expect(el("send-amount").value).toBe("1234.567890123456789012");
await review(); 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); expect(canSend()).toBe(true);
}); });
+6 -1
View File
@@ -225,7 +225,12 @@ describe("a popup already open when the stored profile becomes unreadable", () =
viewData: { viewData: {
pendingWait: { pendingWait: {
hash: "0x1", hash: "0x1",
txInfo: { to: ADDRESS, amount: "1", token: "ETH" }, txInfo: {
to: ADDRESS,
amount: "1",
token: "ETH",
chainId: "0x1",
},
broadcastTime: Date.now(), broadcastTime: Date.now(),
}, },
}, },
+16 -2
View File
@@ -45,6 +45,7 @@ const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval; const realClearInterval = globalThis.clearInterval;
const realSetTimeout = globalThis.setTimeout; const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout; const realClearTimeout = globalThis.clearTimeout;
const realFetch = globalThis.fetch;
// Every timeout set since the last cleanupPopup() that has neither fired nor // Every timeout set since the last cleanupPopup() that has neither fired nor
// been cleared. cleanupPopup() clears them all. // been cleared. cleanupPopup() clears them all.
@@ -260,8 +261,14 @@ async function settle() {
async function bootPopup(stored, options) { async function bootPopup(stored, options) {
jest.resetModules(); jest.resetModules();
// The three modules that reach the network. None is on the path under // The RPC requests made through src/shared/balances.js go through Node's
// test; all would make the suite hit the internet. // 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", () => ({ jest.doMock("../../src/shared/prices", () => ({
prices: {}, prices: {},
refreshPrices: jest.fn(async () => {}), refreshPrices: jest.fn(async () => {}),
@@ -334,6 +341,12 @@ async function bootPopup(stored, options) {
windowListeners[name].push(fn); 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, // The ten-second refresh init() starts, and a transaction wait's timers,
// would outlive the test. So every interval is recorded rather than // would outlive the test. So every interval is recorded rather than
// started, clearInterval() removes it as a browser would, and tick() below // started, clearInterval() removes it as a browser would, and tick() below
@@ -435,6 +448,7 @@ function cleanupPopup() {
pendingTimeouts.clear(); pendingTimeouts.clear();
globalThis.setTimeout = realSetTimeout; globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout; globalThis.clearTimeout = realClearTimeout;
globalThis.fetch = realFetch;
} }
module.exports = { module.exports = {
+123 -6
View File
@@ -8,6 +8,14 @@
// tracks. It decodes against the address the explorer reports the transaction // tracks. It decodes against the address the explorer reports the transaction
// was sent to. // was sent to.
// //
// Its links open the explorer of the network the transaction was sent on,
// whichever network is active
// (https://git.eeqj.de/sneak/AutistMask/issues/584), and the transaction is
// read from that network's Blockscout, the one Settings has for it
// (https://git.eeqj.de/sneak/AutistMask/issues/623). Its decoded details name
// the token, and scale its amount, from what the wallet knows of that
// network's tokens (https://git.eeqj.de/sneak/AutistMask/issues/629).
//
// The row is the one the real history parser makes from the explorer's answer, // The row is the one the real history parser makes from the explorer's answer,
// opened through transactionDetail.show(), which is what a click on the row // opened through transactionDetail.show(), which is what a click on the row
// calls. Driven against a minimal DOM stub in the shape // calls. Driven against a minimal DOM stub in the shape
@@ -22,10 +30,10 @@ jest.mock("../src/shared/log", () => ({
warnf: () => {}, warnf: () => {},
errorf: () => {}, errorf: () => {},
}, },
debugFetch: async (url) => ({ debugFetch: async (url) => {
ok: true, mockAsked.push(url);
json: async () => mockExplorerAnswer(url), return { ok: true, json: async () => mockExplorerAnswer(url) };
}), },
setRuntimeDebug: () => {}, setRuntimeDebug: () => {},
isDebug: () => false, isDebug: () => false,
})); }));
@@ -35,10 +43,13 @@ globalThis.chrome = {
}; };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { fetchRecentTransactions } = require("../src/shared/transactions"); const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
const EXPLORER = "https://explorer.example/api/v2"; const EXPLORER = "https://explorer.example/api/v2";
// A Blockscout that knows nothing of the transaction.
const OTHER_EXPLORER = "https://other-explorer.example/api/v2";
const HOLDER = "0x0000000000000000000000000000000000000a11"; const HOLDER = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD"; const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the wallet tracks and the bundled token list does not name. // A token the wallet tracks and the bundled token list does not name.
@@ -51,6 +62,9 @@ const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)", "function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]); ]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
// Every URL asked of a Blockscout, in order.
const mockAsked = [];
// The explorer's answers: the transfer in HOLDER's token transfers, and the // The explorer's answers: the transfer in HOLDER's token transfers, and the
// transaction itself, sent to the token contract. // transaction itself, sent to the token contract.
function mockExplorerAnswer(url) { function mockExplorerAnswer(url) {
@@ -77,7 +91,11 @@ function mockExplorerAnswer(url) {
}; };
} }
if (url === EXPLORER + "/transactions/" + TX_HASH) { if (url === EXPLORER + "/transactions/" + TX_HASH) {
return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA }; return {
to: { hash: TOKEN },
raw_input: TRANSFER_DATA,
block_number: 100,
};
} }
return { items: [] }; return { items: [] };
} }
@@ -140,6 +158,7 @@ beforeEach(() => {
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER; state.blockscoutUrl = EXPLORER;
state.networkEndpoints = {};
state.wallets = [ state.wallets = [
{ {
name: "Main", name: "Main",
@@ -148,13 +167,18 @@ beforeEach(() => {
}, },
]; ];
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }]; state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
state.networkTrackedTokens = {};
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
}); });
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => { test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1); const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.mainnet.chainId,
);
expect(row.to).toBe(RECIPIENT); expect(row.to).toBe(RECIPIENT);
expect(row.contractAddress).toBe(TOKEN.toLowerCase()); expect(row.contractAddress).toBe(TOKEN.toLowerCase());
@@ -174,3 +198,96 @@ test("a token transfer's decoded details name the token contract and the amount
'<div class="font-bold">1234.5000 TDOL</div>', '<div class="font-bold">1234.5000 TDOL</div>',
); );
}); });
// Mainnet is active, and its Blockscout knows nothing of the transaction. The
// row was read from the Blockscout Settings remembers for Sepolia, and the
// screen reads the transaction from there too, so every link on it is drawn.
test("with mainnet active, a Sepolia transaction is read from Sepolia's Blockscout and its links open the Sepolia explorer", async () => {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
const sepolia = "https://sepolia.etherscan.io";
const links = (id) =>
[...node(id).innerHTML.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
expect(links("tx-detail-hash")).toEqual([`${sepolia}/tx/${TX_HASH}`]);
expect(links("tx-detail-from")).toEqual([`${sepolia}/address/${HOLDER}`]);
expect(links("tx-detail-to")).toEqual([`${sepolia}/address/${RECIPIENT}`]);
expect(links("tx-detail-token-contract")).toEqual([
`${sepolia}/token/${TOKEN.toLowerCase()}`,
]);
expect(links("tx-detail-block")).toEqual([`${sepolia}/block/100`]);
expect(links("tx-detail-calldata-details")).toEqual([
`${sepolia}/address/${TOKEN}`,
`${sepolia}/address/${RECIPIENT}`,
]);
});
test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia transaction is asked of Sepolia's default Blockscout", async () => {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
mockAsked.length = 0;
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
expect(mockAsked).toEqual([
NETWORKS.sepolia.defaultBlockscoutUrl + "/transactions/" + TX_HASH,
]);
});
// Open the Sepolia transfer's detail screen with mainnet active, and wait for
// its decoded details.
async function showSepoliaTransferWithMainnetActive() {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("with mainnet active, a Sepolia token transfer's decoded details name the token tracked on Sepolia and its amount at its scale", async () => {
state.trackedTokens = [];
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).toContain('<div class="font-bold">TDOL</div>');
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("with mainnet active, a Sepolia token transfer's decoded details take nothing from mainnet's tracked tokens or balances", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
state.wallets[0].addresses[0].tokenBalances = [
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
];
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).not.toContain("MDOL");
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
+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]);
});
});
+176 -3
View File
@@ -29,8 +29,13 @@ jest.mock("../src/shared/log", () => ({
})); }));
const mockReceiptLookup = jest.fn(); const mockReceiptLookup = jest.fn();
// The RPC URL and network of every node a wait was given.
const mockNodes = [];
jest.mock("../src/shared/balances", () => ({ jest.mock("../src/shared/balances", () => ({
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }), getProvider: (rpcUrl, networkId) => {
mockNodes.push({ rpcUrl, networkId });
return { getTransactionReceipt: mockReceiptLookup };
},
refreshBalances: jest.fn(), refreshBalances: jest.fn(),
})); }));
@@ -107,6 +112,7 @@ global.chrome = { storage };
const txStatus = require("../src/popup/views/txStatus"); const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const { log } = require("../src/shared/log"); const { log } = require("../src/shared/log");
@@ -118,6 +124,7 @@ const TX_INFO = {
amount: "0.0050", amount: "0.0050",
token: "ETH", token: "ETH",
tokenSymbol: null, tokenSymbol: null,
chainId: NETWORKS.mainnet.chainId,
}; };
// True when a view element is not hidden. // True when a view element is not hidden.
@@ -137,8 +144,14 @@ beforeEach(() => {
jest.setSystemTime(new Date("2026-08-11T12:00:00Z")); jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
elements.clear(); elements.clear();
mockReceiptLookup.mockReset(); mockReceiptLookup.mockReset();
mockNodes.length = 0;
log.errorf.mockClear(); log.errorf.mockClear();
state.networkId = "mainnet";
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
state.networkEndpoints = {};
state.wallets = []; state.wallets = [];
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
@@ -349,14 +362,14 @@ describe("WaitTx persistence across popup close", () => {
// out of restoreView(). // out of restoreView().
{ {
hash: TX_HASH, hash: TX_HASH,
txInfo: { to: 42, amount: "0.0050" }, txInfo: { ...TX_INFO, to: 42 },
broadcastTime: Date.now(), broadcastTime: Date.now(),
}, },
// Otherwise complete but an array: only Array.isArray() rejects // Otherwise complete but an array: only Array.isArray() rejects
// it, since typeof [] is "object" and the fields are present. // it, since typeof [] is "object" and the fields are present.
{ {
hash: TX_HASH, hash: TX_HASH,
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }), txInfo: Object.assign([], TX_INFO),
broadcastTime: Date.now(), broadcastTime: Date.now(),
}, },
{ {
@@ -364,6 +377,18 @@ describe("WaitTx persistence across popup close", () => {
txInfo: { to: RECIPIENT }, txInfo: { to: RECIPIENT },
broadcastTime: Date.now(), broadcastTime: Date.now(),
}, },
// Otherwise complete but for a chain id naming no network: there
// is no node to ask for the receipt, and no explorer to link to.
{
hash: TX_HASH,
txInfo: { ...TX_INFO, chainId: undefined },
broadcastTime: Date.now(),
},
{
hash: TX_HASH,
txInfo: { ...TX_INFO, chainId: "0x5" },
broadcastTime: Date.now(),
},
]) { ]) {
state.viewData = { pendingWait: bad }; state.viewData = { pendingWait: bad };
expect(txStatus.restoreWait()).toBe(false); expect(txStatus.restoreWait()).toBe(false);
@@ -499,6 +524,154 @@ describe("WaitTx when the popup closes", () => {
}); });
}); });
// The popup can reopen onto WaitTx after Settings switched the network. The
// wait asked the active network's node for the receipt, never found it, and
// reported a timeout, and its links went to the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/584).
describe("WaitTx for a Sepolia transaction with mainnet active", () => {
const SEPOLIA_NODE = "https://sepolia.node.example.invalid";
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
beforeEach(() => {
state.rpcUrl = "https://mainnet.node.example.invalid";
state.networkEndpoints = { sepolia: { rpcUrl: SEPOLIA_NODE } };
});
test("the receipt is asked of the Sepolia node Settings has", async () => {
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
state.viewData = {
pendingWait: {
txInfo: sepoliaTx,
hash: TX_HASH,
broadcastTime: Date.now(),
},
};
expect(txStatus.restoreWait()).toBe(true);
await jest.advanceTimersByTimeAsync(0);
expect(mockNodes).toEqual([
{ rpcUrl: SEPOLIA_NODE, networkId: "sepolia" },
]);
expect(state.currentView).toBe("success-tx");
});
test("the recipient and hash link to the Sepolia explorer", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait(sepoliaTx, TX_HASH);
expect(getElement("wait-tx-to").innerHTML).toContain(
`href="https://sepolia.etherscan.io/address/${RECIPIENT}"`,
);
expect(getElement("wait-tx-hash").innerHTML).toContain(
`href="https://sepolia.etherscan.io/tx/${TX_HASH}"`,
);
});
});
// SuccessTx and ErrorTx follow WaitTx and describe the same transaction, but
// their links went to the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/622). Each test checks the
// screen again after rendering it from its stored view data alone, as a
// reopened popup does.
describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active", () => {
const SEPOLIA_EXPLORER = "https://sepolia.etherscan.io";
const TOKEN = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
test("the recipient, block and hash on SuccessTx link to the Sepolia explorer", async () => {
function expectSepoliaLinks() {
expect(getElement("success-tx-to").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
expect(getElement("success-tx-block").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/block/21000000"`,
);
expect(getElement("success-tx-hash").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
);
}
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait(sepoliaTx, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectSepoliaLinks();
elements.clear();
txStatus.renderSuccess();
expectSepoliaLinks();
});
test("the addresses in SuccessTx's decoded action link to the Sepolia explorer", async () => {
function expectSepoliaLinks() {
const html = getElement("success-tx-decoded").innerHTML;
expect(html).toContain(
`href="${SEPOLIA_EXPLORER}/address/${TOKEN}"`,
);
expect(html).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
}
const decoded = {
name: "Transfer",
details: [
{ label: "Token", address: TOKEN, isToken: true },
{ label: "Recipient", address: RECIPIENT },
],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectSepoliaLinks();
elements.clear();
txStatus.renderSuccess();
expectSepoliaLinks();
});
// https://git.eeqj.de/sneak/AutistMask/issues/629
test("SuccessTx's decoded action names the token from the tokens tracked on Sepolia", async () => {
function expectTokenNamed() {
expect(getElement("success-tx-decoded").innerHTML).toContain(
'<div class="font-bold">TDOL</div>',
);
}
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
const decoded = {
name: "Transfer",
details: [{ label: "Token", address: TOKEN, isToken: true }],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectTokenNamed();
elements.clear();
txStatus.renderSuccess();
expectTokenNamed();
});
test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => {
function expectSepoliaLinks() {
expect(getElement("error-tx-to").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
expect(getElement("error-tx-hash").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
);
}
txStatus.showError(sepoliaTx, TX_HASH, "The transaction failed.");
expectSepoliaLinks();
elements.clear();
txStatus.renderError();
expectSepoliaLinks();
});
});
describe("wait-tx is a view the popup may reopen onto", () => { describe("wait-tx is a view the popup may reopen onto", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView() // The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills // refuses any view not in the set, so dropping "wait-tx" from it kills
+67 -29
View File
@@ -14,27 +14,25 @@ const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27. // AutistMask's first-ever swap, 2026-02-27, sent from USER_ADDR.
// Swapped USDT for ETH via Uniswap V4 Universal Router. // Swapped USDT for ETH via Uniswap V4 Universal Router.
// https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77 // https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77
// This is that transaction's input, as a public Ethereum node returns it.
const FIRST_SWAP_CALLDATA = const FIRST_SWAP_CALLDATA =
"0x3593564c" + "0x3593564c" +
"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0" + "000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000069a1550f0000000000000000000000000000000000000000000000000000000000000002" +
"0000000000000000000000000000000000000000000000000000000069a1550f00000000000000000000000000000000000000000000000000000000000000020a10000000000000000000000000000000000000000000000000000000000000" + "0a100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
"0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" + "0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000069c8daf6" +
"0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e0" +
"0000000000000000000000000000000000000000000000000000000069c8daf6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" + "0000000000000000000000000000000000000000000000000000000000000041230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
"0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041" +
"230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
"00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" + "00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" +
"070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" + "070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" +
"00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" + "00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" +
"0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e7" + "0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e70000000000000000000000000000000000000000000000000000000000000001" +
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064" + "0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001" +
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060" +
"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000060" +
"dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
"66133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
const coder = AbiCoder.defaultAbiCoder(); const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([ const routerIface = new Interface([
@@ -362,9 +360,11 @@ describe("uniswap decoder", () => {
expect(result.details.find((d) => d.label === "Amount").value).toBe( expect(result.details.find((d) => d.label === "Amount").value).toBe(
"Unlimited", "Unlimited",
); );
// Its TAKE pays the From address, so the minimum is received.
expect( expect(
result.details.find((d) => d.label === "Min. received").value, result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH"); ).toBe("0.0002 ETH");
expect(labels).not.toContain("Paid to");
// Real mainnet calldata is in the deployed shape, so its V4 step is // Real mainnet calldata is in the deployed shape, so its V4 step is
// not said to be undecoded. // not said to be undecoded.
@@ -739,7 +739,9 @@ describe("uniswap decoder", () => {
}); });
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so // Paid in ETH, with an exact-out step, the last swap step buys WETH, so
// UNWRAP_WETH makes the output ETH. // UNWRAP_WETH makes the output ETH. That step is paid by the user, so the
// USDC the exact-out step bought stays in the router, and the minimum is
// not received (https://git.eeqj.de/sneak/AutistMask/issues/606).
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => { test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked( solidityPacked(
@@ -767,7 +769,7 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH"); expect(detail(result, "Min. paid out").value).toBe("0.4000 ETH");
}); });
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too. // Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
@@ -873,6 +875,7 @@ describe("uniswap decoder", () => {
expect(result.name).toBe("Swap USDT \u2192 ETH"); expect(result.name).toBe("Swap USDT \u2192 ETH");
}); });
// No step pays anyone, so the minimum is not said to be received.
test("decodes BALANCE_CHECK_ERC20 for min output", () => { test("decodes BALANCE_CHECK_ERC20 for min output", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
@@ -886,7 +889,7 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
const minOut = result.details.find((d) => d.label === "Min. received"); const minOut = result.details.find((d) => d.label === "Min. paid out");
expect(minOut).toBeDefined(); expect(minOut).toBeDefined();
expect(minOut.value).toContain("2.0000"); expect(minOut.value).toContain("2.0000");
expect(minOut.value).toContain("USDT"); expect(minOut.value).toContain("USDT");
@@ -953,7 +956,28 @@ describe("uniswap decoder", () => {
router: "UniversalRouterV2_1_2", router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR, address: ROUTER_V2_1_2_ADDR,
command: "0x40", command: "0x40",
steps: "Command 0x40", steps: "Across Bridge Deposit",
},
// Only the routers with the 0x7f mask have 0x07, as
// PAY_PORTION_FULL_PRECISION; the others revert on it
// (https://git.eeqj.de/sneak/AutistMask/issues/599).
{
router: "UniversalRouterV1",
address: ROUTER_V1_ADDR,
command: "0x07",
steps: "Command 0x07",
},
{
router: "UniversalRouterV2",
address: ROUTER_ADDR,
command: "0x07",
steps: "Command 0x07",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x07",
steps: "Pay Portion (Full Precision)",
}, },
])( ])(
"command $command sent to $router is listed as $steps", "command $command sent to $router is listed as $steps",
@@ -965,13 +989,23 @@ describe("uniswap decoder", () => {
); );
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does // A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
// not read. With the allow-revert flag (0x80) the command byte is 0xa1. // not read. With the allow-revert flag (0x80) the command byte is 0xa1,
// and the router lets the step fail
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
test.each([ test.each([
{ flag: "without", command: 0x21 }, {
{ flag: "with", command: 0xa1 }, flag: "without",
command: 0x21,
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
flag: "with",
command: 0xa1,
steps: "Execute Sub-Plan (its steps are not shown) (allowed to fail)",
},
])( ])(
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown", "an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
({ command }) => { ({ command, steps }) => {
const subPlan = encodeSubPlan("0x00", [ const subPlan = encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [ encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR, USDT_ADDR,
@@ -991,9 +1025,7 @@ describe("uniswap decoder", () => {
USER_ADDR, USER_ADDR,
); );
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(steps);
"Execute Sub-Plan (its steps are not shown)",
);
// The sub-plan names both currencies, so the lines must not say // The sub-plan names both currencies, so the lines must not say
// the calldata did not. // the calldata did not.
expect(detail(result, "Token In").value).toBe( expect(detail(result, "Token In").value).toBe(
@@ -1414,9 +1446,12 @@ describe("uniswap decoder", () => {
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL, // are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the // SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
// sides to the swap actions alone. // sides to the swap actions alone.
// `minLabel` is the minimum's line: with no TAKE or TAKE_ALL, no step
// pays the user, so it is not said to be received.
const V4_STEP_ENDINGS = [ const V4_STEP_ENDINGS = [
{ {
ending: "SETTLE_ALL and TAKE_ALL", ending: "SETTLE_ALL and TAKE_ALL",
minLabel: "Min. received",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID], ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [ params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n), encodeV4SettleAll(USDT_ADDR, 1000000000n),
@@ -1425,11 +1460,13 @@ describe("uniswap decoder", () => {
}, },
{ {
ending: "SETTLE and TAKE", ending: "SETTLE and TAKE",
minLabel: "Min. received",
ids: [V4_SETTLE_ID, V4_TAKE_ID], ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)], params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
}, },
{ {
ending: "CLOSE_CURRENCY", ending: "CLOSE_CURRENCY",
minLabel: "Min. paid out",
ids: [ ids: [
V4_CLOSE_CURRENCY_ID, V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID, V4_CLOSE_CURRENCY_ID,
@@ -1578,7 +1615,7 @@ describe("uniswap decoder", () => {
), ),
)( )(
"a V4 step of $step, then $ending, reads Swap USDT → USDC", "a V4 step of $step, then $ending, reads Swap USDT → USDC",
({ swaps, ids, params, amount, min }) => { ({ swaps, ids, params, amount, min, minLabel }) => {
const data = buildExecute( const data = buildExecute(
"0x10", "0x10",
[ [
@@ -1601,7 +1638,7 @@ describe("uniswap decoder", () => {
expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")?.value).toBe(amount); expect(detail(result, "Amount")?.value).toBe(amount);
expect(detail(result, "Min. received")?.value).toBe(min); expect(detail(result, minLabel)?.value).toBe(min);
}, },
); );
@@ -1910,7 +1947,8 @@ describe("uniswap decoder", () => {
}); });
// With no minimum stated before it, a zero balance check is what sets the // With no minimum stated before it, a zero balance check is what sets the
// output side, and it guarantees nothing. // output side, and it guarantees nothing. No step pays anyone, so the
// minimum is not said to be received.
test("a zero balance check with no earlier minimum states no minimum", () => { test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
@@ -1925,7 +1963,7 @@ describe("uniswap decoder", () => {
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT"); expect(detail(result, "Token Out").value).toContain("USDT");
expect(detail(result, "Min. received").value).toBe( expect(detail(result, "Min. paid out").value).toBe(
"None (no minimum guaranteed)", "None (no minimum guaranteed)",
); );
}); });
File diff suppressed because it is too large Load Diff
+15 -7
View File
@@ -186,14 +186,14 @@ function node(id) {
return globalThis.document.getElementById(id); return globalThis.document.getElementById(id);
} }
// Open the transaction detail screen with Sepolia active, for the // Open the transaction detail screen with `activeNetworkId` active, for the
// transaction the explorer reports sent to `sentTo`, and wait for its // Sepolia transaction the explorer reports sent to `sentTo`, and wait for its
// decoded details. // decoded details.
async function showDetailSentTo(sentTo) { async function showDetailSentTo(sentTo, activeNetworkId) {
mockSentTo = sentTo; mockSentTo = sentTo;
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.networkId = "sepolia"; state.networkId = activeNetworkId;
state.blockscoutUrl = "https://explorer.example/api/v2"; state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
@@ -216,21 +216,29 @@ async function showDetailSentTo(sentTo) {
describe("execute() calldata on the transaction detail screen", () => { describe("execute() calldata on the transaction detail screen", () => {
test("sent to a router on the active network, it is a swap", async () => { test("sent to a router on the active network, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER); await showDetailSentTo(SEPOLIA_ROUTER, "sepolia");
expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router",
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/623
test("sent to a router on its network, with the other network active, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER, "mainnet");
expect(node("tx-detail-calldata-details").innerHTML).toContain( expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router", "Swap via Uniswap Universal Router",
); );
}); });
test("sent to an ordinary address, it is not decoded", async () => { test("sent to an ordinary address, it is not decoded", async () => {
await showDetailSentTo(ORDINARY); await showDetailSentTo(ORDINARY, "sepolia");
expect(node("tx-detail-calldata-action").textContent).toBe("execute"); expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
}); });
test("sent to a router of the other network only, it is not decoded", async () => { test("sent to a router of the other network only, it is not decoded", async () => {
await showDetailSentTo(MAINNET_ROUTER); await showDetailSentTo(MAINNET_ROUTER, "sepolia");
expect(node("tx-detail-calldata-action").textContent).toBe("execute"); expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
+14 -12
View File
@@ -71,6 +71,8 @@ function exactInParams(currencyIn, path, amountIn, amountOutMin) {
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token, // BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
// uint256 minBalance). It names the output token and nothing about the input. // 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) { function balanceCheck(token, minBalance) {
return coder.encode( return coder.encode(
["address", "address", "uint256"], ["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) { function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, "0x1", {}); const decoded = decode(data);
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
@@ -87,7 +94,7 @@ function detail(data, label) {
describe("an execute() whose input currency never decoded", () => { describe("an execute() whose input currency never decoded", () => {
// A V4_SWAP whose sub-action params do not decode — an action encoding // A V4_SWAP whose sub-action params do not decode — an action encoding
// this decoder does not know — leaves every V4 token null. The // 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 // figure and a Token Out, and previously claimed the user was paying ETH
// for them. // for them.
const data = () => const data = () =>
@@ -113,9 +120,7 @@ describe("an execute() whose input currency never decoded", () => {
}); });
test("does not name ETH in the swap title either", () => { test("does not name ETH in the swap title either", () => {
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe( expect(decode(data()).name).toBe("Uniswap Swap");
"Uniswap Swap",
);
}); });
}); });
@@ -125,14 +130,13 @@ describe("an execute() that names only an output token", () => {
test("refuses the input rather than defaulting it to ETH", () => { test("refuses the input rather than defaulting it to ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL); expect(detail(data(), "Token In").value).toBe(REFUSAL);
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe( expect(decode(data()).name).toBe("Uniswap Swap");
"Uniswap Swap",
);
}); });
test("still states the output it did establish", () => { test("still states the output it did establish", () => {
expect(detail(data(), "Token Out").value).toContain("USDT"); 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");
}); });
}); });
@@ -174,9 +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, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH"); expect(detail(data, "Amount").value).toBe("1.0000 ETH");
expect(uniswap.decode(data, ROUTER, "0x1", {}).name).toBe( expect(decode(data).name).toBe("Swap ETH → USDT");
"Swap ETH → USDT",
);
}); });
test("WRAP_ETH is still ETH at 18 decimals", () => { test("WRAP_ETH is still ETH at 18 decimals", () => {
+3 -1
View File
@@ -134,7 +134,9 @@ describe("a V4 step that states a Min. received but no output currency", () => {
}); });
test("refuses to scale Min. received rather than assuming 18", () => { 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), unknownDecimalsAmount(MIN_OUT),
); );
}); });
+51 -38
View File
@@ -39,7 +39,8 @@ jest.mock("../src/shared/log", () => ({
// Everything the confirmation screen would reach the network for. The gas // 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 // 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 = { const mockProvider = {
getFeeData: async () => ({ getFeeData: async () => ({
maxFeePerGas: 2000000000n, maxFeePerGas: 2000000000n,
@@ -48,7 +49,14 @@ const mockProvider = {
estimateGas: async () => 21000n, estimateGas: async () => 21000n,
getCode: async () => "0x", getCode: async () => "0x",
getTransactionCount: async () => 1, 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", () => { jest.mock("../src/shared/balances", () => {
@@ -114,7 +122,7 @@ global.document = {
}; };
global.navigator = { clipboard: { writeText() {} } }; global.navigator = { clipboard: { writeText() {} } };
const { parseUnits } = require("ethers"); const { AbiCoder, parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances"); const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log"); const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state"); 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 // 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) { async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
ok: true, ok: true,
status: 200, status: 200,
@@ -181,6 +190,7 @@ async function fetchOnto(items) {
// is from the first. Two addresses is what it takes to reach // is from the first. Two addresses is what it takes to reach
// explorerDecimals()'s disagreement check, which is only reachable across rows. // explorerDecimals()'s disagreement check, which is only reachable across rows.
async function fetchOntoBoth(itemsA, itemsB) { async function fetchOntoBoth(itemsA, itemsB) {
mockProvider.tokenUnits = BigInt(itemsA[0].value);
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
ok: true, ok: true,
status: 200, status: 200,
@@ -239,6 +249,12 @@ function errors() {
return el("confirm-errors").innerHTML; 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() { function sendDisabled() {
return el("btn-confirm-send").disabled; 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"); const txInfo = await reviewSend(WETH, "1.5");
expect(txInfo.tokenDecimals).toBe(18); expect(txInfo.tokenDecimals).toBe(18);
expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals); 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 () => { 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 // No fallback was introduced: resolution answers null here, and the
// null is what goes forward. // null is what goes forward.
expect(txInfo.tokenDecimals).toBeNull(); expect(txInfo.tokenDecimals).toBeNull();
expect(txInfo.tokenBalance).toBeNull();
confirmTx.show(txInfo); confirmTx.show(txInfo);
await settle(); await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)"); 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); 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"); const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBeNull(); expect(txInfo.tokenDecimals).toBeNull();
// The regression this closes: null scale alongside a non-null balance. // 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 () => { 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 // confirm-fee-unknown-error — "the network fee could not be
// estimated... please go back and try again" — as the only explanation // estimated... please go back and try again" — as the only explanation
// for a screen that can never proceed. // for a screen that can never proceed.
expect(errors()).not.toBe(""); expect(balanceError()).toContain("This token's balance is unknown");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(text("confirm-balance")).toBe("unknown (NOVEL)"); expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(sendDisabled()).toBe(true); expect(sendDisabled()).toBe(true);
// The fee line still reports the estimate as unavailable, because it // 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"); expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
const txInfo = await reviewSend(NOVEL, "1.5"); const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6); expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo); confirmTx.show(txInfo);
await settle(); await settle();
expect(text("confirm-balance")).toBe("5.0000 NOVEL"); expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe("");
expect(sendDisabled()).toBe(false); expect(sendDisabled()).toBe(false);
}); });
}); });
describe("the confirmation screen tells an unknown balance from a zero one", () => { describe("the confirmation screen tells an unknown balance from a zero one", () => {
function txInfo(tokenBalance) { // A send of 1.5 NOVEL from an address the node reports none of, with the
return { // token's scale `tokenDecimals`, or null for a scale nobody knows.
from: HOLDER, async function render(tokenDecimals) {
to: RECIPIENT, mockProvider.tokenUnits = 0n;
ensName: null,
amount: "1.5",
token: NOVEL,
balance: "1.0",
tokenSymbol: "NOVEL",
tokenBalance,
tokenDecimals: tokenBalance === null ? null : 18,
};
}
async function render(tokenBalance) {
state.wallets = [ state.wallets = [
{ {
name: "Wallet 1", name: "Wallet 1",
@@ -469,30 +472,40 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 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(); 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 () => { test("the balance line states unknown rather than a quantity of zero", async () => {
const unknown = await render(null); 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).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)"); expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0000 NOVEL"); expect(zero.balance).toBe("0.0000 NOVEL");
}); });
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to // 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 // spend from, which is the fail-closed side — but "your balance does not
// about the holding, and this one has no established quantity to claim. // cover this amount" is a claim about the holding, and this one has no
test("the insufficient-balance message names the reason, not a figure", async () => { // established quantity to claim.
test("the insufficient-balance message names the reason, not a shortfall", async () => {
const unknown = await render(null); const unknown = await render(null);
const zero = await render("0.0"); const zero = await render(18);
expect(unknown.errors).not.toBe(zero.errors); expect(unknown.message).toContain("This token's balance is unknown");
expect(unknown.errors).toContain("This token&#39;s balance is unknown"); expect(unknown.message).not.toContain("does not cover");
expect(unknown.errors).not.toContain("You have"); expect(zero.message).toBe(
expect(zero.errors).toContain("You have 0.0000 NOVEL"); "Your NOVEL balance does not cover this amount. Please go back and" +
expect(zero.errors).not.toContain("balance is unknown"); " send a smaller amount.",
);
}); });
}); });