1 Commits
Author SHA1 Message Date
sneak 6686b4096c 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 routers with
the 0x7f mask), PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 (0x40) and V4
TAKE_PORTION were payouts the decoder did not read, so a swap using one read
Min. paid out with an unknown payee. 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, so it pays nothing out. A
PERMIT2_TRANSFER_FROM into the router is shown with the amount it moves and
the moved token's address, and a bridge deposit names the bridge and 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 11:47:23 +00:00
32 changed files with 485 additions and 4060 deletions
+115 -245
View File
@@ -418,10 +418,6 @@ Each group of these assertions records a coverage key and a final case demands
the exact set, so a section that silently stopped running reddens the suite the exact set, so a section that silently stopped running reddens the suite
instead of quietly shrinking it. instead of quietly shrinking it.
It also covers the red banner on the Settings screen: switching the network
there to Sepolia must put up the `[TESTNET]` banner, and switching back to
mainnet must take it down, both while Settings is still on screen.
It also covers the confirmation screen, for both a native ETH send and an ERC-20 It also covers the confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands, send: Send disabled while the fee estimate is in flight, enabled once it lands,
the fee block quoting the expected cost and the reserve separately, the distinct the fee block quoting the expected cost and the reserve separately, the distinct
@@ -434,15 +430,6 @@ reserve while sitting on the same side of the estimate, so swapping the two in
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only. previously correct by reading only.
It also covers a send that Sign & Send refuses for its network fee: the stub
node quotes an ordinary fee while the confirmation screen is drawn and one over
the bound when Sign & Send asks again, and the refusal must broadcast nothing
and leave the password field and Sign & Send where they were. It checks the same
for a send refused because the selected wallet does not hold its From address:
with the confirmation screen showing a send from the HD wallet, the key wallet
is selected by writing it straight into extension storage, as another popup
selecting it would save it, and the popup is reopened onto that screen.
It also covers both ways the wait for a sent transaction's receipt ends on the It also covers both ways the wait for a sent transaction's receipt ends on the
error screen: lookups that still find no receipt 60 seconds after the broadcast, error screen: lookups that still find no receipt 60 seconds after the broadcast,
and six lookups in a row that fail. Each must show its own message, and Done and six lookups in a row that fail. Each must show its own message, and Done
@@ -1090,49 +1077,42 @@ The rule and its exception live in `src/shared/amountDisplay.js` as
confirmation screens display goes through the floored one — the ERC-20 amount, confirmation screens display goes through the floored one — the ERC-20 amount,
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount` the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
`Current balance` (`src/popup/views/send.js`), and the amount, balance and `Current balance` (`src/popup/views/send.js`), and the balance and network fee
network fee on the confirmation screen for the wallet's own send on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`), which hands the wait, success and error (`src/popup/views/confirmTx.js`). Both screens render a network fee through
screens the amount as it showed it. The transaction is still signed for the `formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
exact amount, and its USD value is priced from it. Both screens render a network rather than its truncated figure, so the same fee reads the same on both, USD
fee through `formatFee()` in `src/popup/views/helpers.js`, which prices the value included. Balances are stored exactly (`src/shared/balances.js`), whatever
exact fee in USD rather than its truncated figure, so the same fee reads the decimals a token declares, so a balance below the floor reaches these screens as
same on both, USD value included. Balances are stored exactly it is. The history list (`src/shared/transactions.js`) uses the unfloored one:
(`src/shared/balances.js`), whatever decimals a token declares, so a balance the transaction detail view is the authoritative record and already shows exact
below the floor reaches these screens as it is. The history list precision. The balance lists use neither: they round to four places with
(`src/shared/transactions.js`) uses the unfloored one: the transaction detail `toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal
view is the authoritative record and already shows exact precision. The balance rule is unchanged everywhere else, including for amounts at or above the floor
lists use neither: they round to four places with `toFixed(4)` (`balanceLine()` on the approval screens.
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.
On the approval screen and the wait, success and error screens that carry its The floor applies only where the token's scale is known. Where it is not, the
amount forward, the floor applies only where the token's scale is known. Where approval screen states base units instead of a quantity — see Unknown token
it is not, they state base units instead of a quantity — see Unknown token scale scale below — and no truncation happens at all.
below — and no truncation happens at all. On ConfirmTx and the wait, success and
error screens that follow it, the amount of a token whose scale is unknown,
which cannot be sent, is the text from Send's amount field cut to four decimals
with the same floor (`.5` reads `.5000`).
**Specific Exception — unknown token scale:** Calldata carries base units and no **Specific Exception — unknown token scale:** Calldata carries base units and no
scale, so every amount on the dApp approval screen needs the token's `decimals`. scale, so every amount on the dApp approval screen needs the token's `decimals`.
It is resolved from the bundled token list, then from the tokens the user tracks It is resolved from the bundled token list, then from the tokens the user
on the network the transaction is for, then, when that is the active network, tracks, then from what the block explorer reported for the contract
from what the block explorer reported for the contract in the wallet's balances, (`resolveTokenDecimals()` in `src/shared/approvalAmount.js`). Where none of them
which are the active network's only (`resolveTokenDecimals()` in answers, the amount is not formatted: the line reads
`src/shared/approvalAmount.js`). Where none of them answers, the amount is not `5000000000 base units (decimals unknown)`. A guessed scale is not an
formatted: the line reads `5000000000 base units (decimals unknown)`. A guessed approximation but a different number — 1,000 units of a 6-decimal token
scale is not an approximation but a different number — 1,000 units of a formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen to state what is being authorized. Both amount paths of that screen take this
whose only job is to state what is being authorized. Both amount paths of that rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
screen take this rule: the ERC-20 `transfer`/`approve` line the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
(`src/popup/views/approval.js`) and the swap's `Amount` and `Min. received` token permission warning on the signature screen takes the same rule for its
lines (`src/shared/uniswap.js`). The token permission warning on the signature amounts. An unbounded allowance or permit needs no scale to describe and is
screen takes the same rule for its amounts. An unbounded allowance or permit still shown as `Unlimited`. A source's answer counts only if it is a whole
needs no scale to describe and is still shown as `Unlimited`. A source's answer number from 0 to 80: `decimals()` returns a `uint8`, but `formatUnits()` cannot
counts only if it is a whole number from 0 to 80: `decimals()` returns a format more than 80 decimal places, so a token that reports 81 to 255 is shown
`uint8`, but `formatUnits()` cannot format more than 80 decimal places, so a as one whose scale nothing knows.
token that reports 81 to 255 is shown as one whose scale nothing knows.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be sources are read as authoritative, so a value written into one of them cannot be
@@ -1213,13 +1193,7 @@ number. They can read:
guarantees nothing: it sets `Token Out` and `Min. received` only when the guarantees nothing: it sets `Token Out` and `Min. received` only when the
output side holds no minimum, not even a zero one, at the point the check is output side holds no minimum, not even a zero one, at the point the check is
reached, and otherwise leaves the current token and figure in place. A nonzero reached, and otherwise leaves the current token and figure in place. A nonzero
`minBalance` sets both lines, as a swap step does. Only a check of the `minBalance` sets both lines, as a swap step does.
transaction's sender's own balance sets either line; a check of any other
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
that the router lets fail without reverting the transaction (see Transaction
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
lets fail is likewise read as zero.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -1531,11 +1505,9 @@ first screen that cannot be rendered from saved state, such as Send or AddToken,
or at AddressToken for any token but the native one, because that token was or at AddressToken for any token but the native one, because that token was
picked on the previous network. The cut stack is saved with the switch, so a picked on the previous network. The cut stack is saved with the switch, so a
popup closed straight after it and reopened onto Settings lands in the same popup closed straight after it and reopened onto Settings lands in the same
place. "Back" then renders the screen it lands on for the new network, and place. "Back" then renders the screen it lands on for the new network, except
anything typed there and not saved, which belonged to the previous network, is ConfirmTx, which keeps the network its transaction was reviewed on, and anything
dropped. ConfirmTx, WaitTx, SuccessTx, ErrorTx and TransactionDetail are typed there and not saved, which belonged to the previous network, is dropped.
rendered instead for the network of the transaction they show: the network it
was reviewed on for ConfirmTx, and the network it was sent on for the others.
Without a wallet the stack is not cut, because it starts at Welcome, which shows Without a wallet the stack is not cut, because it starts at Welcome, which shows
nothing from a network. nothing from a network.
@@ -1780,18 +1752,14 @@ view would leave a wallet one click from deletion.
- Token contract: full address + etherscan link (ERC-20 only) - Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title - From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name - To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses), the value to four decimal - Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
places with the nonzero floor, as on TxApproval (see Display Consistency), filled in is worked out again from this screen's own fee estimate when it
however Send's amount field wrote it (`.5` and `0.5` both read `0.5000`). arrives, as the balance minus the reserve, and the transaction is signed
For a token whose scale is unknown, which cannot be sent, the value is the with that estimate's fee fields, so a fee fetched again at signing cannot
text from Send's amount field cut the same way (`.5` reads `.5000`). An exceed what the amount leaves behind. The address keeps whatever part of
ETH amount Send's "Max" filled in is worked out again from this screen's the reserve the transaction does not use. If the balance could not be read
own fee estimate when it arrives, as the balance minus the reserve, and or no longer covers the fee, the amount is left as it was and one of the
the transaction is signed with that estimate's fee fields, so a fee errors below blocks the send
fetched again at signing cannot exceed what the amount leaves behind. The
address keeps whatever part of the reserve the transaction does not use.
If the balance could not be read or no longer covers the fee, the amount
is left as it was and one of the errors below blocks the send
- Your balance: the From address's balance on the network its transaction - Your balance: the From address's balance on the network its transaction
was reviewed on, read from that network's RPC endpoint each time the was reviewed on, read from that network's RPC endpoint each time the
screen is shown, so a screen the popup reopens onto states and checks that screen is shown, so a screen the popup reopens onto states and checks that
@@ -1825,10 +1793,6 @@ view would leave a wallet one click from deletion.
estimated, and it says the wallet does not know how many decimal places estimated, and it says the wallet does not know how many decimal places
the token uses and that the transaction cannot be sent; for any other the token uses and that the transaction cannot be sent; for any other
failure it asks the user to go back and try again failure it asks the user to go back and try again
- Three more reserved error boxes, made visible when "Sign & Send" refuses a
send because the active network changed, because the selected wallet does
not hold the From address, or because of its gas limit or fee (see
Transitions), so the refusal moves nothing
- Password: an inline field on this screen, not a modal, with its own error - Password: an inline field on this screen, not a modal, with its own error
line line
- "Sign & Send" button (disabled if errors, while the balance is being read, - "Sign & Send" button (disabled if errors, while the balance is being read,
@@ -1846,7 +1810,7 @@ view would leave a wallet one click from deletion.
- "Sign & Send" after the active network was switched in Settings since this - "Sign & Send" after the active network was switched in Settings since this
transaction was reviewed → nothing is signed; "The active network changed transaction was reviewed → nothing is signed; "The active network changed
after this transaction was reviewed, so it was not sent. Please go back after this transaction was reviewed, so it was not sent. Please go back
and review it again." in its own box, no screen change. After a switch, and review it again." in the errors box, no screen change. After a switch,
"Back" from Settings draws this screen again from the saved transaction, "Back" from Settings draws this screen again from the saved transaction,
as a popup reopened onto it does; when Send is beneath it on the stack, as as a popup reopened onto it does; when Send is beneath it on the stack, as
when Send showed it in the same popup, "Back" lands below Send instead when Send showed it in the same popup, "Back" lands below Send instead
@@ -1860,21 +1824,13 @@ view would leave a wallet one click from deletion.
fee estimate and the contract and never-sent recipient checks ask that fee estimate and the contract and never-sent recipient checks ask that
network's RPC endpoint, and the Etherscan label check asks that network's network's RPC endpoint, and the Etherscan label check asks that network's
explorer explorer
- "Sign & Send" (correct password) when the gas limit, a fee per gas or the
total fee the RPC endpoint fills in at signing is over its bound in
`assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is
signed; "The gas limit or fee the RPC endpoint quoted for this transaction
is more than this wallet will sign for, so it was not sent. Please go back
and try again." in its own box, the same sentence for all three, no screen
change
- "Sign & Send" (wrong password) → "Wrong password." on the password error - "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is - "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not screen with another address selected. When the selected wallet does not
hold the From address → nothing is signed; "The selected wallet does not hold the From address → full-sentence error on the password error line,
hold the From address above, so nothing was sent. Please go back and send nothing sent (no screen change)
again." in its own box, no screen change
- "Sign & Send" (correct password, but the From address's key is refused, - "Sign & Send" (correct password, but the From address's key is refused,
see Data Model) → nothing is signed → **ErrorTx** saying why see Data Model) → nothing is signed → **ErrorTx** saying why
- "Back" → **Send** - "Back" → **Send**
@@ -1894,17 +1850,14 @@ view would leave a wallet one click from deletion.
no recipient." instead no recipient." instead
- Transaction hash: full hash (tap to copy) + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link
- Count-up timer: "Waiting for confirmation... Ns" - Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The screen - **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
describes the transaction on the network it was sent on, whichever network is persisted: closing and reopening the popup resumes the poll, with the elapsed
active: the receipt is asked of that network's RPC endpoint, the one Settings counter and the timeout deadline still measured from the original broadcast. A
has for it, and the To and hash links open that network's explorer. The wait lookup that fails is retried on the next tick rather than counted as a missing
is persisted: closing and reopening the popup resumes the poll, with the receipt, because a failed lookup says nothing about the transaction; but six
elapsed counter and the timeout deadline still measured from the original failures in a row (60 seconds at the poll cadence) end the wait, so an RPC
broadcast. A lookup that fails is retried on the next tick rather than counted that never answers cannot leave it running indefinitely. Any lookup that
as a missing receipt, because a failed lookup says nothing about the answers resets that count.
transaction; but six failures in a row (60 seconds at the poll cadence) end
the wait, so an RPC that never answers cannot leave it running indefinitely.
Any lookup that answers resets that count.
- **Transitions**: - **Transitions**:
- Receipt found → **SuccessTx** - Receipt found → **SuccessTx**
- A lookup that answers "no receipt" 60 seconds or more after broadcast → - A lookup that answers "no receipt" 60 seconds or more after broadcast →
@@ -1925,12 +1878,9 @@ view would leave a wallet one click from deletion.
- Amount + symbol - Amount + symbol
- To: color dot + full address + etherscan link, or for a contract creation - To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx the same sentence as on WaitTx
- Block number + etherscan link - Block number
- Transaction hash: full hash (tap to copy) + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button - "Done" button
- **Behavior**: As on WaitTx, every etherscan link on the screen opens the
explorer of the network the transaction was sent on, whichever network is
active.
- **Transitions**: - **Transitions**:
- "Done" in the approval popup → closes the popup window - "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken** - "Done" otherwise → resets the navigation stack, then → **AddressToken**
@@ -1950,9 +1900,6 @@ view would leave a wallet one click from deletion.
- Transaction hash section (hidden if broadcast failed before getting hash): - Transaction hash section (hidden if broadcast failed before getting hash):
full hash (tap to copy) + etherscan link full hash (tap to copy) + etherscan link
- "Done" button - "Done" button
- **Behavior**: As on WaitTx, every etherscan link on the screen opens the
explorer of the network the transaction was sent on, whichever network is
active.
- **Transitions**: - **Transitions**:
- "Done" in the approval popup → closes the popup window - "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken** - "Done" otherwise → resets the navigation stack, then → **AddressToken**
@@ -1975,10 +1922,7 @@ view would leave a wallet one click from deletion.
#### TransactionDetail (`transaction`) #### TransactionDetail (`transaction`)
- **When**: User tapped a transaction row on Home, AddressDetail, or - **When**: User tapped a transaction row on Home, AddressDetail, or
AddressToken. The screen describes the transaction on the network it was sent AddressToken.
on, whichever network is active: its block, network details, decoded details
and raw data are read from that network's Blockscout, the one Settings has for
it, and its etherscan links open that network's explorer.
- **Elements** (grouped into logical blocks using light well containers; field - **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings): labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button - "Transaction" heading, "Back" button
@@ -2379,24 +2323,19 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a - Site origin (bold, scheme and port included) + "wants to send a
transaction" transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, what each `PERMIT2_TRANSFER_FROM` moves and where to, steps, amounts, steps, deadline, and unless a swap pays only the From address, a
deadline, and unless a swap pays only the From address, a `Paid to` line `Paid to` line for each other address it pays, or saying that its output
for each other address it pays, or saying that a token stays in the router stays in the router or who it pays is unknown (see Transaction Decoding)
or who it pays is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx known; for a contract creation, the same sentence as on WaitTx
- Value: amount in ETH (4 decimal places with the nonzero floor, USD in - Value: amount in ETH (4 decimal places, USD in parentheses)
parentheses) - Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places with in parentheses), with the gas limit and the fee per gas in gwei below it
the nonzero floor, USD in parentheses), with the gas limit and the fee per
gas in gwei below it
- Network and nonce - Network and nonce
- Raw data: full calldata displayed inline (shown if present) - Raw data: full calldata displayed inline (shown if present)
- Password input and an error line - Password input and an error line
- "Confirm" / "Reject" buttons - "Confirm" / "Reject" buttons
- **Behavior**: Every etherscan link on the screen opens the explorer of the
network the transaction was populated on, whichever network is active.
- **Transitions**: - **Transitions**:
- "Confirm" (correct password) → decrypts and signs the transaction it was - "Confirm" (correct password) → decrypts and signs the transaction it was
shown, exactly as shown, hands the signed transaction to the background to shown, exactly as shown, hands the signed transaction to the background to
@@ -3005,50 +2944,24 @@ Currently supported:
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline. "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
The swap summary is shown only for a transaction sent to one of the Universal The swap summary is shown only for a transaction sent to one of the Universal
Router addresses Uniswap publishes for the network: on TxApproval the network Router addresses Uniswap publishes for the network: on TxApproval the network
the transaction is for, on TransactionDetail the network it was sent on. The the transaction is for, on TransactionDetail the active network. The same
same calldata sent to any other address is shown as raw data. Decodes Permit2, calldata sent to any other address is shown as raw data. Decodes Permit2,
V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap action is read only V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap action is read only
in the parameter shape deployed on mainnet and Sepolia, not in the newer one in the parameter shape deployed on mainnet and Sepolia, not in the newer one
on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
any other shape is not read, and its step is listed as any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`. Each step is named and read as the command `V4 Swap (could not be decoded)`. Each step is named by its command type,
that the release of the router at that address runs for its command type, as which the decoder reads from the command byte with the mask the router at that
that release's `contracts/libraries/Commands.sol` names it. The decoder reads address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for
the type from the command byte with the mask that router uses: `0x1f` for `UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for
`UniversalRouterV1`, `0x3f` for `UniversalRouterV1_2_V2Support` and `UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag
`UniversalRouterV2`, and `0x7f` for `UniversalRouterV2_1_1` and (`0x80`) therefore does not change a step's name. Only the routers with the
`UniversalRouterV2_1_2`. The allow-revert flag (`0x80`) therefore does not `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`), listed as
change the command a step is read as. Releases run different commands for some `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`), listed as
types: on `UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` to `Across Bridge Deposit`; the other routers revert on `0x07`, and it is listed
`0x13` are NFT commands, where the later routers run `V4_SWAP`, there as `Command 0x07`. The decoder does not read the commands inside an
`V3_POSITION_MANAGER_PERMIT`, `V3_POSITION_MANAGER_CALL` and `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they may spend
`V4_INITIALIZE_POOL`. The decoder does not read those routers' NFT commands, the user's tokens or pay the user. That step is listed as
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 `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 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` `Unknown (could not be decoded)`, and there is no `Amount` or `Min. received`
@@ -3066,92 +2979,49 @@ Currently supported:
`ACROSS_V4_DEPOSIT_V3` pays the bridge, which pays the deposit's recipient on `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 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 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 addresses, and each that is not the transaction's sender is named. When a swap
`BALANCE_CHECK_ERC20` pays nobody: the router checks that its owner holds at step, `TAKE` or `TAKE_PORTION` pays the router the swap's output token, the
least `minBalance` of its token, and reads the owner as written, so output stays in the router unless a later step pays all the router holds of it
`address(1)` and `address(2)` there are addresses too. A check of any address to anyone but the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is
but the transaction's sender's says nothing about what the user receives: it WETH, a `TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a
sets neither `Token Out` nor the minimum, and it is listed as `PAY_PORTION_FULL_PRECISION` of 1e18 of that token, or an
`Balance Check (of another address)`. A check whose input does not decode, `ACROSS_V4_DEPOSIT_V3` of `CONTRACT_BALANCE` of that token, or of ETH when it
such as one whose owner word has a nonzero top byte (the router reads the sets `useNative`. The router sends ETH as written, so a `TRANSFER` of
owner from the word's last 20 bytes), likewise sets neither, and it is listed `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or token to the V2 pair that the very next step spends, as a V2 exact-in of that
`TAKE_PORTION` pays the router any token, the swap's output or another, a token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
paid it into ETH, that token stays in the router unless a later step pays out the router enforces that swap's own minimum. The decoder checks that the
or spends all the router holds of it: a `SWEEP` of that token to anyone but address paid is that pair, deriving it from the V2 factory and pair init code
the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of hash the network's routers were deployed with and the first two tokens of that
`CONTRACT_BALANCE` when it is ETH, a `TRANSFER` of `CONTRACT_BALANCE`, a step's path; a payment to any other address is named. (Sepolia's
`PAY_PORTION` of 10,000 bips or a `PAY_PORTION_FULL_PRECISION` of 1e18 of that `UniversalRouterV1_2_V2Support` was deployed with placeholders for both
token to anyone but the router, an `ACROSS_V4_DEPOSIT_V3` of values, so no V2 swap step can succeed on that router.) Every other payment
`CONTRACT_BALANCE` of that token, or of ETH when it sets `useNative`, or a V2 counts, in whichever step and of whatever token, a share of the output
or V3 exact-in swap step or V4 `SETTLE` that pays `CONTRACT_BALANCE` of that included: an interface fee paid to another address by `PAY_PORTION`,
token from the router (`payerIsUser` false). V2 and V3 exact-in swap steps and `TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
V4 `SETTLE`s that pay WETH from the router also spend all the WETH a because the swap's minimum includes the fee. The minimum reads `Min. received`
`WRAP_ETH` paid it once together they spend the amount the `WRAP_ETH` states, only when some step pays the transaction's sender (the From address on
as a swap paid in ETH does, but only when that amount is not TxApproval, the sender the explorer reports on TransactionDetail) and no
`CONTRACT_BALANCE` and the router held no WETH from an earlier step when the `Paid to` line is shown. `Paid to` lines after `Token Out` name each other
`WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1 address paid; one reads
WETH from it reads as leaving the WETH there. The router pays a `SETTLE` of
`OPEN_DELTA` (zero) only what its step still owes of that token when the
`SETTLE` runs, so one from the router counts as spending the amounts the
step's swap actions state they spend of that token only when every action in
the step could be read, every swap action names the token it spends and the
token it buys (a swap action with an empty path swaps nothing), the `SETTLE`
is the step's only `SETTLE` or `SETTLE_ALL` of that token and comes after
every swap action, no swap action buys that token, and the swap actions that
spend it state a quantity together; otherwise it counts as spending nothing.
So the rest of a V4 `SETTLE` of more than its swap spends, taken to the
router, the WETH of a `WRAP_ETH`, and a token a swap pays the router on the
way to the output, each stays in the router when later steps do not spend all
of it, where anyone can take it in a later transaction. The router sends ETH
as written, so a `TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and
pays out nothing. A step paying a token to the V2 pair that the very next step
spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID`
(zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes
pay the next V2 pair directly, and the router enforces that swap's own
minimum. The decoder checks that the address paid is that pair, deriving it
from the V2 factory and pair init code hash the network's routers were
deployed with and the first two tokens of that step's path; a payment to any
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was
deployed with placeholders for both values, so no V2 swap step can succeed on
that router.) Every other payment counts, in whichever step and of whatever
token, a share of the output included: an interface fee paid to another
address by `PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the
minimum reads `Min. paid out`, because the swap's minimum includes the fee.
The minimum reads `Min. received` only when some step pays the transaction's
sender (the From address on TxApproval, the sender the explorer reports on
TransactionDetail) and no `Paid to` line is shown. `Paid to` lines after
`Token Out` name each other address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a `The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example, bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)` `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` 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 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 reads `The router, where it stays (no later step pays it out)` when the output
that stays in the router, followed, for any token but the output, by a stays in the router; and one more reads `Unknown (could not be decoded)` when
`Token moved into the router` line giving that token in the same way (ETH by a step that may pay an address went unread. When no step pays anyone, a single
name); and one more reads `Unknown (could not be decoded)` when a step that line reads `Unknown (not named in the calldata)`. Wherever there is a
may pay an address went unread. When no step pays anyone, a single line reads `Paid to` line, the minimum reads `Min. paid out`. A step goes unread when its
`Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the recipient does not decode as an address, or when it is a command the decoder
minimum reads `Min. paid out`. After the `Amount` line (after `Token In` when does not read: every command other than `PERMIT2_PERMIT`,
there is none), each `PERMIT2_TRANSFER_FROM` but one into the router, in step `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
order, gives three lines: `Moved from the sender`, the amount it moves, for `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
`Token In` line does; and `Moved to`, which reads `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`,
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)` `PERMIT2_TRANSFER_FROM_BATCH` and `EXECUTE_SUB_PLAN` among them).
on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and
`Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. These lines change neither the
`Paid to` lines nor the minimum's label. A step goes unread when its recipient
does not decode as an address, or when it is a command the decoder does not
read: every command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
`V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
`SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
`ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
(`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and
the NFT commands among them), or a type its release names no command for.
### Non-Goals Forever ### Non-Goals Forever
-27
View File
@@ -697,33 +697,6 @@
id="confirm-fee-unknown-error" id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible" class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div> ></div>
<!-- Shown when Sign & Send refuses a send for its network, for
a From address the selected wallet does not hold, or for its
gas limit or fee. Each holds its space from the first paint, so
the refusal moves nothing. -->
<div
id="confirm-network-changed-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The active network changed after this transaction was
reviewed, so it was not sent. Please go back and review it
again.
</div>
<div
id="confirm-from-address-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The selected wallet does not hold the From address above, so
nothing was sent. Please go back and send again.
</div>
<div
id="confirm-fee-bound-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The gas limit or fee the RPC endpoint quoted for this
transaction is more than this wallet will sign for, so it
was not sent. Please go back and try again.
</div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1 text-xs">Password</label> <label class="block mb-1 text-xs">Password</label>
<input <input
+5 -18
View File
@@ -127,33 +127,28 @@ function isRenderableDecoded(value) {
} }
// The pending transaction confirmTx.show() renders: `token` reaches // The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", `from`/`to` reach // renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches // addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which
// truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in // show() fills in when it is absent, picks the network whose explorer the
// when it is absent, picks the network whose explorer the address links open, // address links open, so it has to name one.
// so it has to name one.
function isRenderablePendingTx(value) { function isRenderablePendingTx(value) {
return ( return (
isRecord(value) && isRecord(value) &&
isText(value.token) && isText(value.token) &&
isAddressText(value.from) && isAddressText(value.from) &&
isAddressText(value.to) && isAddressText(value.to) &&
isText(value.amount) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId)) (!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
); );
} }
// The stored transaction transactionDetail.render() shows. contractAddress is // The stored transaction transactionDetail.render() shows. contractAddress is
// optional on an ETH transfer, and reaches addressDotHtml() when it is there. // optional on an ETH transfer, and reaches addressDotHtml() when it is there.
// `chainId` picks the network whose explorer the links open and whose
// Blockscout the transaction is read from, so it has to name one.
function isRenderableTx(value) { function isRenderableTx(value) {
return ( return (
isRecord(value) && isRecord(value) &&
isAddressText(value.from) && isAddressText(value.from) &&
isAddressText(value.to) && isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress)) && (!value.contractAddress || isAddressText(value.contractAddress))
SUPPORTED_CHAIN_IDS.has(value.chainId)
); );
} }
@@ -205,19 +200,11 @@ function renderView(view, state, views) {
if (!data.hash) return false; if (!data.hash) return false;
if (!isAddressText(data.to)) return false; if (!isAddressText(data.to)) return false;
if (!isRenderableDecoded(data.decoded)) return false; if (!isRenderableDecoded(data.decoded)) return false;
// `chainId` picks the network whose explorer the links open, so
// when it is there it has to name one. Likewise on error-tx.
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
return false;
}
views.txStatus.renderSuccess(); views.txStatus.renderSuccess();
return true; return true;
case "error-tx": case "error-tx":
if (!data.message) return false; if (!data.message) return false;
if (!isAddressText(data.to)) return false; if (!isAddressText(data.to)) return false;
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
return false;
}
views.txStatus.renderError(); views.txStatus.renderError();
return true; return true;
default: default:
+19 -32
View File
@@ -11,7 +11,6 @@ const {
onViewLeave, onViewLeave,
formatFee, formatFee,
tokenLabel, tokenLabel,
tokenSourcesByChainId,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { const {
@@ -58,15 +57,9 @@ const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
// The link opens the explorer of the network with `chainId`, or of the active function approvalAddressHtml(address) {
// network when none is given. A chain id that names no network has no
// explorer, so the address is drawn without a link.
function approvalAddressHtml(address, chainId) {
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
if (chainId && !networkByChainId(chainId)) { return renderAddressHtml(address, { title });
return renderAddressHtml(address, { title, noLink: true });
}
return renderAddressHtml(address, { title, chainId });
} }
// The amount line for a decoded ERC-20 call. With a known scale it is the // The amount line for a decoded ERC-20 call. With a known scale it is the
@@ -92,12 +85,13 @@ function tokenAmountText(rawAmount, decimals, symbol) {
function decodeCalldata(data, toAddress, chainId, fromAddress) { function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null; if (!data || data === "0x" || data.length < 10) return null;
// Where a token's name and scale are looked for, for every decoder below: // Where a token's scale is looked for, for every decoder below: the ERC-20
// what the wallet knows of the tokens on the network with `chainId`, which // amount line and the swap's Amount and Min. received lines resolve it the
// need not be the active one. The ERC-20 amount line and the swap's Amount // same way, and refuse to format the same way when it is nowhere.
// and Min. received lines resolve the scale the same way, and refuse to const decimalsSources = {
// format the same way when it is nowhere. trackedTokens: state.trackedTokens,
const decimalsSources = tokenSourcesByChainId(chainId); wallets: state.wallets,
};
// Try ERC-20 (approve / transfer) // Try ERC-20 (approve / transfer)
try { try {
@@ -267,7 +261,10 @@ function showTxApproval(details) {
const toAddr = approvedTx.to; const toAddr = approvedTx.to;
const ethValue = formatEther(approvedTx.value || "0"); const ethValue = formatEther(approvedTx.value || "0");
const sources = tokenSourcesByChainId(approvedTx.chainId); const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Build txInfo for status screens // Build txInfo for status screens
pendingTxDetails = { pendingTxDetails = {
@@ -344,24 +341,17 @@ function showTxApproval(details) {
}; };
} }
// Every address link on this screen opens the explorer of the network the
// transaction is for, which the Network line names, whichever network is
// active.
$("approve-tx-origin").textContent = details.origin; $("approve-tx-origin").textContent = details.origin;
$("approve-tx-from").innerHTML = approvalAddressHtml( $("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
details.approvedFrom,
approvedTx.chainId,
);
// Show token symbol next to contract address if known on the network the // Show token symbol next to contract address if known
// transaction is for const symbol = toAddr ? tokenLabel(toAddr) : null;
const symbol = toAddr ? tokenLabel(toAddr, approvedTx.chainId) : null;
if (toAddr) { if (toAddr) {
let toHtml = ""; let toHtml = "";
if (symbol) { if (symbol) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`; toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
} }
toHtml += approvalAddressHtml(toAddr, approvedTx.chainId); toHtml += approvalAddressHtml(toAddr);
$("approve-tx-to").innerHTML = toHtml; $("approve-tx-to").innerHTML = toHtml;
} else { } else {
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT); $("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
@@ -398,12 +388,9 @@ function showTxApproval(details) {
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`; detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
} }
detailsHtml += approvalAddressHtml( detailsHtml += approvalAddressHtml(d.address);
d.address,
approvedTx.chainId,
);
} else { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
+60 -69
View File
@@ -23,10 +23,12 @@ const {
goBack, goBack,
onViewLeave, onViewLeave,
formatFee, formatFee,
reviewedNetworkProvider,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
@@ -43,8 +45,8 @@ const {
transferAmountUnits, transferAmountUnits,
} = require("../../shared/transferAmount"); } = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify"); const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The amount, the balance line and the fee reserve go through it, as the // The balance line and the fee reserve go through it, as the approval screen's
// approval screen's amounts do. // amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { const {
CODES, CODES,
@@ -98,32 +100,8 @@ function valueWithUsd(text, usdAmount) {
return text; return text;
} }
// The amount as this screen and the wait, success and error screens show it, // The Amount line, with its USD value. A max ETH send's line is drawn again
// written as the approval screen writes a site's amount: the amount being // once its amount is re-derived from the fee estimate.
// signed, parsed at the scale it is encoded with and written back as plain
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
// `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is
// unknown, which cannot be sent: its amount is cut as typed, so `.5` reads
// `.5000`. txInfo.amount, which is what gets signed, is left as it is.
function shownAmount(txInfo) {
let exact = txInfo.amount;
try {
if (txInfo.token === "ETH") {
exact = formatEther(parseEther(txInfo.amount));
} else {
const decimals = displayedDecimals(txInfo.tokenDecimals);
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
}
} catch {
// A token whose scale is unknown, which is never signed, or an amount
// the amount check refuses: cut as Send's amount field held it.
}
return truncateAmountNeverZero(exact);
}
// The Amount line, with its USD value, which is priced from the exact amount.
// A max ETH send's line is drawn again once its amount is re-derived from the
// fee estimate.
function renderAmount(txInfo) { function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20 const rawSymbol = isErc20
@@ -132,7 +110,7 @@ function renderAmount(txInfo) {
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId); const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null; const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd( $("confirm-amount").textContent = valueWithUsd(
shownAmount(txInfo) + " " + displaySymbol(rawSymbol), txInfo.amount + " " + displaySymbol(rawSymbol),
amountUsd, amountUsd,
); );
} }
@@ -313,13 +291,9 @@ function show(txInfo) {
renderValidation(txInfo); renderValidation(txInfo);
// Reset password field and error, and any refusal of an earlier Sign & // Reset password field and error
// Send.
$("confirm-tx-password").value = ""; $("confirm-tx-password").value = "";
hideError("confirm-tx-password-error"); hideError("confirm-tx-password-error");
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
// Gas estimate — show placeholder then fetch async // Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible"; $("confirm-fee").style.visibility = "visible";
@@ -456,6 +430,20 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden"; $(id).style.visibility = visible ? "visible" : "hidden";
} }
// The node of the network this transaction was reviewed for, which need not be
// the active one: the popup can reopen onto this screen after Settings switched
// the network. Its RPC URL is the one Settings has for that network: the
// active network's is state.rpcUrl, any other's the one remembered for it (see
// applyChainSwitchFields()).
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
async function estimateGas(txInfo) { async function estimateGas(txInfo) {
try { try {
const provider = reviewedNetworkProvider(txInfo); const provider = reviewedNetworkProvider(txInfo);
@@ -568,17 +556,16 @@ async function estimateGas(txInfo) {
} }
} }
// Populate the transaction this send describes, enforce the bounds against the // Populate the transaction this send describes, enforce the fee bound against
// gas limit and fees that were actually filled in, then sign and broadcast it. // the fees that were actually filled in, then sign and broadcast it. Apart
// Apart from a max ETH send, which passes its estimate's fee fields as `fees`, // from a max ETH send, which passes its estimate's fee fields as `fees`, the
// the send pins no fee fields, so ethers fills maxFeePerGas and the gas limit // send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
// from what the configured RPC node answers, with nothing otherwise bounding // what the configured RPC node answers, with nothing otherwise bounding what a
// what a hostile node can set — the dApp path's ceilings never reached this // hostile node can set — the dApp path's ceilings never reached this one.
// one. Populating before the check is what makes assertWithinCeilings() see // Populating before the check is what makes assertWithinCeilings() see the
// the same numbers that would be signed; it throws an ApprovalMismatchError // same numbers that would be signed; it throws an ApprovalMismatchError when
// when the gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is // the product gasLimit × maxFeePerGas is over the bound, which the caller
// over its bound, and the caller then shows the one refusal reserved for all // shows in the reserved error area rather than sending.
// three rather than sending.
async function populateVerifyAndSend(connectedSigner, tx, fees = null) { async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request; let request;
if (tx.token === "ETH") { if (tx.token === "ETH") {
@@ -606,6 +593,15 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
return connectedSigner.sendTransaction(populated); return connectedSigner.sendTransaction(populated);
} }
// Show a full-sentence send failure in the reserved errors box, the same
// element and markup renderValidation() uses for the invalid-amount message,
// so it never moves anything on the screen.
function showSendError(message) {
const el = $("confirm-errors");
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
el.style.visibility = "visible";
}
async function checkRecipientHistory(txInfo) { async function checkRecipientHistory(txInfo) {
try { try {
const provider = reviewedNetworkProvider(txInfo); const provider = reviewedNetworkProvider(txInfo);
@@ -644,11 +640,6 @@ function init(_ctx) {
onViewLeave("confirm-tx", clearPassword); onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => { $("btn-confirm-send").addEventListener("click", async () => {
// A press shows only its own refusal, if any.
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
// Every Send button refuses a defective wallet before this screen, // Every Send button refuses a defective wallet before this screen,
// but the popup also reopens onto it from a saved view. // but the popup also reopens onto it from a saved view.
@@ -663,7 +654,11 @@ function init(_ctx) {
// Settings after a switch draws this screen again from the saved // Settings after a switch draws this screen again from the saved
// transaction, as a popup reopened onto it does. // transaction, as a popup reopened onto it does.
if (currentNetwork().chainId !== pendingTx.chainId) { if (currentNetwork().chainId !== pendingTx.chainId) {
setVisible("confirm-network-changed-error", true); showSendError(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
return; return;
} }
@@ -672,7 +667,11 @@ function init(_ctx) {
// address selected. // address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from); const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) { if (!addr) {
setVisible("confirm-from-address-error", true); 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.",
);
return; return;
} }
@@ -704,10 +703,6 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true; $("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted"); $("btn-confirm-send").classList.add("text-muted");
// The wait, success and error screens show the amount as this screen
// does, so they are handed a copy carrying that text. pendingTx keeps
// the exact amount, which is the one signed.
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
let tx; let tx;
try { try {
const signer = getSignerForAddress(wallet, addr, decryptedSecret); const signer = getSignerForAddress(wallet, addr, decryptedSecret);
@@ -724,23 +719,19 @@ function init(_ctx) {
// Note: JS strings are immutable; this nulls the reference but // Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC. // the original string may persist in memory until GC.
decryptedSecret = null; decryptedSecret = null;
txStatus.showWait(shownTx, tx.hash); txStatus.showWait(pendingTx, tx.hash);
} catch (e) { } catch (e) {
decryptedSecret = null; decryptedSecret = null;
// A gas limit or fee over its bound is refused before anything is // A fee over the bound is refused before anything is broadcast, so
// broadcast, so there is no transaction that may have reached the // there is no transaction that may have reached the network to warn
// network to warn about: the refusal stays on the confirmation // about: the message stays on the confirmation screen where the
// screen where the user can go back, rather than routing to the // user can go back, rather than routing to the sent/failed screen.
// sent/failed screen. Its sentence is fixed in index.html, so that
// its box can hold its space from the first paint; the reason in
// full, naming the bound, is logged.
if (e && e.approvalMismatch) { if (e && e.approvalMismatch) {
log.errorf("send refused:", e.message); showSendError(e.message);
setVisible("confirm-fee-bound-error", true);
return; return;
} }
const hash = tx ? tx.hash : null; const hash = tx ? tx.hash : null;
txStatus.showError(shownTx, hash, e.shortMessage || e.message); txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
} finally { } finally {
$("btn-confirm-send").disabled = false; $("btn-confirm-send").disabled = false;
$("btn-confirm-send").classList.remove("text-muted"); $("btn-confirm-send").classList.remove("text-muted");
+11 -58
View File
@@ -1,7 +1,4 @@
// Helpers the views in this directory share: showing a view and going back, // Shared DOM helpers used by all views.
// error and flash lines and the warning banners, formatting of amounts, fees,
// balances, addresses and dates, the RPC and Blockscout URLs, node and tokens
// of the network a chain id names, explorer links, and click-to-copy.
// //
// Escaping rule for every view in this directory, since they all build // Escaping rule for every view in this directory, since they all build
// markup by concatenation: any VALUE interpolated into an innerHTML string // markup by concatenation: any VALUE interpolated into an innerHTML string
@@ -22,7 +19,6 @@ const {
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount"); const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
@@ -132,8 +128,7 @@ function showView(name) {
} }
// Create or update the debug/insecure warning banner. // Create or update the debug/insecure warning banner.
// Called on every view switch, after the settings debug toggle changes, and // Called on every view switch and after the settings debug toggle changes.
// after a network switch in Settings.
// The banner is shown when the compile-time DEBUG constant is true OR when // The banner is shown when the compile-time DEBUG constant is true OR when
// the user has enabled runtime debug mode via the settings easter egg, OR // the user has enabled runtime debug mode via the settings easter egg, OR
// when the active network is a testnet. // when the active network is a testnet.
@@ -300,30 +295,15 @@ function nativeCurrency() {
return currentNetwork().nativeCurrency; return currentNetwork().nativeCurrency;
} }
// What the wallet knows of the tokens on the network with this chain id, or // The symbol shown for a token line, resolved from the bundled list, the
// on the active network when no chain id is given, as the `sources` that // tokens the user tracks, and the explorer's report — the same chain the
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address // amount line's scale comes from. Null when no source names one, so the token
// names a token on one network only, and every token balance held is the // lines keep saying `Unknown token` for a token nothing knows.
// active network's, so another network gets the tokens tracked on it function tokenLabel(address) {
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances. return resolveTokenSymbol(address, {
function tokenSourcesByChainId(chainId) { trackedTokens: state.trackedTokens,
const net = chainId ? networkByChainId(chainId) : currentNetwork(); wallets: state.wallets,
if (net && net.id === state.networkId) { });
return { trackedTokens: state.trackedTokens, wallets: state.wallets };
}
return {
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
}
// The symbol shown for a token line on the network with `chainId`, or on the
// active network when none is given: from the bundled list, the tokens the
// user tracks, and the explorer's report, the sources the amount line's scale
// comes from. Null when no source names one, so the token lines keep saying
// `Unknown token` for a token nothing knows.
function tokenLabel(address, chainId) {
return resolveTokenSymbol(address, tokenSourcesByChainId(chainId));
} }
// A network fee in wei as the confirmation and approval screens both show it: // A network fee in wei as the confirmation and approval screens both show it:
@@ -626,30 +606,6 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` + `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`; `</svg></span>`;
// The RPC and Blockscout URLs Settings has for the network with this chain id,
// which need not be the active one: ConfirmTx, WaitTx and TransactionDetail
// use the network their transaction's chain id names, and the popup can reopen
// onto them after Settings switched the network. The active network's are
// state.rpcUrl and state.blockscoutUrl, any other's the ones remembered for it,
// or its defaults (see applyChainSwitchFields()).
function endpointsByChainId(chainId) {
const net = networkByChainId(chainId);
if (net.id === state.networkId) {
return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl };
}
const remembered = state.networkEndpoints[net.id] || {};
return {
rpcUrl: remembered.rpcUrl || net.defaultRpcUrl,
blockscoutUrl: remembered.blockscoutUrl || net.defaultBlockscoutUrl,
};
}
// The node of the network a transaction was reviewed for.
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
return getProvider(endpointsByChainId(txInfo.chainId).rpcUrl, net.id);
}
// Block-explorer URLs. The origin is a per-network constant from // Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js, the explorer of the network with `chainId`, or of // src/shared/networks.js, the explorer of the network with `chainId`, or of
// the active network when no chain id is given; only the path segment is // the active network when no chain id is given; only the path segment is
@@ -790,7 +746,6 @@ module.exports = {
keyMaterialName, keyMaterialName,
unknownableAmount, unknownableAmount,
nativeCurrency, nativeCurrency,
tokenSourcesByChainId,
tokenLabel, tokenLabel,
formatFee, formatFee,
addressDotHtml, addressDotHtml,
@@ -806,8 +761,6 @@ module.exports = {
attachCopyHandlers, attachCopyHandlers,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
endpointsByChainId,
reviewedNetworkProvider,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
isoDate, isoDate,
+1 -3
View File
@@ -343,13 +343,11 @@ function init(ctx) {
// switch request. // switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
resetRenderedViews(); resetRenderedViews();
// Settings itself, and the testnet banner above it, are brought up // Settings itself is brought up to date for the new network here.
// to date for the new network here.
markViewRendered("settings"); markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens(); renderTrackedTokens();
updateDebugBanner(state.currentView);
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
+19 -32
View File
@@ -18,11 +18,10 @@ const {
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
endpointsByChainId,
displaySymbol, displaySymbol,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
@@ -49,16 +48,16 @@ function getTransactionType(tx) {
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer"; return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
} }
function txAddressHtml(address, ensName, title, chainId) { function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( return (
`<div class="mb-1">${blockie}</div>` + `<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName, chainId }) renderAddressHtml(address, { title, ensName })
); );
} }
function txHashHtml(hash, chainId) { function txHashHtml(hash) {
const link = explorerUrl("tx", hash, chainId); const link = explorerUrl("tx", hash);
const extLink = etherscanLinkHtml(link); const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink; return copyableHtml(hash, "break-all") + extLink;
} }
@@ -84,11 +83,10 @@ function show(tx) {
method: tx.method || null, method: tx.method || null,
contractAddress: tx.contractAddress || null, contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and // The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's, // the fee are in its native currency, not the active network's:
// the links open its explorer, and the block, network details and // the active network can change, in Settings or when the user
// calldata are read from its Blockscout: the active network can // approves a site's request, before a later popup shows this
// change, in Settings or when the user approves a site's request, // screen again.
// before a later popup shows this screen again.
chainId: tx.chainId, chainId: tx.chainId,
}, },
}; };
@@ -98,24 +96,18 @@ function show(tx) {
function render() { function render() {
const tx = state.viewData.tx; const tx = state.viewData.tx;
if (!tx) return; if (!tx) return;
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash, tx.chainId); $("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
const fromTitle = addressTitle(tx.from, state.wallets); const fromTitle = addressTitle(tx.from, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml( $("tx-detail-from").innerHTML = txAddressHtml(
tx.from, tx.from,
tx.fromEns, tx.fromEns,
fromTitle, fromTitle,
tx.chainId,
); );
// A contract creation has no recipient: transactions.js gives it `to: ""`. // A contract creation has no recipient: transactions.js gives it `to: ""`.
if (tx.to) { if (tx.to) {
const toTitle = addressTitle(tx.to, state.wallets); const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-to").innerHTML = txAddressHtml( $("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
tx.to,
tx.toEns,
toTitle,
tx.chainId,
);
} else { } else {
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT); $("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
} }
@@ -154,7 +146,7 @@ function render() {
if (tokenContractSection && tokenContractEl) { if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) { if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress); const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress, tx.chainId); const link = explorerUrl("token", tx.contractAddress);
// Hand-rolled rather than renderAddressHtml() because the // Hand-rolled rather than renderAddressHtml() because the
// link goes to the explorer's /token/ page, not /address/. // link goes to the explorer's /token/ page, not /address/.
// Same two-row shape though: dot and link on the strip, the // Same two-row shape though: dot and link on the strip, the
@@ -212,11 +204,7 @@ function showDetailField(sectionId, contentId, value) {
function populateOnChainDetails(txData, chainId) { function populateOnChainDetails(txData, chainId) {
// Block number // Block number
if (txData.block_number != null) { if (txData.block_number != null) {
const blockLink = explorerUrl( const blockLink = explorerUrl("block", String(txData.block_number));
"block",
String(txData.block_number),
chainId,
);
const blockSection = $("tx-detail-block-section"); const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block"); const blockEl = $("tx-detail-block");
if (blockSection && blockEl) { if (blockSection && blockEl) {
@@ -313,9 +301,8 @@ async function loadFullTxDetails(txHash, chainId) {
if (!section || !actionEl || !detailsEl) return; if (!section || !actionEl || !detailsEl) return;
try { try {
const { blockscoutUrl } = endpointsByChainId(chainId);
const resp = await debugFetch( const resp = await debugFetch(
blockscoutUrl + "/transactions/" + txHash, state.blockscoutUrl + "/transactions/" + txHash,
); );
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
@@ -329,12 +316,12 @@ async function loadFullTxDetails(txHash, chainId) {
// Decoded against the address the transaction was sent to, as the // Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token // explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract. // transfer that is the transfer's recipient, not the token contract.
// On the network the transaction was sent on, whose Blockscout was // On the active network, the one whose explorer was just asked, and
// just asked, and from the sender it reports. // from the sender it reports.
const decoded = decodeCalldata( const decoded = decodeCalldata(
inputData, inputData,
txData.to?.hash || "", txData.to?.hash || "",
chainId, currentNetwork().chainId,
txData.from?.hash || "", txData.from?.hash || "",
); );
if (decoded) { if (decoded) {
@@ -353,9 +340,9 @@ async function loadFullTxDetails(txHash, chainId) {
if (tokenSymbol) { if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
} }
detailsHtml += renderAddressHtml(d.address, { chainId }); detailsHtml += renderAddressHtml(d.address);
} else if (d.address) { } else if (d.address) {
detailsHtml += renderAddressHtml(d.address, { chainId }); detailsHtml += renderAddressHtml(d.address);
} else { } else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
} }
+29 -39
View File
@@ -14,13 +14,10 @@ const {
displaySymbol, displaySymbol,
clearViewStack, clearViewStack,
tokenLabel, tokenLabel,
reviewedNetworkProvider,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { const { nativeCurrencyByChainId } = require("../../shared/networks");
SUPPORTED_CHAIN_IDS, const { getProvider } = require("../../shared/balances");
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
// Receipt poll cadence and the deadline after which the wait is reported as // Receipt poll cadence and the deadline after which the wait is reported as
@@ -64,22 +61,21 @@ function endWait() {
} }
// A contract creation reaches these screens with `to` as "" (approval.js // A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`). The links below open the explorer of the network // writes `to: toAddr || ""`).
// with `chainId`, or of the active network when none is given. function toAddressHtml(address) {
function toAddressHtml(address, chainId) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT); if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title, chainId }); return renderAddressHtml(address, { title });
} }
function txHashHtml(hash, chainId) { function txHashHtml(hash) {
const link = explorerUrl("tx", hash, chainId); const link = explorerUrl("tx", hash);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link); return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
} }
function blockNumberHtml(blockNumber, chainId) { function blockNumberHtml(blockNumber) {
const num = String(blockNumber); const num = String(blockNumber);
const link = explorerUrl("block", num, chainId); const link = explorerUrl("block", num);
return copyableHtml(num) + etherscanLinkHtml(link); return copyableHtml(num) + etherscanLinkHtml(link);
} }
@@ -95,11 +91,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// native currency of txInfo.chainId, the network the transaction was sent // native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: the active network can change, in // on, not the active network's: the active network can change, in
// Settings or when the user approves a site's request, while this screen // Settings or when the user approves a site's request, while this screen
// is open or before a later popup resumes it. The links on all three // is open or before a later popup resumes it. A null token is a swap whose
// screens open that network's explorer, and this screen's receipt is // amount was not decoded (see showTxApproval() in approval.js): the amount
// asked of that network's node, for the same reason. A null token is a // says so, and no symbol follows it.
// swap whose amount was not decoded (see showTxApproval() in
// approval.js): the amount says so, and no symbol follows it.
let symbol = ""; let symbol = "";
if (txInfo.token === "ETH") { if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId); symbol = nativeCurrencyByChainId(txInfo.chainId);
@@ -108,8 +102,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
} }
$("wait-tx-summary").textContent = $("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : ""); txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId); $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId); $("wait-tx-hash").innerHTML = txHashHtml(txHash);
attachCopyHandlers("view-wait-tx"); attachCopyHandlers("view-wait-tx");
// Persisted so closing and reopening the popup resumes this wait // Persisted so closing and reopening the popup resumes this wait
@@ -134,7 +128,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
renderElapsed(); renderElapsed();
}, 1000); }, 1000);
const provider = reviewedNetworkProvider(txInfo); const provider = getProvider(state.rpcUrl, state.networkId);
let consecutiveFailures = 0; let consecutiveFailures = 0;
async function poll() { async function poll() {
@@ -213,11 +207,10 @@ function showWait(txInfo, txHash) {
// an object merely missing one of them throws a TypeError out of // an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup // restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric // init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks // broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
// the node the receipt is asked of and the explorer the links open, so it has // txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
// to name a network. txInfo.token and txInfo.tokenSymbol are deliberately // compared and coalesced rather than dereferenced, and tokenSymbol is null for
// unchecked: they are compared and coalesced rather than dereferenced, and // ETH.
// tokenSymbol is null for ETH.
function restoreWait() { function restoreWait() {
const d = state.viewData; const d = state.viewData;
if (!d || !d.pendingWait) return false; if (!d || !d.pendingWait) return false;
@@ -233,7 +226,6 @@ function restoreWait() {
// path itself created. // path itself created.
if (typeof info.to !== "string") return false; if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false; if (typeof info.amount !== "string") return false;
if (!SUPPORTED_CHAIN_IDS.has(info.chainId)) return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) { if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
return false; return false;
} }
@@ -258,13 +250,12 @@ function showSuccess(txInfo, txHash, blockNumber) {
hash: txHash, hash: txHash,
blockNumber: blockNumber, blockNumber: blockNumber,
decoded: txInfo.decoded || null, decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
}; };
renderSuccess(); renderSuccess();
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} }
function decodedDetailsHtml(decoded, chainId) { function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return ""; if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`; let html = `<div class="border border-border border-dashed p-2 mb-3">`;
if (decoded.name) { if (decoded.name) {
@@ -280,11 +271,11 @@ function decodedDetailsHtml(decoded, chainId) {
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`; html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
const sym = tokenLabel(d.address, chainId) || "Unknown token"; const sym = tokenLabel(d.address) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`; html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += toAddressHtml(d.address, chainId); html += toAddressHtml(d.address);
} else { } else {
html += toAddressHtml(d.address, chainId); html += toAddressHtml(d.address);
} }
} else { } else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`; html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
@@ -313,16 +304,16 @@ function renderSuccess() {
toSection.classList.remove("hidden"); toSection.classList.remove("hidden");
$("success-tx-summary").textContent = $("success-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : ""); d.amount + (d.symbol ? " " + d.symbol : "");
$("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId); $("success-tx-to").innerHTML = toAddressHtml(d.to);
} }
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId); $("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
$("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId); $("success-tx-hash").innerHTML = txHashHtml(d.hash);
// Show decoded calldata details if present // Show decoded calldata details if present
const decodedEl = $("success-tx-decoded"); const decodedEl = $("success-tx-decoded");
if (decodedEl && hasDecoded) { if (decodedEl && hasDecoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId); decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
decodedEl.classList.remove("hidden"); decodedEl.classList.remove("hidden");
} else if (decodedEl) { } else if (decodedEl) {
decodedEl.classList.add("hidden"); decodedEl.classList.add("hidden");
@@ -348,7 +339,6 @@ function showError(txInfo, txHash, message) {
to: txInfo.to, to: txInfo.to,
hash: txHash || null, hash: txHash || null,
message: message, message: message,
chainId: txInfo.chainId,
}; };
renderError(); renderError();
} }
@@ -358,11 +348,11 @@ function renderError() {
if (!d || !d.message) return; if (!d || !d.message) return;
$("error-tx-summary").textContent = $("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : ""); d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId); $("error-tx-to").innerHTML = toAddressHtml(d.to);
$("error-tx-message").textContent = d.message; $("error-tx-message").textContent = d.message;
if (d.hash) { if (d.hash) {
$("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId); $("error-tx-hash").innerHTML = txHashHtml(d.hash);
$("error-tx-hash-section").classList.remove("hidden"); $("error-tx-hash-section").classList.remove("hidden");
attachCopyHandlers("view-error-tx"); attachCopyHandlers("view-error-tx");
} else { } else {
+133 -534
View File
@@ -5,7 +5,6 @@ const {
Interface, Interface,
AbiCoder, AbiCoder,
getBytes, getBytes,
getAddress,
formatUnits, formatUnits,
getCreate2Address, getCreate2Address,
keccak256, keccak256,
@@ -25,199 +24,55 @@ const ROUTER_IFACE = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)", "function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]); ]);
// The Universal Router releases deployed at the addresses below, each with
// three things read from its source at the commit linked for it:
//
// - `commandTypeMask`, the mask it reads a command's type with: its dispatcher
// runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits above the
// mask are the allow-revert flag (0x80) or ignored. The mask grew between
// releases. Each is `COMMAND_TYPE_MASK` in the release's
// `contracts/libraries/Commands.sol`, the same file as in the source
// verified for its addresses.
// - `commands`, the command it runs for each command type, by the name that
// file gives it. Releases give some types different commands. A type the
// file names no command for, or only a placeholder, is left out: the router
// reverts on it, except UniversalRouterV1_2_V2Support on 0x14, which does
// nothing. decode() names and reads each step as its release's command.
// - `allowRevert`, the command types it lets fail without reverting the
// transaction. Its `execute()`, in `contracts/UniversalRouter.sol`, runs each
// step through `dispatch()`, in `contracts/base/Dispatcher.sol`, and reverts
// the whole transaction when a step fails, unless the step's command byte
// has the allow-revert flag set: then it goes on to the next step. Only for
// these command types does `dispatch()` return a failure. Every other
// command, among them each V2, V3 and V4 swap and each payment of ETH or an
// ERC-20 token, succeeds or reverts the whole transaction itself, so the
// flag has no effect on it.
//
// UniversalRouterV1: https://github.com/Uniswap/universal-router/tree/cda697da221e24ca143bc4f55023694a2a16796f/contracts
const ROUTER_V1 = {
commandTypeMask: 0x1f,
commands: {
0x00: "V3_SWAP_EXACT_IN",
0x01: "V3_SWAP_EXACT_OUT",
0x02: "PERMIT2_TRANSFER_FROM",
0x03: "PERMIT2_PERMIT_BATCH",
0x04: "SWEEP",
0x05: "TRANSFER",
0x06: "PAY_PORTION",
0x08: "V2_SWAP_EXACT_IN",
0x09: "V2_SWAP_EXACT_OUT",
0x0a: "PERMIT2_PERMIT",
0x0b: "WRAP_ETH",
0x0c: "UNWRAP_WETH",
0x0d: "PERMIT2_TRANSFER_FROM_BATCH",
0x10: "SEAPORT",
0x11: "LOOKS_RARE_721",
0x12: "NFTX",
0x13: "CRYPTOPUNKS",
0x14: "LOOKS_RARE_1155",
0x15: "OWNER_CHECK_721",
0x16: "OWNER_CHECK_1155",
0x17: "SWEEP_ERC721",
0x18: "X2Y2_721",
0x19: "SUDOSWAP",
0x1a: "NFT20",
0x1b: "X2Y2_1155",
0x1c: "FOUNDATION",
0x1d: "SWEEP_ERC1155",
},
// Its NFT market calls (0x10 to 0x14, 0x18 to 0x1c) and NFT owner checks
// (0x15, 0x16).
allowRevert: [
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
],
};
// UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/tree/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts
const ROUTER_V1_2 = {
commandTypeMask: 0x3f,
commands: {
0x00: "V3_SWAP_EXACT_IN",
0x01: "V3_SWAP_EXACT_OUT",
0x02: "PERMIT2_TRANSFER_FROM",
0x03: "PERMIT2_PERMIT_BATCH",
0x04: "SWEEP",
0x05: "TRANSFER",
0x06: "PAY_PORTION",
0x08: "V2_SWAP_EXACT_IN",
0x09: "V2_SWAP_EXACT_OUT",
0x0a: "PERMIT2_PERMIT",
0x0b: "WRAP_ETH",
0x0c: "UNWRAP_WETH",
0x0d: "PERMIT2_TRANSFER_FROM_BATCH",
0x0e: "BALANCE_CHECK_ERC20",
0x10: "SEAPORT_V1_5",
0x11: "LOOKS_RARE_V2",
0x12: "NFTX",
0x13: "CRYPTOPUNKS",
0x15: "OWNER_CHECK_721",
0x16: "OWNER_CHECK_1155",
0x17: "SWEEP_ERC721",
0x18: "X2Y2_721",
0x19: "SUDOSWAP",
0x1a: "NFT20",
0x1b: "X2Y2_1155",
0x1c: "FOUNDATION",
0x1d: "SWEEP_ERC1155",
0x1e: "ELEMENT_MARKET",
0x20: "SEAPORT_V1_4",
0x21: "EXECUTE_SUB_PLAN",
0x22: "APPROVE_ERC20",
},
// BALANCE_CHECK_ERC20 (0x0e), its NFT market calls (0x10 to 0x13, 0x18 to
// 0x1c, 0x1e, 0x20), its NFT owner checks (0x15, 0x16) and
// EXECUTE_SUB_PLAN (0x21).
allowRevert: [
0x0e, 0x10, 0x11, 0x12, 0x13, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1e, 0x20, 0x21,
],
};
// UniversalRouterV2: https://github.com/Uniswap/universal-router/tree/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts
const ROUTER_V2 = {
commandTypeMask: 0x3f,
commands: {
0x00: "V3_SWAP_EXACT_IN",
0x01: "V3_SWAP_EXACT_OUT",
0x02: "PERMIT2_TRANSFER_FROM",
0x03: "PERMIT2_PERMIT_BATCH",
0x04: "SWEEP",
0x05: "TRANSFER",
0x06: "PAY_PORTION",
0x08: "V2_SWAP_EXACT_IN",
0x09: "V2_SWAP_EXACT_OUT",
0x0a: "PERMIT2_PERMIT",
0x0b: "WRAP_ETH",
0x0c: "UNWRAP_WETH",
0x0d: "PERMIT2_TRANSFER_FROM_BATCH",
0x0e: "BALANCE_CHECK_ERC20",
0x10: "V4_SWAP",
0x11: "V3_POSITION_MANAGER_PERMIT",
0x12: "V3_POSITION_MANAGER_CALL",
0x13: "V4_INITIALIZE_POOL",
0x14: "V4_POSITION_MANAGER_CALL",
0x21: "EXECUTE_SUB_PLAN",
},
// PERMIT2_PERMIT_BATCH (0x03), PERMIT2_PERMIT (0x0a), BALANCE_CHECK_ERC20
// (0x0e), V3_POSITION_MANAGER_PERMIT, V3_POSITION_MANAGER_CALL,
// V4_INITIALIZE_POOL and V4_POSITION_MANAGER_CALL (0x11 to 0x14), and
// EXECUTE_SUB_PLAN (0x21).
allowRevert: [0x03, 0x0a, 0x0e, 0x11, 0x12, 0x13, 0x14, 0x21],
};
// UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/tree/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts
// UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/tree/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts
// The three files are the same in both releases.
const ROUTER_V2_1 = {
commandTypeMask: 0x7f,
// UniversalRouterV2's, and two more.
commands: {
...ROUTER_V2.commands,
0x07: "PAY_PORTION_FULL_PRECISION",
0x40: "ACROSS_V4_DEPOSIT_V3",
},
// The same as UniversalRouterV2's.
allowRevert: ROUTER_V2.allowRevert,
};
// The Universal Router deployments on each network, by chain id, copied from // The Universal Router deployments on each network, by chain id, copied from
// the deployment files Uniswap publishes, each address named by its key there // the deployment files Uniswap publishes, each address named by its key there.
// and given with the release it runs. The files also list // The files also list `UnsupportedProtocol`, which is not a router.
// `UnsupportedProtocol`, which is not a router. //
// Beside each address is the mask that router reads a command's type with:
// its dispatcher runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits
// above the mask are the allow-revert flag (0x80) or ignored. The mask grew
// between releases. Each is `COMMAND_TYPE_MASK` in the release's
// `contracts/libraries/Commands.sol`, the same file as in the source verified
// for that address:
// - 0x1f, UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Commands.sol
// - 0x3f, UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/libraries/Commands.sol
// - 0x3f, UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/libraries/Commands.sol
// - 0x7f, UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/libraries/Commands.sol
// - 0x7f, UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/libraries/Commands.sol
const ROUTER_ADDRESSES = new Map([ const ROUTER_ADDRESSES = new Map([
[ [
NETWORKS.mainnet.chainId, NETWORKS.mainnet.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json
[ [
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", ROUTER_V1], // UniversalRouterV1 ["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", ROUTER_V2], // UniversalRouterV2 ["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", ROUTER_V2_1], // UniversalRouterV2_1_1 ["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", ROUTER_V2_1], // UniversalRouterV2_1_2 ["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2
], ],
], ],
[ [
NETWORKS.sepolia.chainId, NETWORKS.sepolia.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json
[ [
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", ROUTER_V2], // UniversalRouterV2 ["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", ROUTER_V2_1], // UniversalRouterV2_1_1 ["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", ROUTER_V2_1], // UniversalRouterV2_1_2 ["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2
], ],
], ],
]); ]);
// The release that the Universal Router deployment at `address` on the // The mask the Universal Router deployment at `address` on the network with
// network with this chain id runs, or null when `address` is not one of them. // this chain id reads a command's type with, or null when `address` is not one
// Address case is ignored. // of them. Address case is ignored.
function routerRelease(address, chainId) { function routerCommandTypeMask(address, chainId) {
const routers = ROUTER_ADDRESSES.get(chainId) || []; const routers = ROUTER_ADDRESSES.get(chainId) || [];
const wanted = (address || "").toLowerCase(); const wanted = (address || "").toLowerCase();
const router = routers.find(([a]) => a.toLowerCase() === wanted); const router = routers.find(([a]) => a.toLowerCase() === wanted);
return router ? router[1] : null; return router ? router[1] : null;
} }
// The allow-revert flag on a command byte (see ROUTER_V1).
const ALLOW_REVERT_FLAG = 0x80;
// The Uniswap V2 factory and pair init code hash the Universal Routers on each // The Uniswap V2 factory and pair init code hash the Universal Routers on each
// network were deployed with. A V2 swap step swaps through the pair its path // network were deployed with. A V2 swap step swaps through the pair its path
// starts at, which the router finds as `UniswapV2Library.pairFor()` does: the // starts at, which the router finds as `UniswapV2Library.pairFor()` does: the
@@ -267,52 +122,34 @@ function v2PairFor(chainId, tokenA, tokenB) {
); );
} }
// The Steps line's name for each command in the releases' `commands` (see // Universal Router command types, each read from its command byte with the
// ROUTER_V1). // router's mask (see ROUTER_ADDRESSES). Only the routers with the 0x7f mask
// have 0x07 and 0x40: 0x40 is above the other masks, and the other routers
// revert on 0x07, so decode() names it there as it names a command not
// listed here.
const COMMAND_NAMES = { const COMMAND_NAMES = {
V3_SWAP_EXACT_IN: "V3 Swap (Exact In)", 0x00: "V3 Swap (Exact In)",
V3_SWAP_EXACT_OUT: "V3 Swap (Exact Out)", 0x01: "V3 Swap (Exact Out)",
PERMIT2_TRANSFER_FROM: "Permit2 Transfer", 0x02: "Permit2 Transfer",
PERMIT2_PERMIT_BATCH: "Permit2 Permit Batch", 0x03: "Permit2 Permit Batch",
SWEEP: "Sweep", 0x04: "Sweep",
TRANSFER: "Transfer", 0x05: "Transfer",
PAY_PORTION: "Pay Portion", 0x06: "Pay Portion",
PAY_PORTION_FULL_PRECISION: "Pay Portion (Full Precision)", 0x07: "Pay Portion (Full Precision)",
V2_SWAP_EXACT_IN: "V2 Swap (Exact In)", 0x08: "V2 Swap (Exact In)",
V2_SWAP_EXACT_OUT: "V2 Swap (Exact Out)", 0x09: "V2 Swap (Exact Out)",
PERMIT2_PERMIT: "Permit2 Permit", 0x0a: "Permit2 Permit",
WRAP_ETH: "Wrap ETH", 0x0b: "Wrap ETH",
UNWRAP_WETH: "Unwrap WETH", 0x0c: "Unwrap WETH",
PERMIT2_TRANSFER_FROM_BATCH: "Permit2 Transfer Batch", 0x0d: "Permit2 Transfer Batch",
BALANCE_CHECK_ERC20: "Balance Check", 0x0e: "Balance Check",
V4_SWAP: "V4 Swap", 0x10: "V4 Swap",
V3_POSITION_MANAGER_PERMIT: "V3 Position Mgr Permit", 0x11: "V3 Position Mgr Permit",
V3_POSITION_MANAGER_CALL: "V3 Position Mgr Call", 0x12: "V3 Position Mgr Call",
V4_INITIALIZE_POOL: "V4 Initialize Pool", 0x13: "V4 Initialize Pool",
V4_POSITION_MANAGER_CALL: "V4 Position Mgr Call", 0x14: "V4 Position Mgr Call",
EXECUTE_SUB_PLAN: "Execute Sub-Plan", 0x21: "Execute Sub-Plan",
ACROSS_V4_DEPOSIT_V3: "Across Bridge Deposit", 0x40: "Across Bridge Deposit",
// The NFT commands of UniversalRouterV1 and UniversalRouterV1_2_V2Support,
// and the latter's APPROVE_ERC20.
SEAPORT: "Seaport",
SEAPORT_V1_4: "Seaport V1.4",
SEAPORT_V1_5: "Seaport V1.5",
LOOKS_RARE_721: "LooksRare 721",
LOOKS_RARE_1155: "LooksRare 1155",
LOOKS_RARE_V2: "LooksRare V2",
NFTX: "NFTX",
CRYPTOPUNKS: "CryptoPunks",
OWNER_CHECK_721: "Owner Check 721",
OWNER_CHECK_1155: "Owner Check 1155",
SWEEP_ERC721: "Sweep ERC-721",
SWEEP_ERC1155: "Sweep ERC-1155",
X2Y2_721: "X2Y2 721",
X2Y2_1155: "X2Y2 1155",
SUDOSWAP: "Sudoswap",
NFT20: "NFT20",
FOUNDATION: "Foundation",
ELEMENT_MARKET: "Element Market",
APPROVE_ERC20: "Approve ERC-20",
}; };
// The swap's Amount and Min. received lines land on the same approval screen, // The swap's Amount and Min. received lines land on the same approval screen,
@@ -338,8 +175,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// address this decoder did not read. // address this decoder did not read.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line for each token paid to the router, the swap's output or // The Paid to line when the swap's output is paid to the router and no later
// another, that no later step pays out of the router. // step pays it out of the router.
const STAYS_IN_ROUTER = const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)"; "The router, where it stays (no later step pays it out)";
@@ -414,16 +251,12 @@ const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded". // Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff"); const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// WETH, the token WRAP_ETH turns ETH into and UNWRAP_WETH turns into ETH, on // WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
// each network, by chain id: `weth9` in the deployment parameters // Either one counts on either network.
// V2_FACTORIES cites, which every router in ROUTER_ADDRESSES was deployed const WETH_ADDRESSES = [
// with (their constructor arguments on the explorer). "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
const WETH = new Map([ "0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
[NETWORKS.mainnet.chainId, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"], ];
[NETWORKS.sepolia.chainId, "0xfff9976782d46cc05630d1f6ebab18b2324d6b14"],
]);
// Either one counts on either network as the WETH UNWRAP_WETH turns into ETH.
const WETH_ADDRESSES = [...WETH.values()];
// Two recipient values the router reads as placeholders, not as addresses: // Two recipient values the router reads as placeholders, not as addresses:
// MSG_SENDER pays the caller of execute(), which is the transaction's sender, // MSG_SENDER pays the caller of execute(), which is the transaction's sender,
@@ -488,21 +321,6 @@ function amountText(raw, info) {
}; };
} }
// The line giving a moved token under `label`, in the shape of the Token In
// line, so the screens show it as they show that one. ETH has no address.
function tokenMovedLine(label, token, sources) {
const info = tokenInfo(token, sources);
if (!present(info.address)) {
return { label, value: info.symbol };
}
return {
label,
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
isToken: true,
};
}
// Decode PERMIT2_PERMIT (command 0x0a) input bytes. // Decode PERMIT2_PERMIT (command 0x0a) input bytes.
// ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce), // ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce),
// address spender, uint256 sigDeadline), bytes signature // address spender, uint256 sigDeadline), bytes signature
@@ -521,13 +339,7 @@ function decodePermit2(input) {
} }
} }
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. The router passes the // Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes.
// check when `owner` holds at least `minBalance` of `token`, and reads `owner`
// as written, without map(), on every release that has the command:
// - UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/base/Dispatcher.sol
// - UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol
// - UniversalRouterV2_1_1 and UniversalRouterV2_1_2, the same file at both
// commits: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/base/Dispatcher.sol
// ABI: (address owner, address token, uint256 minBalance) // ABI: (address owner, address token, uint256 minBalance)
function decodeBalanceCheck(input) { function decodeBalanceCheck(input) {
try { try {
@@ -542,7 +354,6 @@ function decodeBalanceCheck(input) {
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// address[] path, bool payerIsUser) // address[] path, bool payerIsUser)
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID. // A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
// With `payerIsUser` false the router pays the input from its own balance.
function decodeV2SwapExactIn(input) { function decodeV2SwapExactIn(input) {
try { try {
const d = coder.decode( const d = coder.decode(
@@ -556,7 +367,6 @@ function decodeV2SwapExactIn(input) {
path: d[3], path: d[3],
tokenIn: d[3][0], tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1], tokenOut: d[3][d[3].length - 1],
payerIsUser: d[4],
}; };
} catch { } catch {
return null; return null;
@@ -596,7 +406,6 @@ function decodeV3Path(pathHex) {
// Decode V3_SWAP_EXACT_IN (command 0x00) input bytes. // Decode V3_SWAP_EXACT_IN (command 0x00) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// bytes path, bool payerIsUser) // bytes path, bool payerIsUser)
// With `payerIsUser` false the router pays the input from its own balance.
function decodeV3SwapExactIn(input) { function decodeV3SwapExactIn(input) {
try { try {
const d = coder.decode( const d = coder.decode(
@@ -611,7 +420,6 @@ function decodeV3SwapExactIn(input) {
amountOutMin: d[2], amountOutMin: d[2],
tokenIn: path.tokenIn, tokenIn: path.tokenIn,
tokenOut: path.tokenOut, tokenOut: path.tokenOut,
payerIsUser: d[4],
}; };
} catch { } catch {
return null; return null;
@@ -901,10 +709,7 @@ function totalAmount(amounts) {
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes // and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// a share of the currency, so it does not name the output side as TAKE does. // a share of the currency, so it does not name the output side as TAKE does.
// `recipientUnread` is set when the step has a payout this decoder did not // `recipientUnread` is set when the step has a payout this decoder did not
// read. `settledFromRouter` is every SETTLE that the router pays from its own // read.
// balance (`payerIsUser` false), as its currency and the amount it pays: a
// quantity, CONTRACT_BALANCE (all the router holds), or null when this
// decoder cannot tell (see below).
function decodeV4Swap(input) { function decodeV4Swap(input) {
try { try {
const d = coder.decode(["bytes", "bytes[]"], input); const d = coder.decode(["bytes", "bytes[]"], input);
@@ -914,15 +719,6 @@ function decodeV4Swap(input) {
let settleToken = null; let settleToken = null;
let takeToken = null; let takeToken = null;
const takes = []; const takes = [];
// Every SETTLE paid from the router, as its currency, its amount as
// written and its index among the step's actions.
const routerSettles = [];
// The currency of every SETTLE and SETTLE_ALL.
const settledCurrencies = [];
// The index of the step's last swap action, read or not, or -1.
let lastSwapAction = -1;
// Set when an action of the step could not be read.
let actionUnread = false;
let recipientUnread = false; let recipientUnread = false;
// The step's swap actions that could be read, as decodeV4SwapAction() // The step's swap actions that could be read, as decodeV4SwapAction()
// reads them. // reads them.
@@ -942,19 +738,10 @@ function decodeV4Swap(input) {
params[i], params[i],
); );
settleToken = s[0]; settleToken = s[0];
settledCurrencies.push(s[0]);
if (!s[2]) {
routerSettles.push({
currency: s[0],
amount: s[1],
index: i,
});
}
} else if (actionId === V4_SETTLE_ALL) { } else if (actionId === V4_SETTLE_ALL) {
// SETTLE_ALL: (address currency, uint256 maxAmount) // SETTLE_ALL: (address currency, uint256 maxAmount)
const s = coder.decode(["address", "uint256"], params[i]); const s = coder.decode(["address", "uint256"], params[i]);
settleToken = s[0]; settleToken = s[0];
settledCurrencies.push(s[0]);
} else if (actionId === V4_TAKE) { } else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount) // TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode( const t = coder.decode(
@@ -981,23 +768,18 @@ function decodeV4Swap(input) {
actionId === V4_SWAP_EXACT_OUT_SINGLE || actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT actionId === V4_SWAP_EXACT_OUT
) { ) {
lastSwapAction = i;
try { try {
swaps.push(decodeV4SwapAction(actionId, params[i])); swaps.push(decodeV4SwapAction(actionId, params[i]));
} catch { } catch {
swapUndecoded = true; swapUndecoded = true;
actionUnread = true;
} }
} else {
// V4Router reverts on any action not named above, so
// none of them pays anyone. This decoder reads none.
actionUnread = true;
} }
// V4Router reverts on any action not named above, so none
// of them pays anyone.
} catch { } catch {
// Skip sub-actions we can't decode. A TAKE, TAKE_ALL or // Skip sub-actions we can't decode. A TAKE, TAKE_ALL or
// TAKE_PORTION among them is a payout this decoder did not // TAKE_PORTION among them is a payout this decoder did not
// read. // read.
actionUnread = true;
if ( if (
actionId === V4_TAKE || actionId === V4_TAKE ||
actionId === V4_TAKE_ALL || actionId === V4_TAKE_ALL ||
@@ -1008,41 +790,6 @@ function decodeV4Swap(input) {
} }
} }
// What each SETTLE paid from the router pays. A quantity or
// CONTRACT_BALANCE pays as written. A SETTLE of OPEN_DELTA (zero)
// pays whatever the step still owes of its currency when it runs.
// That is what the step's swap actions state they spend of it only
// in the plain shape of Uniswap's own routes, so only then is it
// given that amount: every action in the step was read; every swap
// action names the currency it spends and the one it buys (an
// exact-in action with an empty path swaps nothing); the SETTLE is
// the step's only SETTLE or SETTLE_ALL of its currency and comes
// after every swap action; no swap action buys that currency; and
// the swap actions that spend it state a quantity together (see
// totalAmount()). Otherwise it is given null.
const settledFromRouter = routerSettles.map((s) => {
if (s.amount !== 0n) {
return { currency: s.currency, amount: s.amount };
}
const stated = totalAmount(
swaps
.filter((w) => w.tokenIn === s.currency)
.map((w) => w.amountIn),
);
const paysWhatSwapsSpend =
!actionUnread &&
swaps.every((w) => present(w.tokenIn) && present(w.tokenOut)) &&
settledCurrencies.filter((c) => c === s.currency).length ===
1 &&
s.index > lastSwapAction &&
!swaps.some((w) => w.tokenOut === s.currency) &&
typeof stated === "bigint";
return {
currency: s.currency,
amount: paysWhatSwapsSpend ? stated : null,
};
});
// Rule 3. // Rule 3.
if (swapUndecoded) { if (swapUndecoded) {
return { return {
@@ -1051,7 +798,6 @@ function decodeV4Swap(input) {
amountIn: null, amountIn: null,
amountOutMin: null, amountOutMin: null,
takes, takes,
settledFromRouter,
recipientUnread, recipientUnread,
swapUndecoded, swapUndecoded,
hasSwapAction: true, hasSwapAction: true,
@@ -1093,7 +839,6 @@ function decodeV4Swap(input) {
amountIn, amountIn,
amountOutMin, amountOutMin,
takes, takes,
settledFromRouter,
recipientUnread, recipientUnread,
swapUndecoded, swapUndecoded,
hasSwapAction: swaps.length > 0, hasSwapAction: swaps.length > 0,
@@ -1111,9 +856,8 @@ function decodeV4Swap(input) {
// sent to any other contract is not a swap through the router, whatever that // sent to any other contract is not a swap through the router, whatever that
// contract does with it. // contract does with it.
function decode(data, toAddress, chainId, sources, fromAddress) { function decode(data, toAddress, chainId, sources, fromAddress) {
const release = routerRelease(toAddress, chainId); const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (release === null) return null; if (commandTypeMask === null) return null;
const { commandTypeMask, commands, allowRevert } = release;
try { try {
const parsed = ROUTER_IFACE.parseTransaction({ data }); const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null; if (!parsed) return null;
@@ -1186,51 +930,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router, as { token, amount }. Later steps may leave part of it // router, as { token, amount }. Later steps may leave part of it
// there: nothing here checks that they spend all of it. // there: nothing here checks that they spend all of it.
const movedIntoRouter = []; const movedIntoRouter = [];
// What each PERMIT2_TRANSFER_FROM not into the router moves from the // The tokens, lowercased, that a swap step or a V4 TAKE or
// sender, in step order, whoever counts as paid, as // TAKE_PORTION paid to the router and no later step paid out of it in
// { token, recipient, amount, to }: `to` says where it goes, the // full: a SWEEP of that token, an UNWRAP_WETH of WETH, or a TRANSFER,
// sender or the V2 pair the next swap spends, and is null when it // PAY_PORTION, PAY_PORTION_FULL_PRECISION or ACROSS_V4_DEPOSIT_V3 of
// goes to `recipient` as written. A move into the router is in // all the router holds of that token.
// `movedIntoRouter`, whose lines give its amount already. const heldByRouter = new Set();
const movedFromSender = [];
// Each token a step paid to the router that no later step paid out
// or spent in full, by its address, lowercased, ETH as the zero
// address. The router is paid what a swap step, V4 TAKE or
// TAKE_PORTION pays it, the WETH of a WRAP_ETH into it, and the ETH
// of an UNWRAP_WETH into it, in place of the WETH it held. All the
// router holds of a token is paid out or spent by a SWEEP of that
// token, an UNWRAP_WETH of WETH, a WRAP_ETH of CONTRACT_BALANCE of
// ETH, a TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION or
// ACROSS_V4_DEPOSIT_V3 of all of it, or a swap step or V4 SETTLE paid
// from the router (see spendFromRouter()). The value is the amount the
// router holds, in base units, when a WRAP_ETH stated it, otherwise
// null.
const heldByRouter = new Map();
const isRouter = (recipient) => { const isRouter = (recipient) => {
const r = recipient.toLowerCase(); const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.toLowerCase(); return r === ADDRESS_THIS || r === toAddress.toLowerCase();
}; };
// A swap step or V4 SETTLE pays `amount` of `token` from the router's
// own balance. CONTRACT_BALANCE pays all of it. Any other quantity
// counts against the amount a WRAP_ETH stated, and the steps pay all
// of it once their amounts reach that one: a swap paid in ETH wraps
// what its swap steps then spend. An amount that is not a quantity,
// such as the null decodeV4Swap() gives a SETTLE whose payment it
// cannot tell, or one of a token whose amount is not known, pays out
// nothing this decoder can see.
const spendFromRouter = (token, amount) => {
const t = token.toLowerCase();
const held = heldByRouter.get(t);
if (amount === CONTRACT_BALANCE) {
heldByRouter.delete(t);
} else if (typeof held === "bigint" && typeof amount === "bigint") {
if (amount >= held) {
heldByRouter.delete(t);
} else {
heldByRouter.set(t, held - amount);
}
}
};
// A step pays `recipient`, an address other than the sender's or the // A step pays `recipient`, an address other than the sender's or the
// router's, as written. // router's, as written.
const addPaidElsewhere = (recipient) => { const addPaidElsewhere = (recipient) => {
@@ -1244,9 +953,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const addPayee = (recipient, output) => { const addPayee = (recipient, output) => {
const r = recipient.toLowerCase(); const r = recipient.toLowerCase();
if (isRouter(recipient)) { if (isRouter(recipient)) {
if (present(output)) { if (present(output)) heldByRouter.add(output.toLowerCase());
heldByRouter.set(output.toLowerCase(), null);
}
} else if (r === MSG_SENDER || r === sender) { } else if (r === MSG_SENDER || r === sender) {
paidToSender = true; paidToSender = true;
} else { } else {
@@ -1263,9 +970,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router adds only what it holds. // router adds only what it holds.
const isHop = (i, token, recipient) => { const isHop = (i, token, recipient) => {
if (i + 1 >= commandsBytes.length) return false; if (i + 1 >= commandsBytes.length) return false;
const nextCommand = if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false;
commands[commandsBytes[i + 1] & commandTypeMask];
if (nextCommand !== "V2_SWAP_EXACT_IN") return false;
const next = decodeV2SwapExactIn(inputs[i + 1]); const next = decodeV2SwapExactIn(inputs[i + 1]);
return ( return (
next !== null && next !== null &&
@@ -1281,22 +986,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
for (let i = 0; i < commandsBytes.length; i++) { for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask; const cmdId = commandsBytes[i] & commandTypeMask;
// The command this release runs for the step (see ROUTER_V1), // Only the routers with the 0x7f mask have command 0x07 (see
// undefined for a type it names no command for. // COMMAND_NAMES).
const command = commands[cmdId]; const name =
// The router goes on to the next step if this one fails (see cmdId === 0x07 && commandTypeMask !== 0x7f
// ROUTER_V1). The flag on any other command has no effect. ? null
const allowedToFail = : COMMAND_NAMES[cmdId];
(commandsBytes[i] & ALLOW_REVERT_FLAG) !== 0 &&
allowRevert.includes(cmdId);
commandNames.push( commandNames.push(
present(command) name || "Command 0x" + cmdId.toString(16).padStart(2, "0"),
? COMMAND_NAMES[command]
: "Command 0x" + cmdId.toString(16).padStart(2, "0"),
); );
try { try {
if (command === "EXECUTE_SUB_PLAN") { if (cmdId === 0x21) {
// EXECUTE_SUB_PLAN runs a further list of commands held in // EXECUTE_SUB_PLAN runs a further list of commands held in
// its input, which this decoder does not read. The step // its input, which this decoder does not read. The step
// says so, and after this loop neither side is named. // says so, and after this loop neither side is named.
@@ -1304,13 +1005,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
hasSubPlan = true; hasSubPlan = true;
commandNames[i] += " (its steps are not shown)"; commandNames[i] += " (its steps are not shown)";
payeeUnread = true; payeeUnread = true;
} else if (command === "PERMIT2_PERMIT") { } else if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]); const p = decodePermit2(inputs[i]);
// A permit states both halves itself, so it may replace an // A permit states both halves itself, so it may replace an
// input side an earlier hop established without breaking // input side an earlier hop established without breaking
// the invariant. // the invariant.
if (p) setInput(p.token, p.amount); if (p) setInput(p.token, p.amount);
} else if (command === "PERMIT2_TRANSFER_FROM") { } else if (cmdId === 0x02) {
const p = decodePermit2TransferFrom(inputs[i]); const p = decodePermit2TransferFrom(inputs[i]);
if (!p) { if (!p) {
payeeUnread = true; payeeUnread = true;
@@ -1324,55 +1025,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// pays address(1) and address(2) as written (see // pays address(1) and address(2) as written (see
// decodePermit2TransferFrom()). // decodePermit2TransferFrom()).
addPaidElsewhere(p.recipient); addPaidElsewhere(p.recipient);
movedFromSender.push({ ...p, to: null });
} else if (isRouter(p.recipient)) { } else if (isRouter(p.recipient)) {
movedIntoRouter.push(p); movedIntoRouter.push(p);
} else if (isHop(i, p.token, p.recipient)) { } else if (!isHop(i, p.token, p.recipient)) {
movedFromSender.push({
...p,
to: "The V2 pair the next swap spends",
});
} else {
addPayee(p.recipient); addPayee(p.recipient);
movedFromSender.push({
...p,
to:
p.recipient.toLowerCase() === MSG_SENDER
? "The sender"
: null,
});
} }
} else if (command === "BALANCE_CHECK_ERC20") { } else if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
if (!b) { // The router passes this check whenever the owner holds at
// The router still runs a check whose input does not // least minBalance, so a zero one guarantees nothing and
// decode, such as an owner word with a nonzero top // does not replace a minimum an earlier step stated. Any
// byte, of which it reads the last 20 bytes, so the // other minBalance sets the output side as a swap does.
// owner may be any address. It sets no side, and the // It pays nobody.
// step says so. if (b && !(b.minBalance === 0n && present(minOutput))) {
commandNames[i] += " (could not be decoded)"; setOutput(b.token, b.minBalance);
} else if (b.owner.toLowerCase() !== sender) {
// The router reads the owner as written (see
// decodeBalanceCheck()), so address(1) and address(2)
// are not the sender and the router here. A check of
// any address but the sender's says nothing about
// what the user receives: it sets no side, and the
// step says so.
commandNames[i] += " (of another address)";
} else {
// The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees
// nothing and does not replace a minimum an earlier
// step stated. A check the router lets fail guarantees
// nothing either, so it is read as a zero one. Any
// other minBalance sets the output side as a swap
// does. It pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance;
if (!(minBalance === 0n && present(minOutput))) {
setOutput(b.token, minBalance);
}
} }
} else if (command === "V3_SWAP_EXACT_IN") { } else if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]); const s = decodeV3SwapExactIn(inputs[i]);
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountIn); setInputOnce(s.tokenIn, s.amountIn);
@@ -1380,30 +1048,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// the last swap step determines the final output token // the last swap step determines the final output token
// and minimum received amount. // and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin); setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
if (!isHop(i, s.tokenOut, s.recipient)) { if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut); addPayee(s.recipient, s.tokenOut);
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
} }
} else if (command === "V2_SWAP_EXACT_IN") { } else if (cmdId === 0x08) {
const s = decodeV2SwapExactIn(inputs[i]); const s = decodeV2SwapExactIn(inputs[i]);
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountIn); setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin); setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
if (!isHop(i, s.tokenOut, s.recipient)) { if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut); addPayee(s.recipient, s.tokenOut);
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
} }
} else if (command === "V2_SWAP_EXACT_OUT") { } else if (cmdId === 0x09) {
// Buys exactly amountOut and spends at most amountInMax. // Buys exactly amountOut and spends at most amountInMax.
hasV2ExactOut = true; hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]); const s = decodeV2SwapExactOut(inputs[i]);
@@ -1416,7 +1078,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
payeeUnread = true; payeeUnread = true;
} }
} else if (command === "SWEEP") { } else if (cmdId === 0x04) {
const s = decodeSweep(inputs[i]); const s = decodeSweep(inputs[i]);
if (s) { if (s) {
addPayee(s.recipient); addPayee(s.recipient);
@@ -1427,9 +1089,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
payeeUnread = true; payeeUnread = true;
} }
} else if ( } else if (
command === "TRANSFER" || cmdId === 0x05 ||
command === "PAY_PORTION" || cmdId === 0x06 ||
command === "PAY_PORTION_FULL_PRECISION" (cmdId === 0x07 && commandTypeMask === 0x7f)
) { ) {
const t = decodeTransfer(inputs[i]); const t = decodeTransfer(inputs[i]);
if (t) { if (t) {
@@ -1440,16 +1102,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// PAY_PORTION_FULL_PRECISION pays all the router // PAY_PORTION_FULL_PRECISION pays all the router
// holds of the token, as SWEEP does. // holds of the token, as SWEEP does.
const all = const all =
command === "TRANSFER" cmdId === 0x05
? CONTRACT_BALANCE ? CONTRACT_BALANCE
: command === "PAY_PORTION" : cmdId === 0x06
? 10000n ? 10000n
: 10n ** 18n; : 10n ** 18n;
// A TRANSFER of ETH has no such amount: the router // A TRANSFER of ETH has no such amount: the router
// sends ETH as written, so a TRANSFER of // sends ETH as written, so a TRANSFER of
// CONTRACT_BALANCE of ETH always reverts. // CONTRACT_BALANCE of ETH always reverts.
const ethTransfer = const ethTransfer =
command === "TRANSFER" && cmdId === 0x05 &&
t.token === t.token ===
"0x0000000000000000000000000000000000000000"; "0x0000000000000000000000000000000000000000";
if ( if (
@@ -1462,7 +1124,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
payeeUnread = true; payeeUnread = true;
} }
} else if (command === "ACROSS_V4_DEPOSIT_V3") { } else if (cmdId === 0x40) {
// Only the routers with the 0x7f mask read a command type
// of 0x40.
const a = decodeAcrossV4DepositV3(inputs[i]); const a = decodeAcrossV4DepositV3(inputs[i]);
if (a) { if (a) {
bridgeDeposit = true; bridgeDeposit = true;
@@ -1485,7 +1149,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
payeeUnread = true; payeeUnread = true;
} }
} else if (command === "WRAP_ETH") { } else if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]); const w = decodeWrapEth(inputs[i]);
if (w) { if (w) {
setInputOnce( setInputOnce(
@@ -1493,36 +1157,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount, w.amount,
); );
addPayee(w.recipient); addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
if (w.amount === CONTRACT_BALANCE) {
heldByRouter.delete(
"0x0000000000000000000000000000000000000000",
);
}
// Into the router, the router holds the WETH. Its
// amount is known when the step states it and the
// router held no WETH before.
if (isRouter(w.recipient) && w.amount > 0n) {
const weth = WETH.get(chainId);
heldByRouter.set(
weth,
w.amount === CONTRACT_BALANCE ||
heldByRouter.has(weth)
? null
: w.amount,
);
}
} else { } else {
payeeUnread = true; payeeUnread = true;
} }
} else if (command === "V4_SWAP") { } else if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
if (v4) { if (v4) {
// Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays.
for (const s of v4.settledFromRouter) {
spendFromRouter(s.currency, s.amount);
}
for (const t of v4.takes) { for (const t of v4.takes) {
if (!isHop(i, t.currency, t.recipient)) { if (!isHop(i, t.currency, t.recipient)) {
addPayee(t.recipient, t.currency); addPayee(t.recipient, t.currency);
@@ -1562,41 +1202,29 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
setInputOnce(v4.tokenIn, v4.amountIn); setInputOnce(v4.tokenIn, v4.amountIn);
setOutput(v4.tokenOut, v4.amountOutMin); setOutput(v4.tokenOut, v4.amountOutMin);
} }
} else if (command === "UNWRAP_WETH") { } else if (cmdId === 0x0c) {
hasUnwrapWeth = true; hasUnwrapWeth = true;
const u = decodeUnwrapWeth(inputs[i]); const u = decodeUnwrapWeth(inputs[i]);
if (u) { if (u) {
addPayee(u.recipient); addPayee(u.recipient);
// It pays out all the router's WETH, as ETH. Into the if (!isRouter(u.recipient)) {
// router, the router then holds as ETH the WETH a WETH_ADDRESSES.forEach((w) =>
// step paid it. heldByRouter.delete(w),
const heldWeth = WETH_ADDRESSES.some((w) =>
heldByRouter.has(w),
);
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w));
if (isRouter(u.recipient) && heldWeth) {
heldByRouter.set(
"0x0000000000000000000000000000000000000000",
null,
); );
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
} }
} else { } else {
// Any other step may pay an address this decoder does not // Any other command may pay an address this decoder does
// read: V3_SWAP_EXACT_OUT, PERMIT2_TRANSFER_FROM_BATCH and // not read: V3_SWAP_EXACT_OUT and
// the NFT commands among them, and a type the release // PERMIT2_TRANSFER_FROM_BATCH among them.
// names no command for.
payeeUnread = true; payeeUnread = true;
} }
} catch { } catch {
// Skip commands we can't decode. One may pay any address. // Skip commands we can't decode. One may pay any address.
payeeUnread = true; payeeUnread = true;
} }
if (allowedToFail) {
commandNames[i] += " (allowed to fail)";
}
} }
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens // The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
@@ -1710,26 +1338,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}); });
} }
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
details.push({
label: "Moved from the sender",
value: amountText(m.amount, tokenInfo(m.token, sources))
.display,
});
details.push(tokenMovedLine("Token moved", m.token, sources));
details.push(
present(m.to)
? { label: "Moved to", value: m.to }
: {
label: "Moved to",
value: m.recipient,
address: m.recipient,
},
);
}
// Keyed on the address, not the symbol: a token absent from the // Keyed on the address, not the symbol: a token absent from the
// bundled list has no symbol, and gating the line on one dropped it // bundled list has no symbol, and gating the line on one dropped it
// entirely, leaving a Min. received figure with nothing saying what is // entirely, leaving a Min. received figure with nothing saying what is
@@ -1763,11 +1371,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A Paid to line names each other address paid, and says when a // A Paid to line names each other address paid, and says when a
// step pays the bridge, when one moves the sender's tokens into the // step pays the bridge, when one moves the sender's tokens into the
// router, when a token stays in the router, and when a payout could // router, when the output stays in the router, and when a payout
// not be read. The output is received by the sender only when some // could not be read. The output is received by the sender only when
// step pays the sender and there is no such line. When no step names // some step pays the sender and there is no such line. When no step
// anyone paid, one line says so. `paidTo` holds these lines and the // names anyone paid, one line says so. `paidTo` holds these lines and
// token line after each line about a token in the router. // the token line after each move into the router.
const outputHeld = const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase()); present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({ const paidTo = paidElsewhere.map((address) => ({
@@ -1778,6 +1386,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (bridgeDeposit) { if (bridgeDeposit) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
} }
// Each move into the router is followed by a line giving the moved
// token's address, in the shape of the Token In line, so the screens
// show it as they show that one.
for (const m of movedIntoRouter) { for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources); const info = tokenInfo(m.token, sources);
paidTo.push({ paidTo.push({
@@ -1787,30 +1398,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display + amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)", " from the sender (later steps may not spend all of it)",
}); });
paidTo.push( paidTo.push({
tokenMovedLine("Token moved into the router", m.token, sources), label: "Token moved into the router",
); value: info.symbol
? info.symbol + " (" + m.token + ")"
: m.token,
address: m.token,
isToken: true,
});
} }
if (outputHeld) { if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
} }
// Any other token the router still holds after the last step, such
// as one a swap step bought on the way to the output, what a V4
// SETTLE took beyond what the step's swap spent, or the WETH of a
// WRAP_ETH, stays there too, and its line is followed by the token's.
for (const token of heldByRouter.keys()) {
if (present(outputToken) && token === outputToken.toLowerCase()) {
continue;
}
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
paidTo.push(
tokenMovedLine(
"Token moved into the router",
getAddress(token),
sources,
),
);
}
if (payeeUnread) { if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY }); paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
} }
+3 -17
View File
@@ -442,25 +442,11 @@ describe("a confirmation screen whose From is not the selected address", () => {
expect(Transaction.from(signed).from).toBe(derived(1).address); expect(Transaction.from(signed).from).toBe(derived(1).address);
}); });
// The refusal's sentence is fixed in index.html, in a box of its own that
// holds its space from the first paint, so showing it moves nothing.
test("nothing is sent when the selected wallet does not hold it", async () => { test("nothing is sent when the selected wallet does not hold it", async () => {
expect(await confirmSend(derived(5).address, 0)).toBeUndefined(); expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
expect(node("confirm-from-address-error").style.visibility).toBe( expect(node("confirm-tx-password-error").textContent).toBe(
"visible", "The selected wallet does not hold the From address above, " +
); "so nothing was sent. Please go back and send again.",
expect(node("confirm-tx-password-error").textContent).toBe("");
// Reviewing a send again draws the screen again, which hides it.
confirmTx.show({
from: derived(0).address,
to: RECIPIENT,
amount: "0.5",
token: "ETH",
});
await settle();
expect(node("confirm-from-address-error").style.visibility).toBe(
"hidden",
); );
}); });
}); });
-270
View File
@@ -1,270 +0,0 @@
// The approval screen for a site's Sepolia token transfer while Ethereum
// Mainnet is active.
//
// The screen named the token, and scaled its amount, from the active network's
// tracked tokens and balances, so a token the wallet tracks on Sepolia read as
// an unknown token with its amount in base units, and the screens after it
// labelled the amount `?` (https://git.eeqj.de/sneak/AutistMask/issues/629).
// It now uses what the wallet knows of the tokens on the network the
// transaction is for.
//
// Its address links opened the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/631). They now open the
// explorer of the network the transaction is for.
//
// Driven through the real approval screen, password and Confirm included,
// against a minimal DOM stub in the shape tests/swapStatusAmount.test.js uses.
// The vault is mocked so that no password has to be hashed.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { decryptWithPassword } = require("../src/shared/vault");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
// A well-known test phrase, and its first address.
const PHRASE = "test test test test test test test test test test test junk";
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the bundled token list does not name.
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
// FROM sends 1234.5 of the 6-decimal token to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("approve-tx-decoded-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
// Open the approval screen, with Ethereum Mainnet active and FROM holding
// `tokenBalances` there, for the site's transaction sending TRANSFER_DATA to
// the token, on Sepolia unless `chainId` names another chain. Confirm answers
// the signed transaction with a failure, which leads to the error screen.
async function showTokenTransfer(
tokenBalances = [],
chainId = NETWORKS.sepolia.chainId,
) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
return reply({
error: "The transaction could not be sent.",
retryable: false,
});
}
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: TOKEN,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
},
};
state.networkId = "mainnet";
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances },
],
},
];
state.currentView = null;
txStatus.init({ doRefreshAndRender: () => {} });
approval.init({});
await approval.show(1);
}
async function confirm() {
decryptWithPassword.mockResolvedValue(PHRASE);
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
}
beforeEach(() => {
state.trackedTokens = [];
state.networkTrackedTokens = {};
});
describe("a token tracked on Sepolia only", () => {
beforeEach(() => {
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
});
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
await showTokenTransfer();
expect(node("approve-tx-to").innerHTML).toContain(
'<div class="font-bold mb-1">TDOL</div>',
);
expect(decodedLine("Token")).toContain(
'<div class="font-bold">TDOL</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
test("labels the amount on the error screen that follows", async () => {
await showTokenTransfer();
await confirm();
expect(state.currentView).toBe("error-tx");
expect(node("error-tx-summary").textContent).toBe("1234.5000 TDOL");
});
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("a token tracked and held on mainnet only is not named, and its amount is in base units", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
await showTokenTransfer([
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
]);
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
expect(decodedLine("Token")).toContain(
'<div class="font-bold">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
// The From and Contract lines, and the decoded Token and Recipient lines.
test("every address links to the Sepolia explorer", async () => {
await showTokenTransfer();
const sepolia = NETWORKS.sepolia.explorerUrl;
expect(node("approve-tx-from").innerHTML).toContain(
`href="${sepolia}/address/${FROM}"`,
);
expect(node("approve-tx-to").innerHTML).toContain(
`href="${sepolia}/address/${TOKEN}"`,
);
expect(decodedLine("Token")).toContain(
`href="${sepolia}/address/${TOKEN}"`,
);
expect(decodedLine("Recipient")).toContain(
`href="${sepolia}/address/${RECIPIENT}"`,
);
for (const id of [
"approve-tx-from",
"approve-tx-to",
"approve-tx-decoded-details",
]) {
expect(node(id).innerHTML).not.toContain(NETWORKS.mainnet.explorerUrl);
}
});
// Chain id 1337 names no network here, so it has no explorer to link to.
test("a transaction on a chain id that names no network is shown with no address links", async () => {
await showTokenTransfer([], "0x539");
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-network").textContent).toBe(
"Unknown network (chain id 1337)",
);
expect(node("approve-tx-from").innerHTML).toContain(FROM);
for (const id of [
"approve-tx-from",
"approve-tx-to",
"approve-tx-decoded-details",
]) {
expect(node(id).innerHTML).not.toContain("href=");
}
});
+1 -8
View File
@@ -154,14 +154,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the transaction detail renders it", () => { test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], { reopenedOn("settings", ["main", "transaction"], {
viewData: { viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } },
tx: {
hash: "0xdead",
from: ADDRESS,
to: ADDRESS,
chainId: "0x1",
},
},
}); });
goBack(); goBack();
expect(calls).toEqual(["transactionDetail"]); expect(calls).toEqual(["transactionDetail"]);
-415
View File
@@ -1,415 +0,0 @@
// 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");
});
});
+6 -167
View File
@@ -11,115 +11,20 @@
// //
// The check is driven here with a fake connected signer rather than a real // The check is driven here with a fake connected signer rather than a real
// one: populateTransaction() returns the fees the node would have produced, // one: populateTransaction() returns the fees the node would have produced,
// and sendTransaction() records whether the send actually happened. // and sendTransaction() records whether the send actually happened. The real
// // DOM path around it — reading the fee error into the reserved errors box — is
// The last test drives the real confirmation screen instead, with only the // covered by the Chrome e2e suite.
// 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.
// What the stub node quotes for that test: a gas limit of 150,000,000 at a globalThis.chrome = {
// maximum fee of 2 gwei per gas, 0.3 ETH in all. And the signed transactions storage: { local: { get: async () => ({}), set: async () => {} } },
// it was handed.
const mockNode = {
gasLimit: 150000000n,
maxFeePerGas: 2000000000n,
broadcast: [],
}; };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.network = network;
}
async getNetwork() {
return this.network;
}
async getBalance() {
return actual.parseEther("5");
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getFeeData() {
return {
maxFeePerGas: mockNode.maxFeePerGas,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 1000000000n,
};
}
async estimateGas() {
return mockNode.gasLimit;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
// The screens a send that was not refused goes on to.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.fetch = jest.fn(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
// A stub DOM: every id resolves to a recording element. const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
querySelectorAll: () => [],
remove() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
};
const { Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const { populateVerifyAndSend } = confirmTx;
const { const {
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
MAX_TOTAL_FEE, MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify"); } = require("../src/shared/approvalVerify");
@@ -193,69 +98,3 @@ describe("populateVerifyAndSend enforces the combined fee bound", () => {
expect(signer.sent[0].gasLimit).toBe(under.gasLimit); expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
}); });
}); });
describe("Sign & Send on the confirmation screen", () => {
const PRIVATE_KEY = "0x" + "11".repeat(32);
const PASSWORD = "correct horse battery staple";
let encryptedSecret;
beforeAll(async () => {
await loadState();
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
function el(id) {
return global.document.getElementById(id);
}
function shown(id) {
return el(id).style.visibility === "visible";
}
// show() reads the balance and estimates the fee without awaiting
// either; this lets both finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("refuses a gas limit over its bound at an ordinary fee in the box for a refused gas limit or fee, and sends nothing", async () => {
expect(mockNode.gasLimit).toBeGreaterThan(MAX_GAS_LIMIT);
expect(mockNode.gasLimit * mockNode.maxFeePerGas).toBeLessThan(
MAX_TOTAL_FEE,
);
const from = new Wallet(PRIVATE_KEY).address;
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: from }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.init({});
confirmTx.show({
from,
to: RECIPIENT,
ensName: null,
amount: "1",
token: "ETH",
tokenSymbol: null,
tokenDecimals: null,
max: false,
});
await settle();
expect(el("btn-confirm-send").disabled).toBe(false);
expect(shown("confirm-fee-bound-error")).toBe(false);
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
expect(shown("confirm-fee-bound-error")).toBe(true);
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
});
});
+20 -7
View File
@@ -333,7 +333,7 @@ function expectNativeTransferOn(networkId) {
"Native " + symbol + " transfer", "Native " + symbol + " transfer",
); );
expect(el("confirm-amount").textContent).toBe( expect(el("confirm-amount").textContent).toBe(
"1.0000 " + symbol + usdOn(networkId, "$2,000.00"), "1 " + symbol + usdOn(networkId, "$2,000.00"),
); );
expect(el("confirm-balance").textContent).toBe( expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"), "5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
@@ -440,8 +440,12 @@ describe.each([
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled(); expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled(); expect(txStatus.showError).not.toHaveBeenCalled();
expect(shown("confirm-network-changed-error")).toBe(true); expect(el("confirm-errors").style.visibility).toBe("visible");
expect(shown("confirm-fee-bound-error")).toBe(false); expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
}); });
}, },
); );
@@ -493,7 +497,10 @@ describe.each([
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled(); expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled(); expect(txStatus.showError).not.toHaveBeenCalled();
expect(shown("confirm-network-changed-error")).toBe(true); expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
}); });
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => { test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
@@ -572,7 +579,10 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(shown("confirm-network-changed-error")).toBe(true); expect(errors()).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
}); });
const tokenSend = { const tokenSend = {
@@ -619,7 +629,10 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(shown("confirm-network-changed-error")).toBe(true); expect(errors()).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
}); });
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => { test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
@@ -649,7 +662,7 @@ describe.each([
`href="${explorerUrl}/address/${TOKEN}"`, `href="${explorerUrl}/address/${TOKEN}"`,
); );
expect(el("confirm-amount").textContent).toBe( expect(el("confirm-amount").textContent).toBe(
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"), "10 USDC" + usdOn(reviewedOn, "$10.00"),
); );
expect(el("confirm-balance").textContent).toBe( expect(el("confirm-balance").textContent).toBe(
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"), "50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
-1
View File
@@ -192,7 +192,6 @@ describe("the wait, success and error screens", () => {
amount: "0.0000", amount: "0.0000",
token: "ETH", token: "ETH",
tokenSymbol: null, tokenSymbol: null,
chainId: "0x1",
}; };
const transfer = { ...creation, to: RECIPIENT }; const transfer = { ...creation, to: RECIPIENT };
+4 -7
View File
@@ -226,9 +226,8 @@ const RPC_RESULTS = {
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from // The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the // to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the
// reserve and the estimate collapse to the same number, and the gate tests // reserve and the estimate collapse to the same number, and the gate tests
// stop being able to distinguish them. opts.baseFeeWei replaces its base fee, // stop being able to distinguish them.
// read at request time like every other fixture switch. function latestBlock() {
function latestBlock(opts) {
return { return {
hash: "0x" + "11".repeat(32), hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32), parentHash: "0x" + "22".repeat(32),
@@ -240,7 +239,7 @@ function latestBlock(opts) {
gasUsed: "0xf4240", gasUsed: "0xf4240",
miner: STUB_COUNTERPARTY, miner: STUB_COUNTERPARTY,
extraData: "0x", extraData: "0x",
baseFeePerGas: opts.baseFeeWei || hex(BASE_FEE_WEI), baseFeePerGas: hex(BASE_FEE_WEI),
transactions: [], transactions: [],
}; };
} }
@@ -482,7 +481,7 @@ function rpcReply(req, opts, report) {
}); });
} }
if (req.method === "eth_getBlockByNumber") { if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock(opts) }); return Object.assign(envelope, { result: latestBlock() });
} }
// The end of the dApp transaction round trip: the raw signed transaction // The end of the dApp transaction round trip: the raw signed transaction
// the background hands to the node. It is recorded verbatim so a test can // the background hands to the node. It is recorded verbatim so a test can
@@ -626,8 +625,6 @@ function traceEnabled(raw) {
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance; * @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
* defaults to zero, which is what every test that predates the funded * defaults to zero, which is what every test that predates the funded
* fixture expects. * fixture expects.
* @param {string} [opts.baseFeeWei] hex wei answered as the latest block's
* baseFeePerGas; defaults to BASE_FEE_WEI. Read at request time.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a * @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal. * node-side refusal.
* @param {boolean} [opts.holdBalance] hold every batch containing an * @param {boolean} [opts.holdBalance] hold every batch containing an
+2 -276
View File
@@ -1824,65 +1824,6 @@ test("the Settings assertions above all ran (#229)", async (env) => {
); );
}); });
// ------------------------------------------------ testnet banner (#511)
// This suite runs a release build, so the red banner at the top reads
// "[TESTNET]" on Sepolia and is absent on mainnet. A network switch in
// Settings must change it while Settings is still on screen; it used to
// change only when the next screen came up
// (https://git.eeqj.de/sneak/AutistMask/issues/511).
// The banner's text, or null when there is none, once the switch to `net`
// has finished. The switch's flash is the last thing its handler does, and
// showView() clears the flash, so the banner read while that flash is up is
// the one the switch left on the screen it was made from.
async function bannerAfterSwitch(page, net) {
await page.waitForFunction(
(text) => document.getElementById("flash-msg").textContent === text,
"Switched to " + net.name + ".",
);
return page.evaluate(() => {
const banner = document.getElementById("debug-banner");
return {
text: banner === null ? null : banner.textContent,
onSettings: !document
.getElementById("view-settings")
.classList.contains("hidden"),
};
});
}
test("a network switch in Settings shows and removes the testnet banner at once (#511)", async (env) => {
const { mainnet, sepolia } = NETWORKS;
await openSettings(env.page);
assert(
(await env.page.locator("#debug-banner").count()) === 0,
"a banner is up on mainnet before the switch",
);
await env.page.selectOption("#settings-network", "sepolia");
let after = await bannerAfterSwitch(env.page, sepolia);
assert(after.onSettings, "Settings left the screen on the switch");
assert(
after.text === "[TESTNET]",
"after the switch to Sepolia, Settings shows the banner " +
JSON.stringify(after.text) +
', expected "[TESTNET]"',
);
await env.page.selectOption("#settings-network", "mainnet");
after = await bannerAfterSwitch(env.page, mainnet);
assert(after.onSettings, "Settings left the screen on the switch back");
assert(
after.text === null,
"after the switch back to mainnet, Settings still shows the banner " +
JSON.stringify(after.text),
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-main");
});
// ------------------------------------------------ dust threshold (#233) // ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this // The popup size README documents the UI as designed for. Pages in this
@@ -2291,8 +2232,7 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in // What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the // one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, how tall the whole view is, and where the password field and // fee block says, how tall the whole view is, and where Sign & Send sits in it.
// Sign & Send sit in it.
async function confirmState(page) { async function confirmState(page) {
return page.evaluate(() => { return page.evaluate(() => {
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
@@ -2305,9 +2245,6 @@ async function confirmState(page) {
}; };
return { return {
height: el("view-confirm-tx").getBoundingClientRect().height, height: el("view-confirm-tx").getBoundingClientRect().height,
passwordTop:
el("confirm-tx-password").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
sendTop: sendTop:
el("btn-confirm-send").getBoundingClientRect().top - el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top, el("view-confirm-tx").getBoundingClientRect().top,
@@ -2325,12 +2262,6 @@ async function confirmState(page) {
amountFeeError: shown("confirm-amount-fee-error"), amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"), gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"), feeUnknownError: shown("confirm-fee-unknown-error"),
// Its sentence is wrapped across lines in index.html.
feeBoundError: shown("confirm-fee-bound-error")
? el("confirm-fee-bound-error")
.textContent.replace(/\s+/g, " ")
.trim()
: "",
sendDisabled: el("btn-confirm-send").disabled, sendDisabled: el("btn-confirm-send").disabled,
}; };
}); });
@@ -2974,10 +2905,9 @@ async function goToTokenConfirm(env) {
amount: TOKEN_AMOUNT, amount: TOKEN_AMOUNT,
}); });
await waitForEstimate(env.page); await waitForEstimate(env.page);
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
const shown = await shownAmount(env.page); const shown = await shownAmount(env.page);
assert( assert(
shown === "0.2500 " + STUB_TOKEN.symbol, shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
"the confirmation screen is not showing the amount that was entered: " + "the confirmation screen is not showing the amount that was entered: " +
JSON.stringify(shown), JSON.stringify(shown),
); );
@@ -3127,210 +3057,6 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
} }
}); });
// ------------------------- a send Sign & Send refuses moves nothing (#583)
//
// README, No Layout Shift. Sign & Send refuses a send on the confirmation
// screen when the active network changed since review and when its gas limit
// or fee is over its bound, and each refusal's sentence sits in a box that held
// its space from the first paint. This drives the total fee refusal, the longer
// of the two sentences.
// The base fee the stub node quotes once Sign & Send is pressed. ethers asks
// twice it plus the 1 gwei priority fee per gas, 50,001 gwei, so the 21,000-gas
// send would allow a fee of about 1.05 ETH, over the 1 ETH this wallet signs
// for (MAX_TOTAL_FEE in src/shared/approvalVerify.js).
const OVER_BOUND_BASE_FEE_WEI = 25000n * 10n ** 9n;
test("a send refused for its fee leaves the password field and Sign & Send where they were (#583)", async (env) => {
env.errors.expect("confirmTx logging the refused send", /send refused/);
try {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await waitForEstimate(env.page);
const before = await confirmState(env.page);
assert(
!before.sendDisabled && before.feeBoundError === "",
"the screen is not ready to send: " + JSON.stringify(before),
);
// Only now, with the screen drawn and its fee estimated, does the
// node quote a fee over the bound.
env.routeOpts.baseFeeWei = toHexWei(OVER_BOUND_BASE_FEE_WEI);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await env.page.waitForFunction(
() =>
getComputedStyle(
document.getElementById("confirm-fee-bound-error"),
).visibility === "visible",
null,
{ timeout: 60000 },
);
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused send: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send over the fee bound reached the RPC",
);
assert(
after.feeBoundError ===
"The gas limit or fee the RPC endpoint quoted for this" +
" transaction is more than this wallet will sign for, so" +
" it was not sent. Please go back and try again.",
"wrong fee refusal: " + JSON.stringify(after.feeBoundError),
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
} finally {
env.routeOpts.baseFeeWei = undefined;
await backToAddress(env.page);
}
});
// --------- a send from an address the selected wallet does not hold (#609)
//
// The selection is saved apart from the confirmation screen, so the popup can
// reopen onto the screen with a wallet selected that does not hold its From
// address, and Sign & Send then refuses the send. Another popup selecting the
// key wallet would save that selection; here it is written into storage, and
// the popup reopened.
test("a send from an address the selected wallet does not hold leaves the password field and Sign & Send where they were (#609)", async (env) => {
try {
// From the HD wallet's address.
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
// The screen's own save lands first, so it cannot write over the
// selection below.
await waitForPersisted(
env.page,
"currentView",
"confirm-tx",
"before selecting the key wallet",
);
await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
const record = {
...r.autistmask,
selectedWallet: 1,
selectedAddress: 0,
};
chrome.storage.local.set(
{ autistmask: record },
resolve,
);
});
}),
);
await reopenPopup(env, "confirm-tx");
await waitForEstimate(env.page);
// The refusal shown anywhere on the screen, not only in its own box:
// shown on the password error line instead, the test fails on the
// moves checked below rather than on the wait for it.
const refusal = env.page
.locator("#view-confirm-tx")
.getByText("does not hold the From address above")
.filter({ visible: true });
const before = await confirmState(env.page);
assert(
!before.sendDisabled && (await refusal.count()) === 0,
"the screen is not ready to send: " + JSON.stringify(before),
);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await refusal.waitFor({ timeout: 60000 });
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused From address: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send from an address the selected wallet does not hold" +
" reached the RPC",
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
// Only now, where the refusal is shown. Its sentence is wrapped across
// lines in index.html.
const shownIn = await refusal.evaluate((el) => ({
id: el.id,
text: el.textContent.replace(/\s+/g, " ").trim(),
}));
assert(
shownIn.id === "confirm-from-address-error" &&
shownIn.text ===
"The selected wallet does not hold the From address above," +
" so nothing was sent. Please go back and send again.",
"wrong From address refusal: " + JSON.stringify(shownIn),
);
} finally {
// A reopened popup restores no Send screen beneath this one, so Back
// lands on the selected address. Then Home, and the HD wallet's
// address, which the later tests send from.
if (await env.page.isVisible("#view-confirm-tx")) {
await env.page.click("#btn-confirm-back");
}
if (await env.page.isVisible("#view-send")) {
await env.page.click("#btn-send-back");
}
await visible(env.page, "#view-address");
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await openAddressDetail(env.page);
}
});
// ------------------------------ the wait for a receipt ending in error (#315) // ------------------------------ the wait for a receipt ending in error (#315)
// //
// README.md (WaitTx) documents two ways the wait ends on the error screen: a // README.md (WaitTx) documents two ways the wait ends on the error screen: a
+3 -3
View File
@@ -245,7 +245,7 @@ describe.each([
test("the value", async () => { test("the value", async () => {
await confirmSend("0.1"); await confirmSend("0.1");
expect(text("confirm-type")).toBe("Native " + symbol + " transfer"); expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
expect(text("confirm-amount")).toBe("0.1000 " + symbol); expect(text("confirm-amount")).toBe("0.1 " + symbol);
await approveTx(NETWORKS[networkId].chainId); await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol); expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
}); });
@@ -429,10 +429,10 @@ describe.each([
await global.document await global.document
.getElementById("btn-confirm-send") .getElementById("btn-confirm-send")
.handlers.get("click")(); .handlers.get("click")();
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol); expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
state.networkId = activeNetworkId; state.networkId = activeNetworkId;
txStatus.restoreWait(); txStatus.restoreWait();
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol); expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
}); });
test("the transaction detail screen's type line and fee", async () => { test("the transaction detail screen's type line and fee", async () => {
+2 -52
View File
@@ -441,17 +441,6 @@ const CONTRACT = [
views: ["error-tx"], views: ["error-tx"],
restored: false, restored: false,
}, },
// A chain id naming no network has no explorer to link to.
{
value: { hash: "0x1", to: ADDRESS, chainId: "0x5" },
views: ["success-tx"],
restored: false,
},
{
value: { message: "boom", to: ADDRESS, chainId: "0x5" },
views: ["error-tx"],
restored: false,
},
// transaction passes on `data.tx`. // transaction passes on `data.tx`.
{ {
value: { tx: { hash: "0x1" } }, value: { tx: { hash: "0x1" } },
@@ -465,26 +454,6 @@ const CONTRACT = [
from: ADDRESS, from: ADDRESS,
to: ADDRESS, to: ADDRESS,
contractAddress: 42, contractAddress: 42,
chainId: "0x1",
},
},
views: ["transaction"],
restored: false,
},
// A transaction whose chain id is missing or names no network has
// no explorer to link to and no Blockscout to be read from.
{
value: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS } },
views: ["transaction"],
restored: false,
},
{
value: {
tx: {
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
chainId: "0x5",
}, },
}, },
views: ["transaction"], views: ["transaction"],
@@ -508,19 +477,6 @@ const CONTRACT = [
views: ["confirm-tx"], views: ["confirm-tx"],
restored: false, restored: false,
}, },
// The amount line splits the amount to cut it to four decimals.
{
value: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: 42,
},
},
views: ["confirm-tx"],
restored: false,
},
// A chain id naming no network has no explorer to link to. // A chain id naming no network has no explorer to link to.
{ {
value: { value: {
@@ -843,13 +799,7 @@ const WELL_FORMED_DATA = {
message: "boom", message: "boom",
to: ADDRESS, to: ADDRESS,
decoded: { details: [{ address: TOKEN_ADDRESS }] }, decoded: { details: [{ address: TOKEN_ADDRESS }] },
tx: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: null },
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
contractAddress: null,
chainId: "0x1",
},
pendingTx: { pendingTx: {
token: "ETH", token: "ETH",
from: ADDRESS, from: ADDRESS,
@@ -858,7 +808,7 @@ const WELL_FORMED_DATA = {
}, },
pendingWait: { pendingWait: {
hash: "0x1", hash: "0x1",
txInfo: { to: ADDRESS, amount: "1", chainId: "0x1" }, txInfo: { to: ADDRESS, amount: "1" },
broadcastTime: BROADCAST_TIME, broadcastTime: BROADCAST_TIME,
}, },
}; };
+1 -1
View File
@@ -58,7 +58,7 @@ function restoringOnto(view, viewData) {
test("the transaction screen's Blockscout request reaches the stub", async () => { test("the transaction screen's Blockscout request reaches the stub", async () => {
const page = await bootPopup( const page = await bootPopup(
restoringOnto("transaction", { restoringOnto("transaction", {
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, chainId: "0x1" }, tx: { hash: "0x1", from: ADDRESS, to: ADDRESS },
}), }),
); );
+6 -12
View File
@@ -306,8 +306,7 @@ describe("Max on an ETH send", () => {
openSend(); openSend();
await pressMax(); await pressMax();
await review(); await review();
expect(confirmed.amount).toBe(maxAfter(20n * GWEI)); expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
expect(text("confirm-amount")).toBe("1.2341 ETH");
expect(el("confirm-errors").innerHTML).toBe(""); expect(el("confirm-errors").innerHTML).toBe("");
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden"); expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(canSend()).toBe(true); expect(canSend()).toBe(true);
@@ -323,16 +322,14 @@ describe("Max on an ETH send", () => {
// Kept, the Send screen's amount would be refused for want of funds. // Kept, the Send screen's amount would be refused for want of funds.
mockNode.feeData = fees(30n * GWEI); mockNode.feeData = fees(30n * GWEI);
await review(); await review();
expect(confirmed.amount).toBe(maxAfter(30n * GWEI)); expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
expect(text("confirm-amount")).toBe("1.2339 ETH");
expect(canSend()).toBe(true); expect(canSend()).toBe(true);
// And falls when the screen is shown again, as on reopening the popup. // And falls when the screen is shown again, as on reopening the popup.
mockNode.feeData = fees(10n * GWEI); mockNode.feeData = fees(10n * GWEI);
confirmTx.restore(); confirmTx.restore();
await settle(); await settle();
expect(confirmed.amount).toBe(maxAfter(10n * GWEI)); expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
expect(text("confirm-amount")).toBe("1.2343 ETH");
expect(canSend()).toBe(true); expect(canSend()).toBe(true);
}); });
@@ -529,8 +526,7 @@ describe("Max on an ETH send", () => {
el("send-amount").handlers.get("input")(); el("send-amount").handlers.get("input")();
mockNode.feeData = fees(30n * GWEI); mockNode.feeData = fees(30n * GWEI);
await review(); await review();
expect(confirmed.amount).toBe("0.5"); expect(text("confirm-amount")).toBe("0.5 ETH");
expect(text("confirm-amount")).toBe("0.5000 ETH");
}); });
}); });
@@ -544,8 +540,7 @@ describe("Max on a token send", () => {
await pressMax(); await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012"); expect(el("send-amount").value).toBe("1234.567890123456789012");
await review(); await review();
expect(confirmed.amount).toBe("1234.567890123456789012"); expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(canSend()).toBe(true); expect(canSend()).toBe(true);
}); });
@@ -558,8 +553,7 @@ describe("Max on a token send", () => {
await pressMax(); await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012"); expect(el("send-amount").value).toBe("1234.567890123456789012");
await review(); await review();
expect(confirmed.amount).toBe("1234.567890123456789012"); expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(canSend()).toBe(true); expect(canSend()).toBe(true);
}); });
+1 -6
View File
@@ -225,12 +225,7 @@ describe("a popup already open when the stored profile becomes unreadable", () =
viewData: { viewData: {
pendingWait: { pendingWait: {
hash: "0x1", hash: "0x1",
txInfo: { txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
to: ADDRESS,
amount: "1",
token: "ETH",
chainId: "0x1",
},
broadcastTime: Date.now(), broadcastTime: Date.now(),
}, },
}, },
+6 -123
View File
@@ -8,14 +8,6 @@
// tracks. It decodes against the address the explorer reports the transaction // tracks. It decodes against the address the explorer reports the transaction
// was sent to. // was sent to.
// //
// Its links open the explorer of the network the transaction was sent on,
// whichever network is active
// (https://git.eeqj.de/sneak/AutistMask/issues/584), and the transaction is
// read from that network's Blockscout, the one Settings has for it
// (https://git.eeqj.de/sneak/AutistMask/issues/623). Its decoded details name
// the token, and scale its amount, from what the wallet knows of that
// network's tokens (https://git.eeqj.de/sneak/AutistMask/issues/629).
//
// The row is the one the real history parser makes from the explorer's answer, // The row is the one the real history parser makes from the explorer's answer,
// opened through transactionDetail.show(), which is what a click on the row // opened through transactionDetail.show(), which is what a click on the row
// calls. Driven against a minimal DOM stub in the shape // calls. Driven against a minimal DOM stub in the shape
@@ -30,10 +22,10 @@ jest.mock("../src/shared/log", () => ({
warnf: () => {}, warnf: () => {},
errorf: () => {}, errorf: () => {},
}, },
debugFetch: async (url) => { debugFetch: async (url) => ({
mockAsked.push(url); ok: true,
return { ok: true, json: async () => mockExplorerAnswer(url) }; json: async () => mockExplorerAnswer(url),
}, }),
setRuntimeDebug: () => {}, setRuntimeDebug: () => {},
isDebug: () => false, isDebug: () => false,
})); }));
@@ -43,13 +35,10 @@ globalThis.chrome = {
}; };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { fetchRecentTransactions } = require("../src/shared/transactions"); const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
const EXPLORER = "https://explorer.example/api/v2"; const EXPLORER = "https://explorer.example/api/v2";
// A Blockscout that knows nothing of the transaction.
const OTHER_EXPLORER = "https://other-explorer.example/api/v2";
const HOLDER = "0x0000000000000000000000000000000000000a11"; const HOLDER = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD"; const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the wallet tracks and the bundled token list does not name. // A token the wallet tracks and the bundled token list does not name.
@@ -62,9 +51,6 @@ const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)", "function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]); ]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
// Every URL asked of a Blockscout, in order.
const mockAsked = [];
// The explorer's answers: the transfer in HOLDER's token transfers, and the // The explorer's answers: the transfer in HOLDER's token transfers, and the
// transaction itself, sent to the token contract. // transaction itself, sent to the token contract.
function mockExplorerAnswer(url) { function mockExplorerAnswer(url) {
@@ -91,11 +77,7 @@ function mockExplorerAnswer(url) {
}; };
} }
if (url === EXPLORER + "/transactions/" + TX_HASH) { if (url === EXPLORER + "/transactions/" + TX_HASH) {
return { return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA };
to: { hash: TOKEN },
raw_input: TRANSFER_DATA,
block_number: 100,
};
} }
return { items: [] }; return { items: [] };
} }
@@ -158,7 +140,6 @@ beforeEach(() => {
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER; state.blockscoutUrl = EXPLORER;
state.networkEndpoints = {};
state.wallets = [ state.wallets = [
{ {
name: "Main", name: "Main",
@@ -167,18 +148,13 @@ beforeEach(() => {
}, },
]; ];
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }]; state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
state.networkTrackedTokens = {};
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
}); });
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => { test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
const [row] = await fetchRecentTransactions( const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1);
HOLDER,
EXPLORER,
NETWORKS.mainnet.chainId,
);
expect(row.to).toBe(RECIPIENT); expect(row.to).toBe(RECIPIENT);
expect(row.contractAddress).toBe(TOKEN.toLowerCase()); expect(row.contractAddress).toBe(TOKEN.toLowerCase());
@@ -198,96 +174,3 @@ test("a token transfer's decoded details name the token contract and the amount
'<div class="font-bold">1234.5000 TDOL</div>', '<div class="font-bold">1234.5000 TDOL</div>',
); );
}); });
// Mainnet is active, and its Blockscout knows nothing of the transaction. The
// row was read from the Blockscout Settings remembers for Sepolia, and the
// screen reads the transaction from there too, so every link on it is drawn.
test("with mainnet active, a Sepolia transaction is read from Sepolia's Blockscout and its links open the Sepolia explorer", async () => {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
const sepolia = "https://sepolia.etherscan.io";
const links = (id) =>
[...node(id).innerHTML.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
expect(links("tx-detail-hash")).toEqual([`${sepolia}/tx/${TX_HASH}`]);
expect(links("tx-detail-from")).toEqual([`${sepolia}/address/${HOLDER}`]);
expect(links("tx-detail-to")).toEqual([`${sepolia}/address/${RECIPIENT}`]);
expect(links("tx-detail-token-contract")).toEqual([
`${sepolia}/token/${TOKEN.toLowerCase()}`,
]);
expect(links("tx-detail-block")).toEqual([`${sepolia}/block/100`]);
expect(links("tx-detail-calldata-details")).toEqual([
`${sepolia}/address/${TOKEN}`,
`${sepolia}/address/${RECIPIENT}`,
]);
});
test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia transaction is asked of Sepolia's default Blockscout", async () => {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
mockAsked.length = 0;
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
expect(mockAsked).toEqual([
NETWORKS.sepolia.defaultBlockscoutUrl + "/transactions/" + TX_HASH,
]);
});
// Open the Sepolia transfer's detail screen with mainnet active, and wait for
// its decoded details.
async function showSepoliaTransferWithMainnetActive() {
state.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("with mainnet active, a Sepolia token transfer's decoded details name the token tracked on Sepolia and its amount at its scale", async () => {
state.trackedTokens = [];
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).toContain('<div class="font-bold">TDOL</div>');
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
// The same contract address on mainnet is another token, so what the wallet
// tracks and holds there names nothing on Sepolia.
test("with mainnet active, a Sepolia token transfer's decoded details take nothing from mainnet's tracked tokens or balances", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
state.wallets[0].addresses[0].tokenBalances = [
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
];
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).not.toContain("MDOL");
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
+3 -3
View File
@@ -313,9 +313,9 @@ describe("Back after a switch from the Settings gear", () => {
boot.node("confirm-tx-password").value = "any password"; boot.node("confirm-tx-password").value = "any password";
await boot.click("btn-confirm-send"); await boot.click("btn-confirm-send");
expect(boot.visibleViews()).toEqual(["confirm-tx"]); expect(boot.visibleViews()).toEqual(["confirm-tx"]);
expect( expect(boot.node("confirm-errors").innerHTML).toContain(
boot.node("confirm-network-changed-error").style.visibility, "The active network changed after this transaction was reviewed",
).toBe("visible"); );
// Refused before the password is even tried. // Refused before the password is even tried.
expect(boot.text("confirm-tx-password-error")).toBe(""); expect(boot.text("confirm-tx-password-error")).toBe("");
}); });
+3 -176
View File
@@ -29,13 +29,8 @@ jest.mock("../src/shared/log", () => ({
})); }));
const mockReceiptLookup = jest.fn(); const mockReceiptLookup = jest.fn();
// The RPC URL and network of every node a wait was given.
const mockNodes = [];
jest.mock("../src/shared/balances", () => ({ jest.mock("../src/shared/balances", () => ({
getProvider: (rpcUrl, networkId) => { getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
mockNodes.push({ rpcUrl, networkId });
return { getTransactionReceipt: mockReceiptLookup };
},
refreshBalances: jest.fn(), refreshBalances: jest.fn(),
})); }));
@@ -112,7 +107,6 @@ global.chrome = { storage };
const txStatus = require("../src/popup/views/txStatus"); const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const { log } = require("../src/shared/log"); const { log } = require("../src/shared/log");
@@ -124,7 +118,6 @@ const TX_INFO = {
amount: "0.0050", amount: "0.0050",
token: "ETH", token: "ETH",
tokenSymbol: null, tokenSymbol: null,
chainId: NETWORKS.mainnet.chainId,
}; };
// True when a view element is not hidden. // True when a view element is not hidden.
@@ -144,14 +137,8 @@ beforeEach(() => {
jest.setSystemTime(new Date("2026-08-11T12:00:00Z")); jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
elements.clear(); elements.clear();
mockReceiptLookup.mockReset(); mockReceiptLookup.mockReset();
mockNodes.length = 0;
log.errorf.mockClear(); log.errorf.mockClear();
state.networkId = "mainnet";
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
state.networkEndpoints = {};
state.wallets = []; state.wallets = [];
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null; state.currentView = null;
@@ -362,14 +349,14 @@ describe("WaitTx persistence across popup close", () => {
// out of restoreView(). // out of restoreView().
{ {
hash: TX_HASH, hash: TX_HASH,
txInfo: { ...TX_INFO, to: 42 }, txInfo: { to: 42, amount: "0.0050" },
broadcastTime: Date.now(), broadcastTime: Date.now(),
}, },
// Otherwise complete but an array: only Array.isArray() rejects // Otherwise complete but an array: only Array.isArray() rejects
// it, since typeof [] is "object" and the fields are present. // it, since typeof [] is "object" and the fields are present.
{ {
hash: TX_HASH, hash: TX_HASH,
txInfo: Object.assign([], TX_INFO), txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
broadcastTime: Date.now(), broadcastTime: Date.now(),
}, },
{ {
@@ -377,18 +364,6 @@ describe("WaitTx persistence across popup close", () => {
txInfo: { to: RECIPIENT }, txInfo: { to: RECIPIENT },
broadcastTime: Date.now(), broadcastTime: Date.now(),
}, },
// Otherwise complete but for a chain id naming no network: there
// is no node to ask for the receipt, and no explorer to link to.
{
hash: TX_HASH,
txInfo: { ...TX_INFO, chainId: undefined },
broadcastTime: Date.now(),
},
{
hash: TX_HASH,
txInfo: { ...TX_INFO, chainId: "0x5" },
broadcastTime: Date.now(),
},
]) { ]) {
state.viewData = { pendingWait: bad }; state.viewData = { pendingWait: bad };
expect(txStatus.restoreWait()).toBe(false); expect(txStatus.restoreWait()).toBe(false);
@@ -524,154 +499,6 @@ describe("WaitTx when the popup closes", () => {
}); });
}); });
// The popup can reopen onto WaitTx after Settings switched the network. The
// wait asked the active network's node for the receipt, never found it, and
// reported a timeout, and its links went to the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/584).
describe("WaitTx for a Sepolia transaction with mainnet active", () => {
const SEPOLIA_NODE = "https://sepolia.node.example.invalid";
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
beforeEach(() => {
state.rpcUrl = "https://mainnet.node.example.invalid";
state.networkEndpoints = { sepolia: { rpcUrl: SEPOLIA_NODE } };
});
test("the receipt is asked of the Sepolia node Settings has", async () => {
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
state.viewData = {
pendingWait: {
txInfo: sepoliaTx,
hash: TX_HASH,
broadcastTime: Date.now(),
},
};
expect(txStatus.restoreWait()).toBe(true);
await jest.advanceTimersByTimeAsync(0);
expect(mockNodes).toEqual([
{ rpcUrl: SEPOLIA_NODE, networkId: "sepolia" },
]);
expect(state.currentView).toBe("success-tx");
});
test("the recipient and hash link to the Sepolia explorer", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait(sepoliaTx, TX_HASH);
expect(getElement("wait-tx-to").innerHTML).toContain(
`href="https://sepolia.etherscan.io/address/${RECIPIENT}"`,
);
expect(getElement("wait-tx-hash").innerHTML).toContain(
`href="https://sepolia.etherscan.io/tx/${TX_HASH}"`,
);
});
});
// SuccessTx and ErrorTx follow WaitTx and describe the same transaction, but
// their links went to the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/622). Each test checks the
// screen again after rendering it from its stored view data alone, as a
// reopened popup does.
describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active", () => {
const SEPOLIA_EXPLORER = "https://sepolia.etherscan.io";
const TOKEN = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
test("the recipient, block and hash on SuccessTx link to the Sepolia explorer", async () => {
function expectSepoliaLinks() {
expect(getElement("success-tx-to").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
expect(getElement("success-tx-block").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/block/21000000"`,
);
expect(getElement("success-tx-hash").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
);
}
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait(sepoliaTx, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectSepoliaLinks();
elements.clear();
txStatus.renderSuccess();
expectSepoliaLinks();
});
test("the addresses in SuccessTx's decoded action link to the Sepolia explorer", async () => {
function expectSepoliaLinks() {
const html = getElement("success-tx-decoded").innerHTML;
expect(html).toContain(
`href="${SEPOLIA_EXPLORER}/address/${TOKEN}"`,
);
expect(html).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
}
const decoded = {
name: "Transfer",
details: [
{ label: "Token", address: TOKEN, isToken: true },
{ label: "Recipient", address: RECIPIENT },
],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectSepoliaLinks();
elements.clear();
txStatus.renderSuccess();
expectSepoliaLinks();
});
// https://git.eeqj.de/sneak/AutistMask/issues/629
test("SuccessTx's decoded action names the token from the tokens tracked on Sepolia", async () => {
function expectTokenNamed() {
expect(getElement("success-tx-decoded").innerHTML).toContain(
'<div class="font-bold">TDOL</div>',
);
}
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
};
const decoded = {
name: "Transfer",
details: [{ label: "Token", address: TOKEN, isToken: true }],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectTokenNamed();
elements.clear();
txStatus.renderSuccess();
expectTokenNamed();
});
test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => {
function expectSepoliaLinks() {
expect(getElement("error-tx-to").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
expect(getElement("error-tx-hash").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
);
}
txStatus.showError(sepoliaTx, TX_HASH, "The transaction failed.");
expectSepoliaLinks();
elements.clear();
txStatus.renderError();
expectSepoliaLinks();
});
});
describe("wait-tx is a view the popup may reopen onto", () => { describe("wait-tx is a view the popup may reopen onto", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView() // The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills // refuses any view not in the set, so dropping "wait-tx" from it kills
+9 -19
View File
@@ -739,9 +739,7 @@ describe("uniswap decoder", () => {
}); });
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so // Paid in ETH, with an exact-out step, the last swap step buys WETH, so
// UNWRAP_WETH makes the output ETH. That step is paid by the user, so the // UNWRAP_WETH makes the output ETH.
// USDC the exact-out step bought stays in the router, and the minimum is
// not received (https://git.eeqj.de/sneak/AutistMask/issues/606).
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => { test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked( solidityPacked(
@@ -769,7 +767,7 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. paid out").value).toBe("0.4000 ETH"); expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
}); });
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too. // Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
@@ -989,23 +987,13 @@ describe("uniswap decoder", () => {
); );
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does // A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
// not read. With the allow-revert flag (0x80) the command byte is 0xa1, // not read. With the allow-revert flag (0x80) the command byte is 0xa1.
// and the router lets the step fail
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
test.each([ test.each([
{ { flag: "without", command: 0x21 },
flag: "without", { flag: "with", command: 0xa1 },
command: 0x21,
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
flag: "with",
command: 0xa1,
steps: "Execute Sub-Plan (its steps are not shown) (allowed to fail)",
},
])( ])(
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown", "an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
({ command, steps }) => { ({ command }) => {
const subPlan = encodeSubPlan("0x00", [ const subPlan = encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [ encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR, USDT_ADDR,
@@ -1025,7 +1013,9 @@ describe("uniswap decoder", () => {
USER_ADDR, USER_ADDR,
); );
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe(steps); expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)",
);
// The sub-plan names both currencies, so the lines must not say // The sub-plan names both currencies, so the lines must not say
// the calldata did not. // the calldata did not.
expect(detail(result, "Token In").value).toBe( expect(detail(result, "Token In").value).toBe(
File diff suppressed because it is too large Load Diff
+7 -15
View File
@@ -186,14 +186,14 @@ function node(id) {
return globalThis.document.getElementById(id); return globalThis.document.getElementById(id);
} }
// Open the transaction detail screen with `activeNetworkId` active, for the // Open the transaction detail screen with Sepolia active, for the
// Sepolia transaction the explorer reports sent to `sentTo`, and wait for its // transaction the explorer reports sent to `sentTo`, and wait for its
// decoded details. // decoded details.
async function showDetailSentTo(sentTo, activeNetworkId) { async function showDetailSentTo(sentTo) {
mockSentTo = sentTo; mockSentTo = sentTo;
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.networkId = activeNetworkId; state.networkId = "sepolia";
state.blockscoutUrl = "https://explorer.example/api/v2"; state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
@@ -216,29 +216,21 @@ async function showDetailSentTo(sentTo, activeNetworkId) {
describe("execute() calldata on the transaction detail screen", () => { describe("execute() calldata on the transaction detail screen", () => {
test("sent to a router on the active network, it is a swap", async () => { test("sent to a router on the active network, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER, "sepolia"); await showDetailSentTo(SEPOLIA_ROUTER);
expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router",
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/623
test("sent to a router on its network, with the other network active, it is a swap", async () => {
await showDetailSentTo(SEPOLIA_ROUTER, "mainnet");
expect(node("tx-detail-calldata-details").innerHTML).toContain( expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router", "Swap via Uniswap Universal Router",
); );
}); });
test("sent to an ordinary address, it is not decoded", async () => { test("sent to an ordinary address, it is not decoded", async () => {
await showDetailSentTo(ORDINARY, "sepolia"); await showDetailSentTo(ORDINARY);
expect(node("tx-detail-calldata-action").textContent).toBe("execute"); expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
}); });
test("sent to a router of the other network only, it is not decoded", async () => { test("sent to a router of the other network only, it is not decoded", async () => {
await showDetailSentTo(MAINNET_ROUTER, "sepolia"); await showDetailSentTo(MAINNET_ROUTER);
expect(node("tx-detail-calldata-action").textContent).toBe("execute"); expect(node("tx-detail-calldata-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
+10 -11
View File
@@ -71,8 +71,6 @@ function exactInParams(currencyIn, path, amountIn, amountOutMin) {
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token, // BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
// uint256 minBalance). It names the output token and nothing about the input. // uint256 minBalance). It names the output token and nothing about the input.
// Its owner is USER, the transaction's sender: a check of any other address
// names no output token.
function balanceCheck(token, minBalance) { function balanceCheck(token, minBalance) {
return coder.encode( return coder.encode(
["address", "address", "uint256"], ["address", "address", "uint256"],
@@ -80,13 +78,8 @@ function balanceCheck(token, minBalance) {
); );
} }
// A transaction from USER.
function decode(data) {
return uniswap.decode(data, ROUTER, "0x1", {}, USER);
}
function detail(data, label) { function detail(data, label) {
const decoded = decode(data); const decoded = uniswap.decode(data, ROUTER, "0x1", {});
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
@@ -120,7 +113,9 @@ describe("an execute() whose input currency never decoded", () => {
}); });
test("does not name ETH in the swap title either", () => { test("does not name ETH in the swap title either", () => {
expect(decode(data()).name).toBe("Uniswap Swap"); expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Uniswap Swap",
);
}); });
}); });
@@ -130,7 +125,9 @@ describe("an execute() that names only an output token", () => {
test("refuses the input rather than defaulting it to ETH", () => { test("refuses the input rather than defaulting it to ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL); expect(detail(data(), "Token In").value).toBe(REFUSAL);
expect(decode(data()).name).toBe("Uniswap Swap"); expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Uniswap Swap",
);
}); });
test("still states the output it did establish", () => { test("still states the output it did establish", () => {
@@ -178,7 +175,9 @@ describe("native ETH in, which V4 spells as the zero address", () => {
expect(detail(data, "Token In").value).toBe("ETH (native)"); expect(detail(data, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH"); expect(detail(data, "Amount").value).toBe("1.0000 ETH");
expect(decode(data).name).toBe("Swap ETH → USDT"); expect(uniswap.decode(data, ROUTER, "0x1", {}).name).toBe(
"Swap ETH → USDT",
);
}); });
test("WRAP_ETH is still ETH at 18 decimals", () => { test("WRAP_ETH is still ETH at 18 decimals", () => {
+1 -1
View File
@@ -475,7 +475,7 @@ describe("every way to a signature or the private key refuses a defective wallet
to: RECIPIENT, to: RECIPIENT,
value: "0x0", value: "0x0",
data: "0x", data: "0x",
chainId: "0x1", chainId: 1,
nonce: 0, nonce: 0,
gasLimit: "21000", gasLimit: "21000",
maxFeePerGas: "1000000000", maxFeePerGas: "1000000000",