Author SHA1 Message Date
clawbot 7919550c94 fix: a swap split across steps the user pays for shows every amount it spends (closes #625)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The Amount line gave only the first swap step's amount. It now gives the
total the steps take of Token In: the amount of the step that sets the
input side plus each later take of it by a step the user pays. A V4
step, the first included, takes what its SETTLEs paid by the user state.
A take of another token, one after an amount that is not a quantity, or
one of unknown amount gets lines of its own. A step that may take what
the decoder did not read gets one line saying so and leaves no figure.
A PERMIT2_PERMIT names Token In; its amount stands only in the plain
shape of Uniswap's own routes.

Also closes #628.

Model: opus-5-5
2026-10-20 05:07:00 +00:00
clawbot 5b264f4b3e fix: TxApproval links addresses to the explorer of the transaction's network (closes #631)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
TxApproval built its From, Contract and decoded-action address links for
the active network, so a Sepolia transaction reviewed with mainnet active
linked to the mainnet explorer. approvalAddressHtml() now takes the
transaction's chain id and passes it to renderAddressHtml(), as SuccessTx
and ErrorTx do. A chain id that names no network has no explorer, so its
address lines are drawn without a link and the screen still shows. SignApproval
and SiteApproval still link for the active network. The walletDefects
fixture's numeric chain id, which names no network, is now the hex string
the background sends.

Model: opus-5-5
2026-10-20 07:01:41 +02:00
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
32 changed files with 6327 additions and 569 deletions
+339 -118
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
@@ -1170,14 +1190,8 @@ number. They can read:
`amountOut`, the exact amount it buys. `amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the whole open delta", so the calldata states no quantity. Transaction Decoding
amount of the step that sets the input side: the last `PERMIT2_PERMIT` step if below gives the steps whose amounts the line shows.
there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH`
or V4 swap step that names an input token or amount. A V4 step with a swap
action sets the input side when no earlier step set it, even when it names
neither, as described below. A V2 exact-out step gives its `amountInMax`, and
a V4 swap step the `amountIn` of the swap action that spends its input
currency.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's - `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero. `Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the The router reads that zero as "the pair already holds the input tokens": the
@@ -1193,7 +1207,13 @@ 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
@@ -1215,7 +1235,9 @@ only when no earlier step set it. It finds each side by three rules:
is their sum; in any other case the step gives no `Amount`. The decoder reads is their sum; in any other case the step gives no `Amount`. The decoder reads
no amount from an exact-out action, and an open delta is not a stated amount. no amount from an exact-out action, and an open delta is not a stated amount.
Its `Min. received` is found the same way, from the `amountOutMin` of the Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency. actions that buy the output currency. When the user pays the step any of the
input currency, its `Amount` is what the user pays instead (see Transaction
Decoding).
3. If any swap action in the step could not be read, only `SETTLE`, 3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no `SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no
`Amount` or `Min. received`. A side they did not name is unknown, and the `Amount` or `Min. received`. A side they did not name is unknown, and the
@@ -1505,9 +1527,11 @@ 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 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 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 popup closed straight after it and reopened onto Settings lands in the same
place. "Back" then renders the screen it lands on for the new network, except place. "Back" then renders the screen it lands on for the new network, and
ConfirmTx, which keeps the network its transaction was reviewed on, and anything anything typed there and not saved, which belonged to the previous network, is
typed there and not saved, which belonged to the previous network, is dropped. 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 Without a wallet the stack is not cut, because it starts at Welcome, which shows
nothing from a network. nothing from a network.
@@ -1752,14 +1776,18 @@ 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 could not be read ETH amount Send's "Max" filled in is worked out again from this screen's
or no longer covers the fee, the amount is left as it was and one of the own fee estimate when it arrives, as the balance minus the reserve, and
errors below blocks the send the transaction is signed with that estimate's fee fields, so a fee
fetched again at signing cannot exceed what the amount leaves behind. The
address keeps whatever part of the reserve the transaction does not use.
If the balance could not be read or no longer covers the fee, the amount
is left as it was and one of the errors below blocks the send
- Your balance: the From address's balance on the network its transaction - 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 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 screen is shown, so a screen the popup reopens onto states and checks that
@@ -1793,6 +1821,10 @@ view would leave a wallet one click from deletion.
estimated, and it says the wallet does not know how many decimal places 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 the token uses and that the transaction cannot be sent; for any other
failure it asks the user to go back and try again 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, while the balance is being read, - "Sign & Send" button (disabled if errors, while the balance is being read,
@@ -1810,7 +1842,7 @@ view would leave a wallet one click from deletion.
- "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 → nothing is signed; "The active network changed transaction was reviewed → nothing is signed; "The active network changed
after this transaction was reviewed, so it was not sent. Please go back after this transaction was reviewed, so it was not sent. Please go back
and review it again." in the errors box, no screen change. After a switch, and review it again." in its own box, no screen change. After a switch,
"Back" from Settings draws this screen again from the saved transaction, "Back" from Settings draws this screen again from the saved transaction,
as a popup reopened onto it does; when Send is beneath it on the stack, as as a popup reopened onto it does; when Send is beneath it on the stack, as
when Send showed it in the same popup, "Back" lands below Send instead when Send showed it in the same popup, "Back" lands below Send instead
@@ -1824,13 +1856,21 @@ view would leave a wallet one click from deletion.
fee estimate and the contract and never-sent recipient checks ask that 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 network's RPC endpoint, and the Etherscan label check asks that network's
explorer 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**
@@ -1850,14 +1890,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 →
@@ -1878,9 +1921,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**
@@ -1900,6 +1946,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**
@@ -1922,7 +1971,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
@@ -2323,19 +2375,25 @@ 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 unless a swap pays only the From address, a amounts, what each swap step spends that the `Amount` line does not give,
`Paid to` line for each other address it pays, or saying that its output what each `PERMIT2_TRANSFER_FROM` moves and where to, steps, deadline, and
stays in the router or who it pays is unknown (see Transaction Decoding) unless a swap pays only the From address, a `Paid to` line 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
- "Confirm" / "Reject" buttons - "Confirm" / "Reject" buttons
- **Behavior**: Every etherscan link on the screen opens the explorer of the
network the transaction was populated on, whichever network is active.
- **Transitions**: - **Transitions**:
- "Confirm" (correct password) → decrypts and signs the transaction it was - "Confirm" (correct password) → decrypts and signs the transaction it was
shown, exactly as shown, hands the signed transaction to the background to shown, exactly as shown, hands the signed transaction to the background to
@@ -2944,56 +3002,219 @@ 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`, `WRAP_ETH`, `UNWRAP_WETH` and `V4_INITIALIZE_POOL`. The decoder does not read those routers' NFT commands,
V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's and lists each by its name there, for example `Seaport` for `SEAPORT` (`0x10`
sender. The router reads `address(1)` as the transaction's sender, and on `UniversalRouterV1`). A type a release names no command for is listed by
`address(2)` as the router itself, which holds what it is paid until a later its number, for example `Command 0x0e` on `UniversalRouterV1`, which has no
step pays it out; a payment to the router, as `address(2)` or by its own `BALANCE_CHECK_ERC20` and reverts on it. The router reverts the whole
address, is not counted as paid out. When a swap step or `TAKE` pays the transaction when a step fails, unless the step's command byte has that flag
router the swap's output token, the output stays in the router unless a later set and its command is one the router lets fail, as each release's
`SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out. `contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`,
A swap step or `TAKE` paying a token to the V2 pair that the very next step `UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`, `PERMIT2_PERMIT`,
spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID` `PERMIT2_PERMIT_BATCH`, `BALANCE_CHECK_ERC20`, `V3_POSITION_MANAGER_PERMIT`,
(zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes `V3_POSITION_MANAGER_CALL`, `V4_INITIALIZE_POOL`, `V4_POSITION_MANAGER_CALL`
pay the next V2 pair directly, and the router enforces that swap's own and `EXECUTE_SUB_PLAN`; on `UniversalRouterV1_2_V2Support`,
minimum. The decoder checks that the address paid is that pair, deriving it `BALANCE_CHECK_ERC20`, `EXECUTE_SUB_PLAN` and its NFT market calls and NFT
from the V2 factory and pair init code hash the network's routers were owner checks; on `UniversalRouterV1`, its NFT market calls and NFT owner
deployed with and the first two tokens of that step's path; a payment to any checks. Such a step may then fail while the transaction goes on to the next
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was step, and it is listed with ` (allowed to fail)` after its name, for example
deployed with placeholders for both values, so no V2 swap step can succeed on `Balance Check (allowed to fail)`. On every other command, among them each V2,
that router.) Every other payment counts, in whichever step and of whatever V3 and V4 swap and each payment of ETH or an ERC-20 token, the flag has no
token. The minimum reads `Min. received` only when some step pays the effect: the step succeeds or the whole transaction reverts, so the step reads
transaction's sender (the From address on TxApproval, the sender the explorer as it does without the flag. A `BALANCE_CHECK_ERC20` the router lets fail
reports on TransactionDetail), no step pays any other address, no step that guarantees nothing, so it is read as one whose `minBalance` is zero. Only the
may pay an address went unread, and the output does not stay in the router. routers with the `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`),
Otherwise it reads `Min. paid out`, a `Paid to` line after `Token Out` names listed as `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`),
each other address paid, one reads listed as `Across Bridge Deposit`; the other routers revert on `0x07`, and it
`The router, where it stays (no later step pays it out)` when the output stays is listed there as `Command 0x07`. The decoder does not read the commands
in the router, and one more reads `Unknown (could not be decoded)` when such a inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they
step went unread, or `Unknown (not named in the calldata)` when no step pays may spend the user's tokens or pay the user. That step is listed as
anyone. A step goes unread when its recipient does not decode as an address, `Execute Sub-Plan (its steps are not shown)`, and whatever the other steps
or when it is a command or V4 action the decoder does not read: every command state, the summary names no swap direction, `Token In` and `Token Out` read
other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `Unknown (could not be decoded)`, and there is no `Amount` or `Min. received`
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH`, `TRANSFER`,
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `PERMIT2_TRANSFER_FROM` (which
`EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`. 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. A `SETTLE` of `OPEN_DELTA`
(zero), and a `SETTLE_ALL`, pay only what the step still owes of their token
when they run. The decoder takes that to be 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` or
`SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that token and
comes after every swap action, no swap action buys that token, no `TAKE`,
`TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a stated amount
adds to what the step owes), and the swap actions that spend it state a
quantity together; otherwise it does not know the amount, and such a `SETTLE`
from the router 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`. The `Amount` line gives the total the steps
take of `Token In`, from the step that sets the input side on: the first V2 or
V3 exact-in, V2 exact-out, `WRAP_ETH` or V4 swap step that names an input
token or amount. A V4 step with a swap action sets the input side when no
earlier step set it, even when it names neither (see Display Consistency).
That step counts its own amount, whoever pays it: a V2 exact-out step its
`amountInMax`, and a V4 step what it takes of its input token from the user's
wallet, as below, or, when the user pays it none of that token, the `amountIn`
of the swap action that spends it. Each later V2 or V3 exact-in or V2
exact-out step whose `payerIsUser` is true takes its amount, found in the same
way, from the user's wallet. Every V4 step, with a swap action or without,
takes from the user's wallet what its `SETTLE`s paid by the user
(`payerIsUser` true) and its `SETTLE_ALL`s pay, for each token on its own;
what the step that sets the input side takes of any other token counts as a
later step's take. A `SETTLE` of any amount but `OPEN_DELTA` pays that amount,
and a `SETTLE` of `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as
above. So a V4 step that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by
the user and the rest taken to another address, reads `5000.0000 USDT`. A
later step paid from the router spends what the router holds and takes
nothing, and so does a V2 exact-in whose `amountIn` is `ALREADY_PAID`. A later
take of `Token In` is added to the total while both are quantities: a V3
exact-in of 600 USDT and a V2 exact-in of 400 USDT, both paid by the user,
read `1000.0000 USDT`. When the decoder does not know a take's amount, the
total is not known and there is no `Amount` line. `Token In` is the token of
the last `PERMIT2_PERMIT` the decoder reads, when there is one. Its amount,
`Unlimited` included, stands as the `Amount` figure in place of the total only
in the plain shape of Uniswap's own routes: it is the only `PERMIT2_PERMIT`,
its spender is the router, the router may not let it fail, no step before it
takes its token or a token the decoder does not know, and the decoder reads
every step after it in full. Permit2 then lets the router take no more of the
token than that amount, so the permit covers every take of it. A step the
decoder does not read in full may raise what Permit2 lets the router take, as
a `PERMIT2_PERMIT_BATCH` can: a command the decoder does not read (listed at
the end of this section), a step whose input it cannot read, in whole or in
part, and a step it fails on. In any other case the line gives the total, as
with no permit. When the step that sets the input side spends a token other
than the permit's, or one the decoder does not know, its amount gets lines of
its own and the total has no figure to start from: unless the permit's amount
stands there is no `Amount` line, and each take of the permit's token gets
lines of its own. Each take the total does not count gives three lines after
the `Amount` line (after `Token In` when there is none): one of a token other
than `Token In`; one of `Token In` after an amount that is not a quantity (a
V4 open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line,
or no stated amount, as of a V4 exact-out step); and one whose amount the
decoder does not know. When the step that sets the input side states no amount
and there is no `Amount` line, that step gets them too whenever another take's
lines give a figure, so that figure cannot read as all that leaves. They come
in step order, the step that sets the input side first when it has them:
`Moved from the sender`, its amount, worded as the `Amount` line words one, or
`Unknown (could not be decoded)` when the decoder does not know it;
`Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A token the decoder
does not know gives the `Moved from the sender` line alone. A step may also
take from the user's wallet what the decoder did not read: a V4 step whose
input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the
router still runs; a V2 or V3 exact-in or V2 exact-out step or a
`PERMIT2_TRANSFER_FROM` whose input it cannot read; every
`PERMIT2_TRANSFER_FROM_BATCH`; every `V3_SWAP_EXACT_OUT`, which the decoder
does not read, wherever it stands; every `EXECUTE_SUB_PLAN`, wherever it
stands; and every step it fails on. Each such step gives one
`Moved from the sender` line among those, reading
`Unknown (could not be decoded)`. That line, or any line for a token the
decoder does not know, leaves no `Amount` line, whose figure might be too low,
and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from
standing. Then 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
+27
View File
@@ -697,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
+18 -5
View File
@@ -127,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)
); );
} }
@@ -200,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:
+32 -19
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 {
@@ -57,9 +58,15 @@ const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
function approvalAddressHtml(address) { // The link opens the explorer of the network with `chainId`, or of the active
// network when none is given. A chain id that names no network has no
// explorer, so the address is drawn without a link.
function approvalAddressHtml(address, chainId) {
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title }); if (chainId && !networkByChainId(chainId)) {
return renderAddressHtml(address, { title, noLink: true });
}
return renderAddressHtml(address, { title, chainId });
} }
// The amount line for a decoded ERC-20 call. With a known scale it is the // The amount line for a decoded ERC-20 call. With a known scale it is the
@@ -85,13 +92,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 +267,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 = {
@@ -341,17 +344,24 @@ function showTxApproval(details) {
}; };
} }
// Every address link on this screen opens the explorer of the network the
// transaction is for, which the Network line names, whichever network is
// active.
$("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,
approvedTx.chainId,
);
// 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) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`; toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
} }
toHtml += approvalAddressHtml(toAddr); toHtml += approvalAddressHtml(toAddr, approvedTx.chainId);
$("approve-tx-to").innerHTML = toHtml; $("approve-tx-to").innerHTML = toHtml;
} else { } else {
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT); $("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
@@ -388,9 +398,12 @@ 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,
approvedTx.chainId,
);
} else { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
+69 -60
View File
@@ -23,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");
@@ -45,8 +43,8 @@ const {
transferAmountUnits, transferAmountUnits,
} = require("../../shared/transferAmount"); } = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify"); const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The balance line and the fee reserve go through it, as the approval screen's // The amount, the balance line and the fee reserve go through it, as the
// amounts do. // approval screen's amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { const {
CODES, CODES,
@@ -100,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
@@ -110,7 +132,7 @@ 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,
); );
} }
@@ -291,9 +313,13 @@ function show(txInfo) {
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";
@@ -430,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);
@@ -556,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") {
@@ -593,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 the invalid-amount message,
// 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);
@@ -640,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.
@@ -654,11 +663,7 @@ function init(_ctx) {
// Settings after a switch draws this screen again from the saved // Settings after a switch draws this screen again from the saved
// transaction, as a popup reopened onto it does. // 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;
} }
@@ -667,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;
} }
@@ -703,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);
@@ -719,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,
+3 -1
View File
@@ -343,11 +343,13 @@ function init(ctx) {
// switch request. // switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
resetRenderedViews(); resetRenderedViews();
// Settings itself is brought up to date for the new network here. // Settings itself, and the testnet banner above it, are brought up
// to date for the new network here.
markViewRendered("settings"); markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens(); renderTrackedTokens();
updateDebugBanner(state.currentView);
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
+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 {
+1084 -177
View File
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -442,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",
); );
}); });
}); });
+270
View File
@@ -0,0 +1,270 @@
// The approval screen for a site's Sepolia token transfer while Ethereum
// Mainnet is active.
//
// 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 `?` (https://git.eeqj.de/sneak/AutistMask/issues/629).
// It now uses what the wallet knows of the tokens on the network the
// transaction is for.
//
// Its address links opened the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/631). They now open the
// explorer of 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 transaction sending TRANSFER_DATA to
// the token, on Sepolia unless `chainId` names another chain. Confirm answers
// the signed transaction with a failure, which leads to the error screen.
async function showTokenTransfer(
tokenBalances = [],
chainId = NETWORKS.sepolia.chainId,
) {
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,
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 showTokenTransfer();
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 showTokenTransfer();
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 showTokenTransfer([
{ 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>',
);
});
// The From and Contract lines, and the decoded Token and Recipient lines.
test("every address links to the Sepolia explorer", async () => {
await showTokenTransfer();
const sepolia = NETWORKS.sepolia.explorerUrl;
expect(node("approve-tx-from").innerHTML).toContain(
`href="${sepolia}/address/${FROM}"`,
);
expect(node("approve-tx-to").innerHTML).toContain(
`href="${sepolia}/address/${TOKEN}"`,
);
expect(decodedLine("Token")).toContain(
`href="${sepolia}/address/${TOKEN}"`,
);
expect(decodedLine("Recipient")).toContain(
`href="${sepolia}/address/${RECIPIENT}"`,
);
for (const id of [
"approve-tx-from",
"approve-tx-to",
"approve-tx-decoded-details",
]) {
expect(node(id).innerHTML).not.toContain(NETWORKS.mainnet.explorerUrl);
}
});
// Chain id 1337 names no network here, so it has no explorer to link to.
test("a transaction on a chain id that names no network is shown with no address links", async () => {
await showTokenTransfer([], "0x539");
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-network").textContent).toBe(
"Unknown network (chain id 1337)",
);
expect(node("approve-tx-from").innerHTML).toContain(FROM);
for (const id of [
"approve-tx-from",
"approve-tx-to",
"approve-tx-decoded-details",
]) {
expect(node(id).innerHTML).not.toContain("href=");
}
});
+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"]);
+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();
});
});
+7 -20
View File
@@ -333,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"),
@@ -440,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.",
);
}); });
}, },
); );
@@ -497,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 () => {
@@ -579,10 +572,7 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(errors()).toContain( expect(shown("confirm-network-changed-error")).toBe(true);
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
}); });
const tokenSend = { const tokenSend = {
@@ -629,10 +619,7 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(errors()).toContain( expect(shown("confirm-network-changed-error")).toBe(true);
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
}); });
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 () => {
@@ -662,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"),
+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 };
+7 -4
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,7 +240,7 @@ 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: [],
}; };
} }
@@ -481,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
@@ -625,6 +626,8 @@ 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 * @param {boolean} [opts.holdBalance] hold every batch containing an
+276 -2
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, how tall the whole view is, and where Sign & Send sits in it. // 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,9 @@ 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: sendTop:
el("btn-confirm-send").getBoundingClientRect().top - el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top, el("view-confirm-tx").getBoundingClientRect().top,
@@ -2262,6 +2325,12 @@ async function confirmState(page) {
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,
}; };
}); });
@@ -2905,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),
); );
@@ -3057,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
+3 -3
View File
@@ -245,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);
}); });
@@ -429,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 () => {
+52 -2
View File
@@ -441,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" } },
@@ -454,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"],
@@ -477,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: {
@@ -799,7 +843,13 @@ 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,
@@ -808,7 +858,7 @@ const WELL_FORMED_DATA = {
}, },
pendingWait: { pendingWait: {
hash: "0x1", hash: "0x1",
txInfo: { to: ADDRESS, amount: "1" }, txInfo: { to: ADDRESS, amount: "1", chainId: "0x1" },
broadcastTime: BROADCAST_TIME, broadcastTime: BROADCAST_TIME,
}, },
}; };
+1 -1
View File
@@ -58,7 +58,7 @@ function restoringOnto(view, viewData) {
test("the transaction screen's Blockscout request reaches the stub", async () => { test("the transaction screen's Blockscout request reaches the stub", async () => {
const page = await bootPopup( const page = await bootPopup(
restoringOnto("transaction", { restoringOnto("transaction", {
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS }, tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, chainId: "0x1" },
}), }),
); );
+12 -6
View File
@@ -306,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);
@@ -322,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);
}); });
@@ -526,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");
}); });
}); });
@@ -540,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);
}); });
@@ -553,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(),
}, },
}, },
+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>',
);
});
+3 -3
View File
@@ -313,9 +313,9 @@ describe("Back after a switch from the Settings gear", () => {
boot.node("confirm-tx-password").value = "any password"; boot.node("confirm-tx-password").value = "any password";
await boot.click("btn-confirm-send"); await boot.click("btn-confirm-send");
expect(boot.visibleViews()).toEqual(["confirm-tx"]); expect(boot.visibleViews()).toEqual(["confirm-tx"]);
expect(boot.node("confirm-errors").innerHTML).toContain( expect(
"The active network changed after this transaction was reviewed", boot.node("confirm-network-changed-error").style.visibility,
); ).toBe("visible");
// Refused before the password is even tried. // Refused before the password is even tried.
expect(boot.text("confirm-tx-password-error")).toBe(""); expect(boot.text("confirm-tx-password-error")).toBe("");
}); });
+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
+1003 -12
View File
File diff suppressed because it is too large Load Diff
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);
+11 -10
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);
} }
@@ -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,9 +130,7 @@ 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", () => {
@@ -175,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", () => {
+1 -1
View File
@@ -475,7 +475,7 @@ describe("every way to a signature or the private key refuses a defective wallet
to: RECIPIENT, to: RECIPIENT,
value: "0x0", value: "0x0",
data: "0x", data: "0x",
chainId: 1, chainId: "0x1",
nonce: 0, nonce: 0,
gasLimit: "21000", gasLimit: "21000",
maxFeePerGas: "1000000000", maxFeePerGas: "1000000000",