Author SHA1 Message Date
clawbot cad2d262ce 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 swap's 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 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, such as a V3_SWAP_EXACT_OUT or an
EXECUTE_SUB_PLAN, 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.

Model: opus-5-5
2026-10-20 04:15:32 +00: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
29 changed files with 6018 additions and 537 deletions
+324 -125
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
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
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
@@ -433,7 +437,11 @@ 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.
and leave the password field and Sign & Send where they were. It checks the same
for a send refused because the selected wallet does not hold its From address:
with the confirmation screen showing a send from the HD wallet, the key wallet
is selected by writing it straight into extension storage, as another popup
selecting it would save it, and the popup is reopened onto that screen.
It also covers both ways the wait for a sent transaction's receipt ends on the
error screen: lookups that still find no receipt 60 seconds after the broadcast,
@@ -1082,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,
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`). Both screens render a network fee through
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
rather than its truncated figure, so the same fee reads the same on both, USD
value included. Balances are stored exactly (`src/shared/balances.js`), whatever
decimals a token declares, so a balance below the floor reaches these screens as
it is. The history list (`src/shared/transactions.js`) uses the unfloored one:
the transaction detail view is the authoritative record and already shows exact
precision. The balance lists use neither: they round to four places with
`toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal
rule is unchanged everywhere else, including for amounts at or above the floor
on the approval screens.
`Current balance` (`src/popup/views/send.js`), and the amount, balance and
network fee on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`), which hands the wait, success and error
screens the amount as it showed it. The transaction is still signed for the
exact amount, and its USD value is priced from it. Both screens render a network
fee through `formatFee()` in `src/popup/views/helpers.js`, which prices the
exact fee in USD rather than its truncated figure, so the same fee reads the
same on both, USD value included. Balances are stored exactly
(`src/shared/balances.js`), whatever decimals a token declares, so a balance
below the floor reaches these screens as it is. The history list
(`src/shared/transactions.js`) uses the unfloored one: the transaction detail
view is the authoritative record and already shows exact precision. The balance
lists use neither: they round to four places with `toFixed(4)` (`balanceLine()`
in `src/popup/views/helpers.js`). The 4-decimal rule is unchanged everywhere
else, including for amounts at or above the floor on the approval screens.
The floor applies only where the token's scale is known. Where it is not, the
approval screen states base units instead of a quantity — see Unknown token
scale below — and no truncation happens at all.
On the approval screen and the wait, success and error screens that carry its
amount forward, the floor applies only where the token's scale is known. Where
it is not, they state base units instead of a quantity — see Unknown token scale
below — and no truncation happens at all. On ConfirmTx and the wait, success and
error screens that follow it, the amount of a token whose scale is unknown,
which cannot be sent, is the text from Send's amount field cut to four decimals
with the same floor (`.5` reads `.5000`).
**Specific Exception — unknown token scale:** Calldata carries base units and no
scale, so every amount on the dApp approval screen needs the token's `decimals`.
It is resolved from the bundled token list, then from the tokens the user
tracks, then from what the block explorer reported for the contract
(`resolveTokenDecimals()` in `src/shared/approvalAmount.js`). Where none of them
answers, the amount is not formatted: the line reads
`5000000000 base units (decimals unknown)`. A guessed scale is not an
approximation but a different number — 1,000 units of a 6-decimal token
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
to state what is being authorized. Both amount paths of that screen take this
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
token permission warning on the signature screen takes the same rule for its
amounts. An unbounded allowance or permit needs no scale to describe and is
still shown as `Unlimited`. A source's answer counts only if it is a whole
number from 0 to 80: `decimals()` returns a `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.
It is resolved from the bundled token list, then from the tokens the user tracks
on the network the transaction is for, then, when that is the active network,
from what the block explorer reported for the contract in the wallet's balances,
which are the active network's only (`resolveTokenDecimals()` in
`src/shared/approvalAmount.js`). Where none of them answers, the amount is not
formatted: the line reads `5000000000 base units (decimals unknown)`. A guessed
scale is not an approximation but a different number — 1,000 units of a
6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen
whose only job is to state what is being authorized. Both amount paths of that
screen take this rule: the ERC-20 `transfer`/`approve` line
(`src/popup/views/approval.js`) and the swap's `Amount` and `Min. received`
lines (`src/shared/uniswap.js`). The token permission warning on the signature
screen takes the same rule for its amounts. An unbounded allowance or permit
needs no scale to describe and is still shown as `Unlimited`. A source's answer
counts only if it is a whole number from 0 to 80: `decimals()` returns a
`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
sources are read as authoritative, so a value written into one of them cannot be
@@ -1175,14 +1190,8 @@ number. They can read:
`amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the
amount of the step that sets the input side: the last `PERMIT2_PERMIT` step if
there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH`
or V4 swap step that names an input token or amount. A V4 step with a swap
action sets the input side when no earlier step set it, even when it names
neither, as described below. A V2 exact-out step gives its `amountInMax`, and
a V4 swap step the `amountIn` of the swap action that spends its input
currency.
whole open delta", so the calldata states no quantity. Transaction Decoding
below gives the steps whose amounts the line shows.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
@@ -1198,7 +1207,13 @@ number. They can read:
guarantees nothing: it sets `Token Out` and `Min. received` only when the
output side holds no minimum, not even a zero one, at the point the check is
reached, and otherwise leaves the current token and figure in place. A nonzero
`minBalance` sets both lines, as a swap step does.
`minBalance` sets both lines, as a swap step does. Only a check of the
transaction's sender's own balance sets either line; a check of any other
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
that the router lets fail without reverting the transaction (see Transaction
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
lets fail is likewise read as zero.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -1510,9 +1525,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
picked on the previous network. The cut stack is saved with the switch, so a
popup closed straight after it and reopened onto Settings lands in the same
place. "Back" then renders the screen it lands on for the new network, except
ConfirmTx, which keeps the network its transaction was reviewed on, and anything
typed there and not saved, which belonged to the previous network, is dropped.
place. "Back" then renders the screen it lands on for the new network, and
anything typed there and not saved, which belonged to the previous network, is
dropped. ConfirmTx, WaitTx, SuccessTx, ErrorTx and TransactionDetail are
rendered instead for the network of the transaction they show: the network it
was reviewed on for ConfirmTx, and the network it was sent on for the others.
Without a wallet the stack is not cut, because it starts at Welcome, which shows
nothing from a network.
@@ -1757,14 +1774,18 @@ view would leave a wallet one click from deletion.
- Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
filled in is worked out again from this screen's own fee estimate when it
arrives, as the balance minus the reserve, and the transaction is signed
with that estimate's fee fields, so a fee fetched again at signing cannot
exceed what the amount leaves behind. The address keeps whatever part of
the reserve the transaction does not use. If the balance 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
- Amount: value + symbol (USD in parentheses), the value to four decimal
places with the nonzero floor, as on TxApproval (see Display Consistency),
however Send's amount field wrote it (`.5` and `0.5` both read `0.5000`).
For a token whose scale is unknown, which cannot be sent, the value is the
text from Send's amount field cut the same way (`.5` reads `.5000`). An
ETH amount Send's "Max" filled in is worked out again from this screen's
own fee estimate when it arrives, as the balance minus the reserve, and
the transaction is signed with that estimate's fee fields, so a fee
fetched again at signing cannot exceed what the amount leaves behind. The
address keeps whatever part of the reserve the transaction does not use.
If the balance could not be read or no longer covers the fee, the amount
is left as it was and one of the errors below blocks the send
- Your balance: the From address's balance on the network its transaction
was reviewed on, read from that network's RPC endpoint each time the
screen is shown, so a screen the popup reopens onto states and checks that
@@ -1798,8 +1819,9 @@ view would leave a wallet one click from deletion.
estimated, and it says the wallet does not know how many decimal places
the token uses and that the transaction cannot be sent; for any other
failure it asks the user to go back and try again
- Two more reserved error boxes, made visible when "Sign & Send" refuses a
send because the active network changed or because of its network fee (see
- Three more reserved error boxes, made visible when "Sign & Send" refuses a
send because the active network changed, because the selected wallet does
not hold the From address, or because of its gas limit or fee (see
Transitions), so the refusal moves nothing
- Password: an inline field on this screen, not a modal, with its own error
line
@@ -1832,19 +1854,21 @@ view would leave a wallet one click from deletion.
fee estimate and the contract and never-sent recipient checks ask that
network's RPC endpoint, and the Etherscan label check asks that network's
explorer
- "Sign & Send" (correct password) when the gas limit and fee the RPC
endpoint fills in at signing go over the bounds `assertWithinCeilings()`
in `src/shared/approvalVerify.js` sets → nothing is signed; "This
transaction would allow a network fee of more than this wallet will sign
for, so it was not sent. Please go back and try again." in its own box, no
screen change
- "Sign & Send" (correct password) when the gas limit, a fee per gas or the
total fee the RPC endpoint fills in at signing is over its bound in
`assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is
signed; "The gas limit or fee the RPC endpoint quoted for this transaction
is more than this wallet will sign for, so it was not sent. Please go back
and try again." in its own box, the same sentence for all three, no screen
change
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not
hold the From address → full-sentence error on the password error line,
nothing sent (no screen change)
hold the From address → nothing is signed; "The selected wallet does not
hold the From address above, so nothing was sent. Please go back and send
again." in its own box, no screen change
- "Sign & Send" (correct password, but the From address's key is refused,
see Data Model) → nothing is signed → **ErrorTx** saying why
- "Back" → **Send**
@@ -1864,14 +1888,17 @@ view would leave a wallet one click from deletion.
no recipient." instead
- Transaction hash: full hash (tap to copy) + etherscan link
- Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
persisted: closing and reopening the popup resumes the poll, with the elapsed
counter and the timeout deadline still measured from the original broadcast. A
lookup that fails is retried on the next tick rather than counted 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.
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The screen
describes the transaction on the network it was sent on, whichever network is
active: the receipt is asked of that network's RPC endpoint, the one Settings
has for it, and the To and hash links open that network's explorer. The wait
is persisted: closing and reopening the popup resumes the poll, with the
elapsed counter and the timeout deadline still measured from the original
broadcast. A lookup that fails is retried on the next tick rather than counted
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**:
- Receipt found → **SuccessTx**
- A lookup that answers "no receipt" 60 seconds or more after broadcast →
@@ -1892,9 +1919,12 @@ view would leave a wallet one click from deletion.
- Amount + symbol
- To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx
- Block number
- Block number + etherscan link
- Transaction hash: full hash (tap to copy) + etherscan link
- "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**:
- "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
@@ -1914,6 +1944,9 @@ view would leave a wallet one click from deletion.
- Transaction hash section (hidden if broadcast failed before getting hash):
full hash (tap to copy) + etherscan link
- "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**:
- "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
@@ -1936,7 +1969,10 @@ view would leave a wallet one click from deletion.
#### TransactionDetail (`transaction`)
- **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
labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button
@@ -2337,15 +2373,19 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a
transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying that its output
stays in the router or who it pays is unknown (see Transaction Decoding)
amounts, what each swap step spends that the `Amount` line does not give,
what each `PERMIT2_TRANSFER_FROM` moves and where to, steps, deadline, and
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
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
- Value: amount in ETH (4 decimal places, USD in parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
in parentheses), with the gas limit and the fee per gas in gwei below it
- Value: amount in ETH (4 decimal places with the nonzero floor, USD in
parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places with
the nonzero floor, USD in parentheses), with the gas limit and the fee per
gas in gwei below it
- Network and nonce
- Raw data: full calldata displayed inline (shown if present)
- Password input and an error line
@@ -2958,56 +2998,215 @@ Currently supported:
"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
Router addresses Uniswap publishes for the network: on TxApproval the network
the transaction is for, on TransactionDetail the active network. The same
calldata sent to any other address is shown as raw data. Decodes Permit2,
the transaction is for, on TransactionDetail the network it was sent on. The
same calldata sent to any other address is shown as raw data. Decodes Permit2,
V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap action is read only
in the parameter shape deployed on mainnet and Sepolia, not in the newer one
on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`. Each step is named by its command type,
which the decoder reads from the command byte with the mask the router at that
address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag
(`0x80`) therefore does not change a step's name. The decoder does not read
the commands inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that
step stands they may spend the user's tokens or pay the user. That step is
listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other
steps state, the summary names no swap direction, `Token In` and `Token Out`
read `Unknown (could not be decoded)`, and there is no `Amount` or
`Min. received` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
V4 `TAKE` 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. When a swap step or `TAKE` pays the
router the swap's output token, the output stays in the router unless a later
`SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out.
A swap step or `TAKE` 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. 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), no step pays any other address, no step that
may pay an address went unread, and the output does not stay in the router.
Otherwise it reads `Min. paid out`, a `Paid to` line after `Token Out` names
each other address paid, one reads
`The router, where it stays (no later step pays it out)` when the output stays
in the router, and one more reads `Unknown (could not be decoded)` when such a
step went unread, or `Unknown (not named in the calldata)` when no step pays
anyone. A step goes unread when its recipient does not decode as an address,
or when it is a command or V4 action the decoder does not read: every command
other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and
`EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`.
`V4 Swap (could not be decoded)`. Each step is named and read as the command
that the release of the router at that address runs for its command type, as
that release's `contracts/libraries/Commands.sol` names it. The decoder reads
the type from the command byte with the mask that router uses: `0x1f` for
`UniversalRouterV1`, `0x3f` for `UniversalRouterV1_2_V2Support` and
`UniversalRouterV2`, and `0x7f` for `UniversalRouterV2_1_1` and
`UniversalRouterV2_1_2`. The allow-revert flag (`0x80`) therefore does not
change the command a step is read as. Releases run different commands for some
types: on `UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` to
`0x13` are NFT commands, where the later routers run `V4_SWAP`,
`V3_POSITION_MANAGER_PERMIT`, `V3_POSITION_MANAGER_CALL` and
`V4_INITIALIZE_POOL`. The decoder does not read those routers' NFT commands,
and lists each by its name there, for example `Seaport` for `SEAPORT` (`0x10`
on `UniversalRouterV1`). A type a release names no command for is listed by
its number, for example `Command 0x0e` on `UniversalRouterV1`, which has no
`BALANCE_CHECK_ERC20` and reverts on it. The router reverts the whole
transaction when a step fails, unless the step's command byte has that flag
set and its command is one the router lets fail, as each release's
`contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`,
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`, `PERMIT2_PERMIT`,
`PERMIT2_PERMIT_BATCH`, `BALANCE_CHECK_ERC20`, `V3_POSITION_MANAGER_PERMIT`,
`V3_POSITION_MANAGER_CALL`, `V4_INITIALIZE_POOL`, `V4_POSITION_MANAGER_CALL`
and `EXECUTE_SUB_PLAN`; on `UniversalRouterV1_2_V2Support`,
`BALANCE_CHECK_ERC20`, `EXECUTE_SUB_PLAN` and its NFT market calls and NFT
owner checks; on `UniversalRouterV1`, its NFT market calls and NFT owner
checks. Such a step may then fail while the transaction goes on to the next
step, and it is listed with ` (allowed to fail)` after its name, for example
`Balance Check (allowed to fail)`. On every other command, among them each V2,
V3 and V4 swap and each payment of ETH or an ERC-20 token, the flag has no
effect: the step succeeds or the whole transaction reverts, so the step reads
as it does without the flag. A `BALANCE_CHECK_ERC20` the router lets fail
guarantees nothing, so it is read as one whose `minBalance` is zero. Only the
routers with the `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`),
listed as `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`),
listed as `Across Bridge Deposit`; the other routers revert on `0x07`, and it
is listed there as `Command 0x07`. The decoder does not read the commands
inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they
may spend the user's tokens or pay the user. That step is listed as
`Execute Sub-Plan (its steps are not shown)`, and whatever the other steps
state, the summary names no swap direction, `Token In` and `Token Out` read
`Unknown (could not be decoded)`, and there is no `Amount` or `Min. received`
line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `PERMIT2_TRANSFER_FROM` (which
moves the user's own tokens) and V4 `TAKE` and `TAKE_PORTION` names the
address it pays, and `TAKE_ALL` pays the transaction's sender. The router
reads `address(1)` as the transaction's sender, and `address(2)` as the router
itself, which holds what it is paid until a later step pays it out; a payment
to the router, as `address(2)` or by its own address, is not counted as paid
out. `UniversalRouterV1` alone pays a `PERMIT2_TRANSFER_FROM` to `address(1)`
or `address(2)` as those addresses, and the decoder names them. A
`PERMIT2_TRANSFER_FROM` into the router is named with the amount it moves,
because the decoder does not check that later steps spend all of it.
`ACROSS_V4_DEPOSIT_V3` pays the bridge, which pays the deposit's recipient on
another network and refunds a deposit nobody fills to its depositor; the
router passes both on as written, so `address(1)` and `address(2)` there are
addresses, and each that is not the transaction's sender is named. A
`BALANCE_CHECK_ERC20` pays nobody: the router checks that its owner holds at
least `minBalance` of its token, and reads the owner as written, so
`address(1)` and `address(2)` there are addresses too. A check of any address
but the transaction's sender's says nothing about what the user receives: it
sets neither `Token Out` nor the minimum, and it is listed as
`Balance Check (of another address)`. A check whose input does not decode,
such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router 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 swap step the `amountIn` of the swap action that
spends its input currency. 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. So does every V4 step but the one that sets the input side,
with a swap action or without: it takes what its `SETTLE`s paid by the user
(`payerIsUser` true) and its `SETTLE_ALL`s pay, for each token on its own. 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. 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, other
than the one that sets the input side, 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
+14 -6
View File
@@ -697,9 +697,10 @@
id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<!-- Shown when Sign & Send refuses a send for its network or
its fee. Each holds its space from the first paint, so the
refusal moves nothing. -->
<!-- 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"
@@ -708,13 +709,20 @@
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"
>
This transaction would allow a network fee of more than this
wallet will sign for, so it was not sent. Please go back and
try again.
The gas limit or fee the RPC endpoint quoted for this
transaction is more than this wallet will sign for, so it
was not sent. Please go back and try again.
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
+18 -5
View File
@@ -127,28 +127,33 @@ function isRenderableDecoded(value) {
}
// The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which
// show() fills in when it is absent, picks the network whose explorer the
// address links open, so it has to name one.
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
// truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in
// when it is absent, picks the network whose explorer the address links open,
// so it has to name one.
function isRenderablePendingTx(value) {
return (
isRecord(value) &&
isText(value.token) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
isText(value.amount) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
);
}
// The stored transaction transactionDetail.render() shows. contractAddress is
// 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) {
return (
isRecord(value) &&
isAddressText(value.from) &&
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 (!isAddressText(data.to)) 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();
return true;
case "error-tx":
if (!data.message) return false;
if (!isAddressText(data.to)) return false;
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
return false;
}
views.txStatus.renderError();
return true;
default:
+12 -14
View File
@@ -11,6 +11,7 @@ const {
onViewLeave,
formatFee,
tokenLabel,
tokenSourcesByChainId,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const {
@@ -85,13 +86,12 @@ function tokenAmountText(rawAmount, decimals, symbol) {
function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Where a token's scale is looked for, for every decoder below: the ERC-20
// amount line and the swap's Amount and Min. received lines resolve it the
// same way, and refuse to format the same way when it is nowhere.
const decimalsSources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Where a token's name and scale are looked for, for every decoder below:
// what the wallet knows of the tokens on the network with `chainId`, which
// need not be the active one. The ERC-20 amount line and the swap's Amount
// and Min. received lines resolve the scale the same way, and refuse to
// format the same way when it is nowhere.
const decimalsSources = tokenSourcesByChainId(chainId);
// Try ERC-20 (approve / transfer)
try {
@@ -261,10 +261,7 @@ function showTxApproval(details) {
const toAddr = approvedTx.to;
const ethValue = formatEther(approvedTx.value || "0");
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
const sources = tokenSourcesByChainId(approvedTx.chainId);
// Build txInfo for status screens
pendingTxDetails = {
@@ -344,8 +341,9 @@ function showTxApproval(details) {
$("approve-tx-origin").textContent = details.origin;
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
// Show token symbol next to contract address if known
const symbol = toAddr ? tokenLabel(toAddr) : null;
// Show token symbol next to contract address if known on the network the
// transaction is for
const symbol = toAddr ? tokenLabel(toAddr, approvedTx.chainId) : null;
if (toAddr) {
let toHtml = "";
if (symbol) {
@@ -388,7 +386,7 @@ function showTxApproval(details) {
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) {
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);
} else {
+58 -46
View File
@@ -23,12 +23,10 @@ const {
goBack,
onViewLeave,
formatFee,
reviewedNetworkProvider,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
@@ -45,8 +43,8 @@ const {
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The balance line and the fee reserve go through it, as the approval screen's
// amounts do.
// The amount, the balance line and the fee reserve go through it, as the
// approval screen's amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
@@ -100,8 +98,32 @@ function valueWithUsd(text, usdAmount) {
return text;
}
// The Amount line, with its USD value. A max ETH send's line is drawn again
// once its amount is re-derived from the fee estimate.
// The amount as this screen and the wait, success and error screens show it,
// written as the approval screen writes a site's amount: the amount being
// signed, parsed at the scale it is encoded with and written back as plain
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
// `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is
// unknown, which cannot be sent: its amount is cut as typed, so `.5` reads
// `.5000`. txInfo.amount, which is what gets signed, is left as it is.
function shownAmount(txInfo) {
let exact = txInfo.amount;
try {
if (txInfo.token === "ETH") {
exact = formatEther(parseEther(txInfo.amount));
} else {
const decimals = displayedDecimals(txInfo.tokenDecimals);
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
}
} catch {
// A token whose scale is unknown, which is never signed, or an amount
// the amount check refuses: cut as Send's amount field held it.
}
return truncateAmountNeverZero(exact);
}
// The Amount line, with its USD value, which is priced from the exact amount.
// A max ETH send's line is drawn again once its amount is re-derived from the
// fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20
@@ -110,7 +132,7 @@ function renderAmount(txInfo) {
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol),
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
amountUsd,
);
}
@@ -296,6 +318,7 @@ function show(txInfo) {
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
// Gas estimate — show placeholder then fetch async
@@ -433,20 +456,6 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// The node of the network this transaction was reviewed for, which need not be
// the active one: the popup can reopen onto this screen after Settings switched
// the network. Its RPC URL is the one Settings has for that network: the
// active network's is state.rpcUrl, any other's the one remembered for it (see
// applyChainSwitchFields()).
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
async function estimateGas(txInfo) {
try {
const provider = reviewedNetworkProvider(txInfo);
@@ -559,16 +568,17 @@ async function estimateGas(txInfo) {
}
}
// Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. Apart
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
// what the configured RPC node answers, with nothing otherwise bounding what a
// hostile node can set — the dApp path's ceilings never reached this one.
// Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, and the caller then
// shows the refusal reserved for it rather than sending.
// Populate the transaction this send describes, enforce the bounds against the
// gas limit and fees that were actually filled in, then sign and broadcast it.
// Apart from a max ETH send, which passes its estimate's fee fields as `fees`,
// the send pins no fee fields, so ethers fills maxFeePerGas and the gas limit
// from what the configured RPC node answers, with nothing otherwise bounding
// what a hostile node can set — the dApp path's ceilings never reached this
// one. Populating before the check is what makes assertWithinCeilings() see
// the same numbers that would be signed; it throws an ApprovalMismatchError
// when the gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
// over its bound, and the caller then shows the one refusal reserved for all
// three rather than sending.
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request;
if (tx.token === "ETH") {
@@ -636,6 +646,7 @@ function init(_ctx) {
$("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];
@@ -661,11 +672,7 @@ function init(_ctx) {
// address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) {
showError(
"confirm-tx-password-error",
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
setVisible("confirm-from-address-error", true);
return;
}
@@ -697,6 +704,10 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
// The wait, success and error screens show the amount as this screen
// does, so they are handed a copy carrying that text. pendingTx keeps
// the exact amount, which is the one signed.
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
let tx;
try {
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
@@ -713,22 +724,23 @@ function init(_ctx) {
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decryptedSecret = null;
txStatus.showWait(pendingTx, tx.hash);
txStatus.showWait(shownTx, tx.hash);
} catch (e) {
decryptedSecret = null;
// A fee over the bound is refused before anything is broadcast, so
// there is no transaction that may have reached the network to warn
// about: the refusal stays on the confirmation screen where the
// user can go back, rather than routing to the sent/failed screen.
// Its sentence is fixed in index.html, so that its box can hold its
// space from the first paint; the reason in full is logged.
// A gas limit or fee over its bound is refused before anything is
// broadcast, so there is no transaction that may have reached the
// network to warn about: the refusal stays on the confirmation
// screen where the user can go back, rather than routing to the
// sent/failed screen. Its sentence is fixed in index.html, so that
// its box can hold its space from the first paint; the reason in
// full, naming the bound, is logged.
if (e && e.approvalMismatch) {
log.errorf("send refused:", e.message);
setVisible("confirm-fee-bound-error", true);
return;
}
const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
txStatus.showError(shownTx, hash, e.shortMessage || e.message);
} finally {
$("btn-confirm-send").disabled = false;
$("btn-confirm-send").classList.remove("text-muted");
+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
// markup by concatenation: any VALUE interpolated into an innerHTML string
@@ -19,6 +22,7 @@ const {
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
@@ -128,7 +132,8 @@ function showView(name) {
}
// 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 user has enabled runtime debug mode via the settings easter egg, OR
// when the active network is a testnet.
@@ -295,15 +300,30 @@ function nativeCurrency() {
return currentNetwork().nativeCurrency;
}
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
// What the wallet knows of the tokens on the network with this chain id, or
// on the active network when no chain id is given, as the `sources` that
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address
// names a token on one network only, and every token balance held is the
// active network's, so another network gets the tokens tracked on it
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
function tokenSourcesByChainId(chainId) {
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:
@@ -606,6 +626,30 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</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
// 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
@@ -746,6 +790,7 @@ module.exports = {
keyMaterialName,
unknownableAmount,
nativeCurrency,
tokenSourcesByChainId,
tokenLabel,
formatFee,
addressDotHtml,
@@ -761,6 +806,8 @@ module.exports = {
attachCopyHandlers,
etherscanLinkHtml,
explorerUrl,
endpointsByChainId,
reviewedNetworkProvider,
EXT_ICON,
truncateMiddle,
isoDate,
+3 -1
View File
@@ -343,11 +343,13 @@ function init(ctx) {
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
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");
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
updateDebugBanner(state.currentView);
showFlash("Switched to " + net.name + ".");
});
+32 -19
View File
@@ -18,10 +18,11 @@ const {
copyableHtml,
etherscanLinkHtml,
explorerUrl,
endpointsByChainId,
displaySymbol,
goBack,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log");
@@ -48,16 +49,16 @@ function getTransactionType(tx) {
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
}
function txAddressHtml(address, ensName, title) {
function txAddressHtml(address, ensName, title, chainId) {
const blockie = blockieHtml(address);
return (
`<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName })
renderAddressHtml(address, { title, ensName, chainId })
);
}
function txHashHtml(hash) {
const link = explorerUrl("tx", hash);
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink;
}
@@ -83,10 +84,11 @@ function show(tx) {
method: tx.method || null,
contractAddress: tx.contractAddress || null,
// 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 active network can change, in Settings or when the user
// approves a site's request, before a later popup shows this
// screen again.
// the fee are in its native currency, not the active network's,
// the links open its explorer, and the block, network details and
// calldata are read from its Blockscout: the active network can
// change, in Settings or when the user approves a site's request,
// before a later popup shows this screen again.
chainId: tx.chainId,
},
};
@@ -96,18 +98,24 @@ function show(tx) {
function render() {
const tx = state.viewData.tx;
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);
$("tx-detail-from").innerHTML = txAddressHtml(
tx.from,
tx.fromEns,
fromTitle,
tx.chainId,
);
// A contract creation has no recipient: transactions.js gives it `to: ""`.
if (tx.to) {
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
$("tx-detail-to").innerHTML = txAddressHtml(
tx.to,
tx.toEns,
toTitle,
tx.chainId,
);
} else {
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
@@ -146,7 +154,7 @@ function render() {
if (tokenContractSection && tokenContractEl) {
if (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
// link goes to the explorer's /token/ page, not /address/.
// 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) {
// Block number
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 blockEl = $("tx-detail-block");
if (blockSection && blockEl) {
@@ -301,8 +313,9 @@ async function loadFullTxDetails(txHash, chainId) {
if (!section || !actionEl || !detailsEl) return;
try {
const { blockscoutUrl } = endpointsByChainId(chainId);
const resp = await debugFetch(
state.blockscoutUrl + "/transactions/" + txHash,
blockscoutUrl + "/transactions/" + txHash,
);
if (!resp.ok) return;
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
// explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract.
// On the active network, the one whose explorer was just asked, and
// from the sender it reports.
// On the network the transaction was sent on, whose Blockscout was
// just asked, and from the sender it reports.
const decoded = decodeCalldata(
inputData,
txData.to?.hash || "",
currentNetwork().chainId,
chainId,
txData.from?.hash || "",
);
if (decoded) {
@@ -340,9 +353,9 @@ async function loadFullTxDetails(txHash, chainId) {
if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
}
detailsHtml += renderAddressHtml(d.address);
detailsHtml += renderAddressHtml(d.address, { chainId });
} else if (d.address) {
detailsHtml += renderAddressHtml(d.address);
detailsHtml += renderAddressHtml(d.address, { chainId });
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
+39 -29
View File
@@ -14,10 +14,13 @@ const {
displaySymbol,
clearViewStack,
tokenLabel,
reviewedNetworkProvider,
} = require("./helpers");
const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances");
const {
SUPPORTED_CHAIN_IDS,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { log } = require("../../shared/log");
// 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
// writes `to: toAddr || ""`).
function toAddressHtml(address) {
// writes `to: toAddr || ""`). The links below open the explorer of the network
// with `chainId`, or of the active network when none is given.
function toAddressHtml(address, chainId) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title });
return renderAddressHtml(address, { title, chainId });
}
function txHashHtml(hash) {
const link = explorerUrl("tx", hash);
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
}
function blockNumberHtml(blockNumber) {
function blockNumberHtml(blockNumber, chainId) {
const num = String(blockNumber);
const link = explorerUrl("block", num);
const link = explorerUrl("block", num, chainId);
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
// 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
// is open or before a later popup resumes it. 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.
// is open or before a later popup resumes it. The links on all three
// screens open that network's explorer, and this screen's receipt is
// 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 = "";
if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId);
@@ -102,8 +108,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx");
// Persisted so closing and reopening the popup resumes this wait
@@ -128,7 +134,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
renderElapsed();
}, 1000);
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = reviewedNetworkProvider(txInfo);
let consecutiveFailures = 0;
async function poll() {
@@ -207,10 +213,11 @@ function showWait(txInfo, txHash) {
// an object merely missing one of them throws a TypeError out of
// 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
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
// ETH.
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
// the node the receipt is asked of and the explorer the links open, so it has
// to name a network. txInfo.token and txInfo.tokenSymbol are deliberately
// unchecked: they are compared and coalesced rather than dereferenced, and
// tokenSymbol is null for ETH.
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -226,6 +233,7 @@ function restoreWait() {
// path itself created.
if (typeof info.to !== "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)) {
return false;
}
@@ -250,12 +258,13 @@ function showSuccess(txInfo, txHash, blockNumber) {
hash: txHash,
blockNumber: blockNumber,
decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
};
renderSuccess();
ctx.doRefreshAndRender();
}
function decodedDetailsHtml(decoded) {
function decodedDetailsHtml(decoded, chainId) {
if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
if (decoded.name) {
@@ -271,11 +280,11 @@ function decodedDetailsHtml(decoded) {
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) {
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 += toAddressHtml(d.address);
html += toAddressHtml(d.address, chainId);
} else {
html += toAddressHtml(d.address);
html += toAddressHtml(d.address, chainId);
}
} else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
@@ -304,16 +313,16 @@ function renderSuccess() {
toSection.classList.remove("hidden");
$("success-tx-summary").textContent =
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-hash").innerHTML = txHashHtml(d.hash);
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId);
$("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
// Show decoded calldata details if present
const decodedEl = $("success-tx-decoded");
if (decodedEl && hasDecoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId);
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
@@ -339,6 +348,7 @@ function showError(txInfo, txHash, message) {
to: txInfo.to,
hash: txHash || null,
message: message,
chainId: txInfo.chainId,
};
renderError();
}
@@ -348,11 +358,11 @@ function renderError() {
if (!d || !d.message) return;
$("error-tx-summary").textContent =
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;
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");
attachCopyHandlers("view-error-tx");
} else {
+1071 -174
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);
});
// The refusal's sentence is fixed in index.html, in a box of its own that
// holds its space from the first paint, so showing it moves nothing.
test("nothing is sent when the selected wallet does not hold it", async () => {
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
expect(node("confirm-tx-password-error").textContent).toBe(
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
expect(node("confirm-from-address-error").style.visibility).toBe(
"visible",
);
expect(node("confirm-tx-password-error").textContent).toBe("");
// Reviewing a send again draws the screen again, which hides it.
confirmTx.show({
from: derived(0).address,
to: RECIPIENT,
amount: "0.5",
token: "ETH",
});
await settle();
expect(node("confirm-from-address-error").style.visibility).toBe(
"hidden",
);
});
});
+220
View File
@@ -0,0 +1,220 @@
// The approval screen for a site's Sepolia token transfer while Ethereum
// Mainnet is active (https://git.eeqj.de/sneak/AutistMask/issues/629).
//
// The screen named the token, and scaled its amount, from the active network's
// tracked tokens and balances, so a token the wallet tracks on Sepolia read as
// an unknown token with its amount in base units, and the screens after it
// labelled the amount `?`. It now uses what the wallet knows of the tokens on
// the network the transaction is for.
//
// Driven through the real approval screen, password and Confirm included,
// against a minimal DOM stub in the shape tests/swapStatusAmount.test.js uses.
// The vault is mocked so that no password has to be hashed.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { decryptWithPassword } = require("../src/shared/vault");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
// A well-known test phrase, and its first address.
const PHRASE = "test test test test test test test test test test test junk";
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the bundled token list does not name.
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
// FROM sends 1234.5 of the 6-decimal token to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("approve-tx-decoded-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
// Open the approval screen, with Ethereum Mainnet active and FROM holding
// `tokenBalances` there, for the site's Sepolia transaction sending
// TRANSFER_DATA to the token. Confirm answers the signed transaction with a
// failure, which leads to the error screen.
async function showSepoliaTransfer(tokenBalances = []) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
return reply({
error: "The transaction could not be sent.",
retryable: false,
});
}
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId: NETWORKS.sepolia.chainId,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: TOKEN,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
},
};
state.networkId = "mainnet";
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances },
],
},
];
state.currentView = null;
txStatus.init({ doRefreshAndRender: () => {} });
approval.init({});
await approval.show(1);
}
async function confirm() {
decryptWithPassword.mockResolvedValue(PHRASE);
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
}
beforeEach(() => {
state.trackedTokens = [];
state.networkTrackedTokens = {};
});
describe("a token tracked on Sepolia only", () => {
beforeEach(() => {
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
});
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
await showSepoliaTransfer();
expect(node("approve-tx-to").innerHTML).toContain(
'<div class="font-bold mb-1">TDOL</div>',
);
expect(decodedLine("Token")).toContain(
'<div class="font-bold">TDOL</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
test("labels the amount on the error screen that follows", async () => {
await showSepoliaTransfer();
await confirm();
expect(state.currentView).toBe("error-tx");
expect(node("error-tx-summary").textContent).toBe("1234.5000 TDOL");
});
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("a token tracked and held on mainnet only is not named, and its amount is in base units", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
await showSepoliaTransfer([
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
]);
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
expect(decodedLine("Token")).toContain(
'<div class="font-bold">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
+8 -1
View File
@@ -154,7 +154,14 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], {
viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } },
viewData: {
tx: {
hash: "0xdead",
from: ADDRESS,
to: ADDRESS,
chainId: "0x1",
},
},
});
goBack();
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
// one: populateTransaction() returns the fees the node would have produced,
// and sendTransaction() records whether the send actually happened. The real
// DOM path around it — the refusal shown in the box reserved for it — is
// covered by the Chrome e2e suite.
// and sendTransaction() records whether the send actually happened.
//
// The last test drives the real confirmation screen instead, with only the
// node and the DOM stubbed: a gas limit over its own bound at an ordinary fee
// is refused in the same box as a fee over the bound
// (https://git.eeqj.de/sneak/AutistMask/issues/583). That the box moves
// nothing on the screen is checked by the Chrome e2e suite.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
// What the stub node quotes for that test: a gas limit of 150,000,000 at a
// maximum fee of 2 gwei per gas, 0.3 ETH in all. And the signed transactions
// it was handed.
const mockNode = {
gasLimit: 150000000n,
maxFeePerGas: 2000000000n,
broadcast: [],
};
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.network = network;
}
async getNetwork() {
return this.network;
}
async getBalance() {
return actual.parseEther("5");
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getFeeData() {
return {
maxFeePerGas: mockNode.maxFeePerGas,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 1000000000n,
};
}
async estimateGas() {
return mockNode.gasLimit;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
// The screens a send that was not refused goes on to.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
querySelectorAll: () => [],
remove() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
};
const { Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const { populateVerifyAndSend } = confirmTx;
const {
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify");
@@ -98,3 +193,69 @@ describe("populateVerifyAndSend enforces the combined fee bound", () => {
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
});
});
describe("Sign & Send on the confirmation screen", () => {
const PRIVATE_KEY = "0x" + "11".repeat(32);
const PASSWORD = "correct horse battery staple";
let encryptedSecret;
beforeAll(async () => {
await loadState();
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
function el(id) {
return global.document.getElementById(id);
}
function shown(id) {
return el(id).style.visibility === "visible";
}
// show() reads the balance and estimates the fee without awaiting
// either; this lets both finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("refuses a gas limit over its bound at an ordinary fee in the box for a refused gas limit or fee, and sends nothing", async () => {
expect(mockNode.gasLimit).toBeGreaterThan(MAX_GAS_LIMIT);
expect(mockNode.gasLimit * mockNode.maxFeePerGas).toBeLessThan(
MAX_TOTAL_FEE,
);
const from = new Wallet(PRIVATE_KEY).address;
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: from }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.init({});
confirmTx.show({
from,
to: RECIPIENT,
ensName: null,
amount: "1",
token: "ETH",
tokenSymbol: null,
tokenDecimals: null,
max: false,
});
await settle();
expect(el("btn-confirm-send").disabled).toBe(false);
expect(shown("confirm-fee-bound-error")).toBe(false);
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
expect(shown("confirm-fee-bound-error")).toBe(true);
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -333,7 +333,7 @@ function expectNativeTransferOn(networkId) {
"Native " + symbol + " transfer",
);
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(
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
@@ -649,7 +649,7 @@ describe.each([
`href="${explorerUrl}/address/${TOKEN}"`,
);
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(
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
+1
View File
@@ -192,6 +192,7 @@ describe("the wait, success and error screens", () => {
amount: "0.0000",
token: "ETH",
tokenSymbol: null,
chainId: "0x1",
};
const transfer = { ...creation, to: RECIPIENT };
+187 -7
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)
// The popup size README documents the UI as designed for. Pages in this
@@ -2915,9 +2974,10 @@ async function goToTokenConfirm(env) {
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
const shown = await shownAmount(env.page);
assert(
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
shown === "0.2500 " + STUB_TOKEN.symbol,
"the confirmation screen is not showing the amount that was entered: " +
JSON.stringify(shown),
);
@@ -3070,9 +3130,10 @@ 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 fee is over
// the bound, and each refusal's sentence sits in a box that held its space from
// the first paint. This drives the fee refusal, the longer of the two.
// 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
@@ -3128,9 +3189,9 @@ test("a send refused for its fee leaves the password field and Sign & Send where
);
assert(
after.feeBoundError ===
"This transaction would allow a network fee of more than" +
" this wallet will sign for, so it was not sent. Please" +
" go back and try again.",
"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(
@@ -3151,6 +3212,125 @@ test("a send refused for its fee leaves the password field and Sign & Send where
}
});
// --------- a send from an address the selected wallet does not hold (#609)
//
// The selection is saved apart from the confirmation screen, so the popup can
// reopen onto the screen with a wallet selected that does not hold its From
// address, and Sign & Send then refuses the send. Another popup selecting the
// key wallet would save that selection; here it is written into storage, and
// the popup reopened.
test("a send from an address the selected wallet does not hold leaves the password field and Sign & Send where they were (#609)", async (env) => {
try {
// From the HD wallet's address.
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
// The screen's own save lands first, so it cannot write over the
// selection below.
await waitForPersisted(
env.page,
"currentView",
"confirm-tx",
"before selecting the key wallet",
);
await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
const record = {
...r.autistmask,
selectedWallet: 1,
selectedAddress: 0,
};
chrome.storage.local.set(
{ autistmask: record },
resolve,
);
});
}),
);
await reopenPopup(env, "confirm-tx");
await waitForEstimate(env.page);
// The refusal shown anywhere on the screen, not only in its own box:
// shown on the password error line instead, the test fails on the
// moves checked below rather than on the wait for it.
const refusal = env.page
.locator("#view-confirm-tx")
.getByText("does not hold the From address above")
.filter({ visible: true });
const before = await confirmState(env.page);
assert(
!before.sendDisabled && (await refusal.count()) === 0,
"the screen is not ready to send: " + JSON.stringify(before),
);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await refusal.waitFor({ timeout: 60000 });
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused From address: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send from an address the selected wallet does not hold" +
" reached the RPC",
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
// Only now, where the refusal is shown. Its sentence is wrapped across
// lines in index.html.
const shownIn = await refusal.evaluate((el) => ({
id: el.id,
text: el.textContent.replace(/\s+/g, " ").trim(),
}));
assert(
shownIn.id === "confirm-from-address-error" &&
shownIn.text ===
"The selected wallet does not hold the From address above," +
" so nothing was sent. Please go back and send again.",
"wrong From address refusal: " + JSON.stringify(shownIn),
);
} finally {
// A reopened popup restores no Send screen beneath this one, so Back
// lands on the selected address. Then Home, and the HD wallet's
// address, which the later tests send from.
if (await env.page.isVisible("#view-confirm-tx")) {
await env.page.click("#btn-confirm-back");
}
if (await env.page.isVisible("#view-send")) {
await env.page.click("#btn-send-back");
}
await visible(env.page, "#view-address");
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await openAddressDetail(env.page);
}
});
// ------------------------------ the wait for a receipt ending in error (#315)
//
// README.md (WaitTx) documents two ways the wait ends on the error screen: a
+3 -3
View File
@@ -245,7 +245,7 @@ describe.each([
test("the value", async () => {
await confirmSend("0.1");
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
expect(text("confirm-amount")).toBe("0.1 " + symbol);
expect(text("confirm-amount")).toBe("0.1000 " + symbol);
await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
});
@@ -429,10 +429,10 @@ describe.each([
await global.document
.getElementById("btn-confirm-send")
.handlers.get("click")();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
state.networkId = activeNetworkId;
txStatus.restoreWait();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
});
test("the transaction detail screen's type line and fee", async () => {
+52 -2
View File
@@ -441,6 +441,17 @@ const CONTRACT = [
views: ["error-tx"],
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`.
{
value: { tx: { hash: "0x1" } },
@@ -454,6 +465,26 @@ const CONTRACT = [
from: ADDRESS,
to: ADDRESS,
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"],
@@ -477,6 +508,19 @@ const CONTRACT = [
views: ["confirm-tx"],
restored: false,
},
// The amount line splits the amount to cut it to four decimals.
{
value: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: 42,
},
},
views: ["confirm-tx"],
restored: false,
},
// A chain id naming no network has no explorer to link to.
{
value: {
@@ -799,7 +843,13 @@ const WELL_FORMED_DATA = {
message: "boom",
to: 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: {
token: "ETH",
from: ADDRESS,
@@ -808,7 +858,7 @@ const WELL_FORMED_DATA = {
},
pendingWait: {
hash: "0x1",
txInfo: { to: ADDRESS, amount: "1" },
txInfo: { to: ADDRESS, amount: "1", chainId: "0x1" },
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 () => {
const page = await bootPopup(
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();
await pressMax();
await review();
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
expect(confirmed.amount).toBe(maxAfter(20n * GWEI));
expect(text("confirm-amount")).toBe("1.2341 ETH");
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(canSend()).toBe(true);
@@ -322,14 +323,16 @@ describe("Max on an ETH send", () => {
// Kept, the Send screen's amount would be refused for want of funds.
mockNode.feeData = fees(30n * GWEI);
await review();
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
expect(confirmed.amount).toBe(maxAfter(30n * GWEI));
expect(text("confirm-amount")).toBe("1.2339 ETH");
expect(canSend()).toBe(true);
// And falls when the screen is shown again, as on reopening the popup.
mockNode.feeData = fees(10n * GWEI);
confirmTx.restore();
await settle();
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
expect(confirmed.amount).toBe(maxAfter(10n * GWEI));
expect(text("confirm-amount")).toBe("1.2343 ETH");
expect(canSend()).toBe(true);
});
@@ -526,7 +529,8 @@ describe("Max on an ETH send", () => {
el("send-amount").handlers.get("input")();
mockNode.feeData = fees(30n * GWEI);
await review();
expect(text("confirm-amount")).toBe("0.5 ETH");
expect(confirmed.amount).toBe("0.5");
expect(text("confirm-amount")).toBe("0.5000 ETH");
});
});
@@ -540,7 +544,8 @@ describe("Max on a token send", () => {
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(confirmed.amount).toBe("1234.567890123456789012");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(canSend()).toBe(true);
});
@@ -553,7 +558,8 @@ describe("Max on a token send", () => {
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(confirmed.amount).toBe("1234.567890123456789012");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(canSend()).toBe(true);
});
+6 -1
View File
@@ -225,7 +225,12 @@ describe("a popup already open when the stored profile becomes unreadable", () =
viewData: {
pendingWait: {
hash: "0x1",
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
txInfo: {
to: ADDRESS,
amount: "1",
token: "ETH",
chainId: "0x1",
},
broadcastTime: Date.now(),
},
},
+123 -6
View File
@@ -8,6 +8,14 @@
// tracks. It decodes against the address the explorer reports the transaction
// 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,
// opened through transactionDetail.show(), which is what a click on the row
// calls. Driven against a minimal DOM stub in the shape
@@ -22,10 +30,10 @@ jest.mock("../src/shared/log", () => ({
warnf: () => {},
errorf: () => {},
},
debugFetch: async (url) => ({
ok: true,
json: async () => mockExplorerAnswer(url),
}),
debugFetch: async (url) => {
mockAsked.push(url);
return { ok: true, json: async () => mockExplorerAnswer(url) };
},
setRuntimeDebug: () => {},
isDebug: () => false,
}));
@@ -35,10 +43,13 @@ globalThis.chrome = {
};
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail");
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 RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// 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)",
]).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
// transaction itself, sent to the token contract.
function mockExplorerAnswer(url) {
@@ -77,7 +91,11 @@ function mockExplorerAnswer(url) {
};
}
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: [] };
}
@@ -140,6 +158,7 @@ beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER;
state.networkEndpoints = {};
state.wallets = [
{
name: "Main",
@@ -148,13 +167,18 @@ beforeEach(() => {
},
];
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
state.networkTrackedTokens = {};
state.viewData = {};
state.viewStack = [];
state.currentView = null;
});
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.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>',
);
});
// 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>',
);
});
+176 -3
View File
@@ -29,8 +29,13 @@ jest.mock("../src/shared/log", () => ({
}));
const mockReceiptLookup = jest.fn();
// The RPC URL and network of every node a wait was given.
const mockNodes = [];
jest.mock("../src/shared/balances", () => ({
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
getProvider: (rpcUrl, networkId) => {
mockNodes.push({ rpcUrl, networkId });
return { getTransactionReceipt: mockReceiptLookup };
},
refreshBalances: jest.fn(),
}));
@@ -107,6 +112,7 @@ global.chrome = { storage };
const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const { log } = require("../src/shared/log");
@@ -118,6 +124,7 @@ const TX_INFO = {
amount: "0.0050",
token: "ETH",
tokenSymbol: null,
chainId: NETWORKS.mainnet.chainId,
};
// True when a view element is not hidden.
@@ -137,8 +144,14 @@ beforeEach(() => {
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
elements.clear();
mockReceiptLookup.mockReset();
mockNodes.length = 0;
log.errorf.mockClear();
state.networkId = "mainnet";
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
state.networkEndpoints = {};
state.wallets = [];
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.viewData = {};
state.viewStack = [];
state.currentView = null;
@@ -349,14 +362,14 @@ describe("WaitTx persistence across popup close", () => {
// out of restoreView().
{
hash: TX_HASH,
txInfo: { to: 42, amount: "0.0050" },
txInfo: { ...TX_INFO, to: 42 },
broadcastTime: Date.now(),
},
// Otherwise complete but an array: only Array.isArray() rejects
// it, since typeof [] is "object" and the fields are present.
{
hash: TX_HASH,
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
txInfo: Object.assign([], TX_INFO),
broadcastTime: Date.now(),
},
{
@@ -364,6 +377,18 @@ describe("WaitTx persistence across popup close", () => {
txInfo: { to: RECIPIENT },
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 };
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", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills
+921 -10
View File
@@ -739,7 +739,9 @@ describe("uniswap decoder", () => {
});
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
// UNWRAP_WETH makes the output ETH.
// UNWRAP_WETH makes the output ETH. That step is paid by the user, so the
// USDC the exact-out step bought stays in the router, and the minimum is
// not received (https://git.eeqj.de/sneak/AutistMask/issues/606).
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute(
solidityPacked(
@@ -767,7 +769,7 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
expect(detail(result, "Min. paid out").value).toBe("0.4000 ETH");
});
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
@@ -954,7 +956,28 @@ describe("uniswap decoder", () => {
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x40",
steps: "Command 0x40",
steps: "Across Bridge Deposit",
},
// Only the routers with the 0x7f mask have 0x07, as
// PAY_PORTION_FULL_PRECISION; the others revert on it
// (https://git.eeqj.de/sneak/AutistMask/issues/599).
{
router: "UniversalRouterV1",
address: ROUTER_V1_ADDR,
command: "0x07",
steps: "Command 0x07",
},
{
router: "UniversalRouterV2",
address: ROUTER_ADDR,
command: "0x07",
steps: "Command 0x07",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x07",
steps: "Pay Portion (Full Precision)",
},
])(
"command $command sent to $router is listed as $steps",
@@ -966,13 +989,23 @@ describe("uniswap decoder", () => {
);
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
// not read. With the allow-revert flag (0x80) the command byte is 0xa1.
// not read. With the allow-revert flag (0x80) the command byte is 0xa1,
// and the router lets the step fail
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
test.each([
{ flag: "without", command: 0x21 },
{ flag: "with", command: 0xa1 },
{
flag: "without",
command: 0x21,
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
flag: "with",
command: 0xa1,
steps: "Execute Sub-Plan (its steps are not shown) (allowed to fail)",
},
])(
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
({ command }) => {
({ command, steps }) => {
const subPlan = encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR,
@@ -992,9 +1025,7 @@ describe("uniswap decoder", () => {
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)",
);
expect(detail(result, "Steps").value).toBe(steps);
// The sub-plan names both currencies, so the lines must not say
// the calldata did not.
expect(detail(result, "Token In").value).toBe(
@@ -1258,6 +1289,886 @@ describe("uniswap decoder", () => {
expect(detail(result, "Amount").value).not.toContain("500000000000");
});
// https://git.eeqj.de/sneak/AutistMask/issues/625 — a route split across
// two swap steps the user pays for, with no PERMIT2_PERMIT, takes both
// steps' amounts from the wallet. On next at 671b4d3 the Amount line read
// only the first step's 600.0000 USDT.
describe("a swap split across steps the user pays for", () => {
const USDT_600 = 600000000n;
const USDT_400 = 400000000n;
const DAI_400 = 400000000000000000000n;
const OTHER_ADDR = "0x1111111111111111111111111111111111111111";
const UNDECODED = "Unknown (could not be decoded)";
// The lines between Token In and Token Out, as [label, value].
function linesBetweenTokens(result) {
const lines = result.details.map((d) => [d.label, d.value]);
const from = lines.findIndex(([label]) => label === "Token In");
const to = lines.findIndex(([label]) => label === "Token Out");
return lines.slice(from + 1, to);
}
// execute() of these command bytes and inputs, decoded as sent by
// the user.
function decodeSteps(commands, inputs) {
const data = buildExecute(
solidityPacked(
commands.map(() => "uint8"),
commands,
),
inputs,
9999999999n,
);
return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
}
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the
// user, which sets the input side.
const V3_600 = encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]);
// V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of
// `currency` to `recipient`.
const settle = (currency, amount, payerIsUser) =>
coder.encode(
["address", "uint256", "bool"],
[currency, amount, payerIsUser],
);
const take = (currency, recipient, amount) =>
coder.encode(
["address", "address", "uint256"],
[currency, recipient, amount],
);
// `input` with the top byte of its word at `index` set. This decoder
// cannot read such a word as an address or a length; the router reads
// its low bytes and runs the step.
const dirtyWord = (input, index) => {
const at = 2 + index * 64;
return input.slice(0, at) + "ff" + input.slice(at + 2);
};
test("a split across a V3 and a V2 step the user pays for shows the total", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Token In").address.toLowerCase()).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Amount").rawValue).toBe("1000.0000");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
// In the order of Uniswap's own V4 routes: the swap, then a SETTLE of
// OPEN_DELTA by the user, which pays what the swap spends.
test("a split across a V3 step and a V4 step the user pays for shows the total", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_400,
390000000n,
),
encodeV4Settle(USDT_ADDR), // paid by the user
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
test("a second step the user pays for that spends another token shows that token's amount", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(
USER_ADDR,
400000000000000000000n, // 400 DAI
390000000n,
[DAI_ADDR, USDC_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
]);
});
// Every later V4 step takes from the sender what its SETTLEs paid by
// the user and its SETTLE_ALLs pay, each currency on its own, with a
// swap action or without. Each of these read 600.0000 USDT alone
// before.
const USDT_FOR_USDC_400 = encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_400,
390000000n,
);
const DAI_FOR_USDC_400 = encodeV4ExactInSingle(
DAI_ADDR,
USDC_ADDR,
DAI_400,
390000000n,
);
const DAI_GROUP = [
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
test.each([
{
kind: "a V4 step paying USDT and DAI by a SETTLE_ALL of each",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
DAI_FOR_USDC_400,
encodeV4SettleAll(USDT_ADDR, USDT_400),
encodeV4SettleAll(DAI_ADDR, DAI_400),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "1000.0000 USDT"], ...DAI_GROUP],
},
{
kind: "a V4 step with a SETTLE of USDT by the user, then a SETTLE of WETH from the router",
actions: [
V4_SETTLE_ID,
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ALL_ID,
],
params: [
settle(USDT_ADDR, USDT_400, true),
settle(WETH_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE
USDT_FOR_USDC_400,
encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, 0n, 1n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "1000.0000 USDT"]],
},
{
kind: "a V4 step with no swap action: a SETTLE of 400 USDT by the user and a TAKE of it to another address",
actions: [V4_SETTLE_ID, V4_TAKE_ID],
params: [
settle(USDT_ADDR, USDT_400, true),
take(USDT_ADDR, OTHER_ADDR, USDT_400),
],
lines: [["Amount", "1000.0000 USDT"]],
},
// The SETTLE's amount, not the swap's, is what the user pays.
{
kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address",
actions: [
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
V4_TAKE_ALL_ID,
],
params: [
settle(USDT_ADDR, 5000000000n, true),
USDT_FOR_USDC_400,
take(USDT_ADDR, OTHER_ADDR, 0n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "5600.0000 USDT"]],
},
// A SETTLE_ALL pays all the step owes, here what the TAKE takes as
// well as what the swap spends, so its amount is not known.
{
kind: "a V4 step with no swap action: a TAKE of 400 USDT to another address, then a SETTLE_ALL of USDT",
actions: [V4_TAKE_ID, V4_SETTLE_ALL_ID],
params: [
take(USDT_ADDR, OTHER_ADDR, USDT_400),
encodeV4SettleAll(USDT_ADDR, USDT_400),
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
],
},
{
kind: "a V4 step swapping 400 USDT, with a TAKE of 400 USDT to another address before its SETTLE_ALL of USDT",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
take(USDT_ADDR, OTHER_ADDR, USDT_400),
encodeV4SettleAll(USDT_ADDR, 800000000n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
],
},
])(
"a V3 step of 600 USDT, then $kind",
({ actions, params, lines }) => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// A step that may take from the sender what the decoder did not read
// leaves no Amount figure, which may be too low, and one line says so.
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
test.each([
{
kind: "a V4 step whose SETTLE by the user cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
dirtyWord(settle(USDT_ADDR, USDT_400, true), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
{
kind: "a V4 step whose SETTLE_ALL cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
dirtyWord(encodeV4SettleAll(USDT_ADDR, USDT_400), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
{
kind: "a V4 step whose swap action and SETTLE_ALL cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
UNREAD_SWAP,
dirtyWord(encodeV4SettleAll(WETH_ADDR, ONE_WETH), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
])("a V3 step of 600 USDT, then $kind", ({ actions, params }) => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
});
test.each([
{
kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
commands: [0x00, 0x10],
inputs: [
V3_600,
dirtyWord(
encodeV4Swap(new Uint8Array([V4_SETTLE_ID]), [
settle(USDT_ADDR, USDT_400, true),
]),
2,
),
],
},
{
kind: "a V4 step with no swap action whose SETTLE_ALL cannot be read, then a V3 step of 600 USDT",
commands: [0x10, 0x00],
inputs: [
encodeV4Swap(
new Uint8Array([V4_TAKE_ID, V4_SETTLE_ALL_ID]),
[
take(USDT_ADDR, OTHER_ADDR, USDT_400),
dirtyWord(
encodeV4SettleAll(USDT_ADDR, USDT_400),
0,
),
],
),
V3_600,
],
},
{
kind: "a V3 step of 600 USDT, then a V2 step the user pays for whose input cannot be read",
commands: [0x00, 0x08],
inputs: [
V3_600,
dirtyWord(
encodeV2SwapExactIn(OTHER_ADDR, USDT_400, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
0,
),
],
},
{
kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM whose input cannot be read",
commands: [0x00, 0x02],
inputs: [
V3_600,
dirtyWord(
coder.encode(
["address", "address", "uint160"],
[USDT_ADDR, OTHER_ADDR, USDT_400],
),
1,
),
],
},
{
kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x00, 0x0d],
inputs: [
V3_600,
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
),
],
},
])("$kind", ({ commands, inputs }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
});
// The steps of an EXECUTE_SUB_PLAN may take from the sender too, so
// wherever it stands one line says so, and the figure of another
// take cannot read as all that leaves.
const SUB_PLAN = encodeSubPlan("0x00", [V3_600]);
const V2_DAI_400 = encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [
DAI_ADDR,
USDC_ADDR,
]);
test.each([
{
kind: "a V3 step of 600 USDT and a V2 step of 400 DAI, then an EXECUTE_SUB_PLAN",
commands: [0x00, 0x08, 0x21],
inputs: [V3_600, V2_DAI_400, SUB_PLAN],
lines: [
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", UNDECODED],
],
},
{
kind: "an EXECUTE_SUB_PLAN, then a V3 step of 600 USDT and a V2 step of 400 DAI",
commands: [0x21, 0x00, 0x08],
inputs: [SUB_PLAN, V3_600, V2_DAI_400],
lines: [
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
],
},
{
kind: "a V3 step of 600 USDT and a PERMIT2_TRANSFER_FROM of 400 DAI, then an EXECUTE_SUB_PLAN",
commands: [0x00, 0x02, 0x21],
inputs: [
V3_600,
coder.encode(
["address", "address", "uint160"],
[DAI_ADDR, OTHER_ADDR, DAI_400],
),
SUB_PLAN,
],
lines: [
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", OTHER_ADDR],
],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A PERMIT2_PERMIT in the plain shape of Uniswap's own routes keeps
// its own amount: Permit2 lets the router take no more of the token,
// so the steps after it do not add to it.
test("a split after a PERMIT2_PERMIT shows the permit's amount", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x00, 0x08]),
[
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), // 5000 USDT
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("5000.0000 USDT");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
// Out of that shape the permit caps nothing the Amount line can rely
// on, so the line gives the total the steps take, as with no permit.
const ONE_USDT_PERMIT = (spender) =>
encodePermit2(USDT_ADDR, 1000000n, spender);
test.each([
{
kind: "to another spender",
commands: [0x0a],
permits: [ONE_USDT_PERMIT(OTHER_ADDR)],
},
{
kind: "that the router lets fail",
commands: [0x8a],
permits: [ONE_USDT_PERMIT(ROUTER_ADDR)],
},
{
kind: "followed by a second one",
commands: [0x0a, 0x0a],
permits: [
ONE_USDT_PERMIT(ROUTER_ADDR),
ONE_USDT_PERMIT(ROUTER_ADDR),
],
},
])(
"a split after a PERMIT2_PERMIT $kind shows the total the steps take",
({ commands, permits }) => {
const result = decodeSteps(
[...commands, 0x00, 0x08],
[
...permits,
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "1000.0000 USDT"],
]);
},
);
// A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise
// what the router may take of the permit's token again.
test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => {
const permitBatch = coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
[
[
[[USDT_ADDR, 10000000000n, 0, 0]], // 10,000 USDT
ROUTER_ADDR,
9999999999,
],
"0x1234",
],
);
const result = decodeSteps(
[0x0a, 0x03, 0x00, 0x08],
[
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
permitBatch,
V3_600,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
]);
});
// Any other step this decoder does not read may do the same, for
// example through the hook of the pool a V4_INITIALIZE_POOL sets up.
test.each([
{
kind: "a V4_INITIALIZE_POOL",
command: 0x13,
input: coder.encode(
[POOL_KEY, "uint160"],
[
[USDC_ADDR, USDT_ADDR, 3000, 60, OTHER_ADDR],
79228162514264337593543950336n,
],
),
},
{
kind: "a SWEEP whose input cannot be read",
command: 0x04,
input: dirtyWord(
coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
),
1,
),
},
])(
"a split after a PERMIT2_PERMIT, with $kind between its steps, shows the total the steps take",
({ command, input }) => {
const result = decodeSteps(
[0x0a, 0x00, command, 0x08],
[
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
input,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
]);
},
);
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC. Its input is (recipient, amountOut, amountInMax,
// path, payerIsUser), laid out as encodeV3SwapExactIn() writes one;
// an exact-out path lists the token bought first.
const v3ExactOut = (amountInMax) =>
encodeV3SwapExactIn(USER_ADDR, 390000000n, amountInMax, [
USDC_ADDR,
USDT_ADDR,
]);
// A split exact-out route: a V3_SWAP_EXACT_OUT of at most 6,000 USDT
// and a V2_SWAP_EXACT_OUT of at most 4,000 USDT, both paid by the
// user, then a SWEEP of USDC to the user.
const EXACT_OUT_SPLIT = [
v3ExactOut(6000000000n),
encodeV2SwapExactOut(USER_ADDR, 390000000n, 4000000000n, [
USDT_ADDR,
USDC_ADDR,
]),
coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
),
];
// This decoder does not read a V3_SWAP_EXACT_OUT, so what one takes
// leaves no Amount figure wherever it stands, and a permit's amount
// does not stand.
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT",
commands: [0x00, 0x01],
inputs: [V3_600, v3ExactOut(USDT_400)],
},
{
kind: "a PERMIT2_PERMIT of 10,000 USDT, then a V3_SWAP_EXACT_OUT, a V2_SWAP_EXACT_OUT and a SWEEP",
commands: [0x0a, 0x01, 0x09, 0x04],
inputs: [
encodePermit2(USDT_ADDR, 10000000000n, ROUTER_ADDR),
...EXACT_OUT_SPLIT,
],
},
{
kind: "a V3_SWAP_EXACT_OUT, a V2_SWAP_EXACT_OUT and a SWEEP",
commands: [0x01, 0x09, 0x04],
inputs: EXACT_OUT_SPLIT,
},
{
kind: "a V3_SWAP_EXACT_OUT of at most 9,400 USDT, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT",
commands: [0x01, 0x0a, 0x00],
inputs: [
v3ExactOut(9400000000n),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
],
},
])(
"$kind, all paid by the user, shows no Amount figure",
({ commands, inputs }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
},
);
// A later step's amount that cannot be added to the Amount line,
// because the amount of the step that set the input side is not a
// quantity, gets lines of its own. When that step stated no amount,
// it gets lines of its own too, saying so, so the 400 USDT cannot
// read as all that leaves.
const V2_EXACT_OUT_400 = encodeV2SwapExactOut(
USER_ADDR,
390000000n,
USDT_400,
[USDT_ADDR, USDC_ADDR],
);
const UP_TO_400_USDT = [
["Moved from the sender", "Up to 400.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
const UNKNOWN_USDT = [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// A V4 exact-out step of at most 600 USDT for 1 WETH, in the shape
// of Uniswap's own routes: a SETTLE of OPEN_DELTA by the user pays
// what the swap spends.
const V4_EXACT_OUT_600 = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
ONE_WETH,
USDT_600,
),
encodeV4Settle(USDT_ADDR),
encodeV4TakeAll(WETH_ADDR, 0n),
],
);
test.each([
{
kind: "a V4 exact-out step with a SETTLE of OPEN_DELTA by the user",
commands: [0x10, 0x09],
first: V4_EXACT_OUT_600,
lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT],
},
{
kind: "a V4 exact-in step of the open delta",
commands: [0x10, 0x09],
first: encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(USDT_ADDR, USDC_ADDR, 0n, 1n),
encodeV4Settle(USDT_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
lines: [
["Amount", "All available (V4 open delta)"],
...UP_TO_400_USDT,
],
},
{
kind: "a V2 exact-in step of ALREADY_PAID",
commands: [0x08, 0x09],
first: encodeV2SwapExactIn(USER_ADDR, 0n, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
lines: [
[
"Amount",
"Whatever an earlier step sent to the pair (V2 already paid)",
],
...UP_TO_400_USDT,
],
},
])(
"$kind, then a V2 exact-out of at most 400 USDT the user pays for, shows the 400 USDT",
({ commands, first, lines }) => {
const result = decodeSteps(commands, [first, V2_EXACT_OUT_400]);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// The V4 exact-out route above the other way round, where the later
// take's unknown amount leaves no figure at all, and other ways the
// step that sets the input side states no amount.
test.each([
{
kind: "a V2 exact-out of at most 400 USDT, then a V4 exact-out step of at most 600 USDT",
commands: [0x09, 0x10],
inputs: [V2_EXACT_OUT_400, V4_EXACT_OUT_600],
lines: UNKNOWN_USDT,
},
{
kind: "a PERMIT2_PERMIT of 1 USDT to another spender, a V4 exact-out step of at most 600 USDT, then a V2 exact-out of at most 400 USDT",
commands: [0x0a, 0x10, 0x09],
inputs: [
encodePermit2(USDT_ADDR, 1000000n, OTHER_ADDR),
V4_EXACT_OUT_600,
V2_EXACT_OUT_400,
],
lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT],
},
{
kind: "a V4 step whose swap action cannot be read, with a SETTLE of WETH by the user, then a V2 exact-out of at most 400 USDT",
commands: [0x10, 0x09],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
UNREAD_SWAP,
encodeV4Settle(WETH_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
V2_EXACT_OUT_400,
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", "The swap step that spends it"],
...UP_TO_400_USDT,
],
},
])(
"$kind, all paid by the user, shows that an amount is not known",
({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// A V2 exact-out step makes every amount the swap steps spend a maximum,
// the total included.
test("a split with a V2 exact-out step the user pays for shows the total as a maximum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x09]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactOut(USER_ADDR, 390000000n, USDT_400, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
// the shape the issue measured: a V4 step that states a minimum of zero
// and names no output currency.
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);
}
// Open the transaction detail screen with Sepolia active, for the
// transaction the explorer reports sent to `sentTo`, and wait for its
// Open the transaction detail screen with `activeNetworkId` active, for the
// Sepolia transaction the explorer reports sent to `sentTo`, and wait for its
// decoded details.
async function showDetailSentTo(sentTo) {
async function showDetailSentTo(sentTo, activeNetworkId) {
mockSentTo = sentTo;
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.networkId = "sepolia";
state.networkId = activeNetworkId;
state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {};
state.viewStack = [];
@@ -216,21 +216,29 @@ async function showDetailSentTo(sentTo) {
describe("execute() calldata on the transaction detail screen", () => {
test("sent to a router on the active network, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER);
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(
"Swap via Uniswap Universal Router",
);
});
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-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
});
test("sent to a router of the other network only, it is not decoded", async () => {
await showDetailSentTo(MAINNET_ROUTER);
await showDetailSentTo(MAINNET_ROUTER, "sepolia");
expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
+11 -10
View File
@@ -71,6 +71,8 @@ function exactInParams(currencyIn, path, amountIn, amountOutMin) {
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
// uint256 minBalance). It names the output token and nothing about the input.
// Its owner is USER, the transaction's sender: a check of any other address
// names no output token.
function balanceCheck(token, minBalance) {
return coder.encode(
["address", "address", "uint256"],
@@ -78,8 +80,13 @@ function balanceCheck(token, minBalance) {
);
}
// A transaction from USER.
function decode(data) {
return uniswap.decode(data, ROUTER, "0x1", {}, USER);
}
function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, "0x1", {});
const decoded = decode(data);
expect(decoded).not.toBeNull();
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", () => {
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Uniswap Swap",
);
expect(decode(data()).name).toBe("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", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL);
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Uniswap Swap",
);
expect(decode(data()).name).toBe("Uniswap Swap");
});
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, "Amount").value).toBe("1.0000 ETH");
expect(uniswap.decode(data, ROUTER, "0x1", {}).name).toBe(
"Swap ETH → USDT",
);
expect(decode(data).name).toBe("Swap ETH → USDT");
});
test("WRAP_ETH is still ETH at 18 decimals", () => {