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

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

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

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

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

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

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

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

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

Model: opus-5-5
2026-10-19 14:03:52 +02:00
18 changed files with 2273 additions and 301 deletions
+156 -78
View File
@@ -430,6 +430,15 @@ reserve while sitting on the same side of the estimate, so swapping the two in
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only.
It also covers a send that Sign & Send refuses for its network fee: the stub
node quotes an ordinary fee while the confirmation screen is drawn and one over
the bound when Sign & Send asks again, and the refusal must broadcast nothing
and leave the password field and Sign & Send where they were. It checks the same
for a send refused because the selected wallet does not hold its From address:
with the confirmation screen showing a send from the HD wallet, the key wallet
is selected by writing it straight into extension storage, as another popup
selecting it would save it, and the popup is reopened onto that screen.
It also covers both ways the wait for a sent transaction's receipt ends on the
error screen: lookups that still find no receipt 60 seconds after the broadcast,
and six lookups in a row that fail. Each must show its own message, and Done
@@ -1077,23 +1086,29 @@ The rule and its exception live in `src/shared/amountDisplay.js` as
confirmation screens display goes through the floored one — the ERC-20 amount,
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`). Both screens render a network fee through
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
rather than its truncated figure, so the same fee reads the same on both, USD
value included. Balances are stored exactly (`src/shared/balances.js`), whatever
decimals a token declares, so a balance below the floor reaches these screens as
it is. The history list (`src/shared/transactions.js`) uses the unfloored one:
the transaction detail view is the authoritative record and already shows exact
precision. The balance lists use neither: they round to four places with
`toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal
rule is unchanged everywhere else, including for amounts at or above the floor
on the approval screens.
`Current balance` (`src/popup/views/send.js`), and the amount, balance and
network fee on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`), which hands the wait, success and error
screens the amount as it showed it. The transaction is still signed for the
exact amount, and its USD value is priced from it. Both screens render a network
fee through `formatFee()` in `src/popup/views/helpers.js`, which prices the
exact fee in USD rather than its truncated figure, so the same fee reads the
same on both, USD value included. Balances are stored exactly
(`src/shared/balances.js`), whatever decimals a token declares, so a balance
below the floor reaches these screens as it is. The history list
(`src/shared/transactions.js`) uses the unfloored one: the transaction detail
view is the authoritative record and already shows exact precision. The balance
lists use neither: they round to four places with `toFixed(4)` (`balanceLine()`
in `src/popup/views/helpers.js`). The 4-decimal rule is unchanged everywhere
else, including for amounts at or above the floor on the approval screens.
The floor applies only where the token's scale is known. Where it is not, the
approval screen states base units instead of a quantity — see Unknown token
scale below — and no truncation happens at all.
On the approval screen and the wait, success and error screens that carry its
amount forward, the floor applies only where the token's scale is known. Where
it is not, they state base units instead of a quantity — see Unknown token scale
below — and no truncation happens at all. On ConfirmTx and the wait, success and
error screens that follow it, the amount of a token whose scale is unknown,
which cannot be sent, is the text from Send's amount field cut to four decimals
with the same floor (`.5` reads `.5000`).
**Specific Exception — unknown token scale:** Calldata carries base units and no
scale, so every amount on the dApp approval screen needs the token's `decimals`.
@@ -1193,7 +1208,13 @@ number. They can read:
guarantees nothing: it sets `Token Out` and `Min. received` only when the
output side holds no minimum, not even a zero one, at the point the check is
reached, and otherwise leaves the current token and figure in place. A nonzero
`minBalance` sets both lines, as a swap step does.
`minBalance` sets both lines, as a swap step does. Only a check of the
transaction's sender's own balance sets either line; a check of any other
address sets neither (see Transaction Decoding). A `BALANCE_CHECK_ERC20` step
that the router lets fail without reverting the transaction (see Transaction
Decoding) guarantees nothing either, whatever its `minBalance`, and is read as
one whose `minBalance` is zero. A minimum stated by a V4 swap step the router
lets fail is likewise read as zero.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -1752,14 +1773,18 @@ view would leave a wallet one click from deletion.
- Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
filled in is worked out again from this screen's own fee estimate when it
arrives, as the balance minus the reserve, and the transaction is signed
with that estimate's fee fields, so a fee fetched again at signing cannot
exceed what the amount leaves behind. The address keeps whatever part of
the reserve the transaction does not use. If the balance could not be read
or no longer covers the fee, the amount is left as it was and one of the
errors below blocks the send
- Amount: value + symbol (USD in parentheses), the value to four decimal
places with the nonzero floor, as on TxApproval (see Display Consistency),
however Send's amount field wrote it (`.5` and `0.5` both read `0.5000`).
For a token whose scale is unknown, which cannot be sent, the value is the
text from Send's amount field cut the same way (`.5` reads `.5000`). An
ETH amount Send's "Max" filled in is worked out again from this screen's
own fee estimate when it arrives, as the balance minus the reserve, and
the transaction is signed with that estimate's fee fields, so a fee
fetched again at signing cannot exceed what the amount leaves behind. The
address keeps whatever part of the reserve the transaction does not use.
If the balance could not be read or no longer covers the fee, the amount
is left as it was and one of the errors below blocks the send
- Your balance: the From address's balance on the network its transaction
was reviewed on, read from that network's RPC endpoint each time the
screen is shown, so a screen the popup reopens onto states and checks that
@@ -1793,6 +1818,10 @@ view would leave a wallet one click from deletion.
estimated, and it says the wallet does not know how many decimal places
the token uses and that the transaction cannot be sent; for any other
failure it asks the user to go back and try again
- Three more reserved error boxes, made visible when "Sign & Send" refuses a
send because the active network changed, because the selected wallet does
not hold the From address, or because of its gas limit or fee (see
Transitions), so the refusal moves nothing
- Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors, while the balance is being read,
@@ -1810,7 +1839,7 @@ view would leave a wallet one click from deletion.
- "Sign & Send" after the active network was switched in Settings since this
transaction was reviewed → nothing is signed; "The active network changed
after this transaction was reviewed, so it was not sent. Please go back
and review it again." in the errors box, no screen change. After a switch,
and review it again." in its own box, no screen change. After a switch,
"Back" from Settings draws this screen again from the saved transaction,
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
@@ -1824,13 +1853,21 @@ view would leave a wallet one click from deletion.
fee estimate and the contract and never-sent recipient checks ask that
network's RPC endpoint, and the Etherscan label check asks that network's
explorer
- "Sign & Send" (correct password) when the gas limit, a fee per gas or the
total fee the RPC endpoint fills in at signing is over its bound in
`assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is
signed; "The gas limit or fee the RPC endpoint quoted for this transaction
is more than this wallet will sign for, so it was not sent. Please go back
and try again." in its own box, the same sentence for all three, no screen
change
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not
hold the From address → full-sentence error on the password error line,
nothing sent (no screen change)
hold the From address → nothing is signed; "The selected wallet does not
hold the From address above, so nothing was sent. Please go back and send
again." in its own box, no screen change
- "Sign & Send" (correct password, but the From address's key is refused,
see Data Model) → nothing is signed → **ErrorTx** saying why
- "Back" → **Send**
@@ -2329,9 +2366,11 @@ view would leave a wallet one click from deletion.
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
- Value: amount in ETH (4 decimal places, USD in parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
in parentheses), with the gas limit and the fee per gas in gwei below it
- Value: amount in ETH (4 decimal places with the nonzero floor, USD in
parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places with
the nonzero floor, USD in parentheses), with the gas limit and the fee per
gas in gwei below it
- Network and nonce
- Raw data: full calldata displayed inline (shown if present)
- Password input and an error line
@@ -2950,18 +2989,44 @@ Currently supported:
in the parameter shape deployed on mainnet and Sepolia, not in the newer one
on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`. Each step is named by its command type,
which the decoder reads from the command byte with the mask the router at that
address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag
(`0x80`) therefore does not change a step's name. 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
`V4 Swap (could not be decoded)`. Each step is named and read as the command
that the release of the router at that address runs for its command type, as
that release's `contracts/libraries/Commands.sol` names it. The decoder reads
the type from the command byte with the mask that router uses: `0x1f` for
`UniversalRouterV1`, `0x3f` for `UniversalRouterV1_2_V2Support` and
`UniversalRouterV2`, and `0x7f` for `UniversalRouterV2_1_1` and
`UniversalRouterV2_1_2`. The allow-revert flag (`0x80`) therefore does not
change the command a step is read as. Releases run different commands for some
types: on `UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` to
`0x13` are NFT commands, where the later routers run `V4_SWAP`,
`V3_POSITION_MANAGER_PERMIT`, `V3_POSITION_MANAGER_CALL` and
`V4_INITIALIZE_POOL`. The decoder does not read those routers' NFT commands,
and lists each by its name there, for example `Seaport` for `SEAPORT` (`0x10`
on `UniversalRouterV1`). A type a release names no command for is listed by
its number, for example `Command 0x0e` on `UniversalRouterV1`, which has no
`BALANCE_CHECK_ERC20` and reverts on it. The router reverts the whole
transaction when a step fails, unless the step's command byte has that flag
set and its command is one the router lets fail, as each release's
`contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`,
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`, `PERMIT2_PERMIT`,
`PERMIT2_PERMIT_BATCH`, `BALANCE_CHECK_ERC20`, `V3_POSITION_MANAGER_PERMIT`,
`V3_POSITION_MANAGER_CALL`, `V4_INITIALIZE_POOL`, `V4_POSITION_MANAGER_CALL`
and `EXECUTE_SUB_PLAN`; on `UniversalRouterV1_2_V2Support`,
`BALANCE_CHECK_ERC20`, `EXECUTE_SUB_PLAN` and its NFT market calls and NFT
owner checks; on `UniversalRouterV1`, its NFT market calls and NFT owner
checks. Such a step may then fail while the transaction goes on to the next
step, and it is listed with ` (allowed to fail)` after its name, for example
`Balance Check (allowed to fail)`. On every other command, among them each V2,
V3 and V4 swap and each payment of ETH or an ERC-20 token, the flag has no
effect: the step succeeds or the whole transaction reverts, so the step reads
as it does without the flag. A `BALANCE_CHECK_ERC20` the router lets fail
guarantees nothing, so it is read as one whose `minBalance` is zero. Only the
routers with the `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`),
listed as `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`),
listed as `Across Bridge Deposit`; the other routers revert on `0x07`, and it
is listed there as `Command 0x07`. The decoder does not read the commands
inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they
may spend the user's tokens or pay the user. That step is listed as
`Execute Sub-Plan (its steps are not shown)`, and whatever the other steps
state, the summary names no swap direction, `Token In` and `Token Out` read
`Unknown (could not be decoded)`, and there is no `Amount` or `Min. received`
@@ -2979,46 +3044,59 @@ Currently supported:
`ACROSS_V4_DEPOSIT_V3` pays the bridge, which pays the deposit's recipient on
another network and refunds a deposit nobody fills to its depositor; the
router passes both on as written, so `address(1)` and `address(2)` there are
addresses, and each that is not the transaction's sender is named. When a swap
step, `TAKE` or `TAKE_PORTION` pays the router the swap's output token, the
output stays in the router unless a later step pays all the router holds of it
to anyone but the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is
WETH, a `TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a
addresses, and each that is not the transaction's sender is named. A
`BALANCE_CHECK_ERC20` pays nobody: the router checks that its owner holds at
least `minBalance` of its token, and reads the owner as written, so
`address(1)` and `address(2)` there are addresses too. A check of any address
but the transaction's sender's says nothing about what the user receives: it
sets neither `Token Out` nor the minimum, and it is listed as
`Balance Check (of another address)`. A check whose input does not decode,
such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router the swap's output token, the output stays in
the router unless a later step pays all the router holds of it to anyone but
the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is WETH, a
`TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a
`PAY_PORTION_FULL_PRECISION` of 1e18 of that token, or an
`ACROSS_V4_DEPOSIT_V3` of `CONTRACT_BALANCE` of that token, or of ETH when it
sets `useNative`. 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
sets `useNative`. The router sends ETH as written, so a `TRANSFER` of
`CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
token to the V2 pair that the very next step spends, as a V2 exact-in of that
token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
the router enforces that swap's own minimum. The decoder checks that the
address paid is that pair, deriving it from the V2 factory and pair init code
hash the network's routers were deployed with and the first two tokens of that
step's path; a payment to any other address is named. (Sepolia's
`UniversalRouterV1_2_V2Support` was deployed with placeholders for both
values, so no V2 swap step can succeed on that router.) Every other payment
counts, in whichever step and of whatever token, a share of the output
included: an interface fee paid to another address by `PAY_PORTION`,
`TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
because the swap's minimum includes the fee. The minimum reads `Min. received`
only when some step pays the transaction's sender (the From address on
TxApproval, the sender the explorer reports on TransactionDetail) and no
`Paid to` line is shown. `Paid to` lines after `Token Out` name each other
address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
for each move into the router; one reads
`The router, where it stays (no later step pays it out)` when the output stays
in the router; and one more reads `Unknown (could not be decoded)` when a step
that may pay an address went unread. When no step pays anyone, a single line
reads `Unknown (not named in the calldata)`. Wherever there is a `Paid to`
line, the minimum reads `Min. paid out`. A step goes unread when its recipient
does not decode as an address, or when it is a command the decoder does not
read: every command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
`V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
`SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
`ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
(`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH` and `EXECUTE_SUB_PLAN`
among them).
for each move into the router, followed by a `Token moved into the router`
line that gives the moved token's address as the `Token In` line does; one
reads `The router, where it stays (no later step pays it out)` when the output
stays in the router; and one more reads `Unknown (could not be decoded)` when
a step that may pay an address went unread. When no step pays anyone, a single
line reads `Unknown (not named in the calldata)`. Wherever there is a
`Paid to` line, the minimum reads `Min. paid out`. A step goes unread when its
recipient does not decode as an address, or when it is a command the decoder
does not read: every command other than `PERMIT2_PERMIT`,
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
`PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
`UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`,
`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among
them), or a type its release names no command for.
### Non-Goals Forever
+27
View File
@@ -697,6 +697,33 @@
id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div>
<!-- Shown when Sign & Send refuses a send for its network, for
a From address the selected wallet does not hold, or for its
gas limit or fee. Each holds its space from the first paint, so
the refusal moves nothing. -->
<div
id="confirm-network-changed-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The active network changed after this transaction was
reviewed, so it was not sent. Please go back and review it
again.
</div>
<div
id="confirm-from-address-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The selected wallet does not hold the From address above, so
nothing was sent. Please go back and send again.
</div>
<div
id="confirm-fee-bound-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The gas limit or fee the RPC endpoint quoted for this
transaction is more than this wallet will sign for, so it
was not sent. Please go back and try again.
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
+6 -4
View File
@@ -127,16 +127,18 @@ function isRenderableDecoded(value) {
}
// The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which
// show() fills in when it is absent, picks the network whose explorer the
// address links open, so it has to name one.
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
// truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in
// when it is absent, picks the network whose explorer the address links open,
// so it has to name one.
function isRenderablePendingTx(value) {
return (
isRecord(value) &&
isText(value.token) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
isText(value.amount) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
);
}
+67 -42
View File
@@ -45,8 +45,8 @@ const {
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The balance line and the fee reserve go through it, as the approval screen's
// amounts do.
// The amount, the balance line and the fee reserve go through it, as the
// approval screen's amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
@@ -100,8 +100,32 @@ function valueWithUsd(text, usdAmount) {
return text;
}
// The Amount line, with its USD value. A max ETH send's line is drawn again
// once its amount is re-derived from the fee estimate.
// The amount as this screen and the wait, success and error screens show it,
// written as the approval screen writes a site's amount: the amount being
// signed, parsed at the scale it is encoded with and written back as plain
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
// `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is
// unknown, which cannot be sent: its amount is cut as typed, so `.5` reads
// `.5000`. txInfo.amount, which is what gets signed, is left as it is.
function shownAmount(txInfo) {
let exact = txInfo.amount;
try {
if (txInfo.token === "ETH") {
exact = formatEther(parseEther(txInfo.amount));
} else {
const decimals = displayedDecimals(txInfo.tokenDecimals);
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
}
} catch {
// A token whose scale is unknown, which is never signed, or an amount
// the amount check refuses: cut as Send's amount field held it.
}
return truncateAmountNeverZero(exact);
}
// The Amount line, with its USD value, which is priced from the exact amount.
// A max ETH send's line is drawn again once its amount is re-derived from the
// fee estimate.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20
@@ -110,7 +134,7 @@ function renderAmount(txInfo) {
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
txInfo.amount + " " + displaySymbol(rawSymbol),
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
amountUsd,
);
}
@@ -291,9 +315,13 @@ function show(txInfo) {
renderValidation(txInfo);
// Reset password field and error
// Reset password field and error, and any refusal of an earlier Sign &
// Send.
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
// Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible";
@@ -556,16 +584,17 @@ async function estimateGas(txInfo) {
}
}
// Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. Apart
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
// what the configured RPC node answers, with nothing otherwise bounding what a
// hostile node can set — the dApp path's ceilings never reached this one.
// Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending.
// Populate the transaction this send describes, enforce the bounds against the
// gas limit and fees that were actually filled in, then sign and broadcast it.
// Apart from a max ETH send, which passes its estimate's fee fields as `fees`,
// the send pins no fee fields, so ethers fills maxFeePerGas and the gas limit
// from what the configured RPC node answers, with nothing otherwise bounding
// what a hostile node can set — the dApp path's ceilings never reached this
// one. Populating before the check is what makes assertWithinCeilings() see
// the same numbers that would be signed; it throws an ApprovalMismatchError
// when the gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
// over its bound, and the caller then shows the one refusal reserved for all
// three rather than sending.
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request;
if (tx.token === "ETH") {
@@ -593,15 +622,6 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
return connectedSigner.sendTransaction(populated);
}
// Show a full-sentence send failure in the reserved errors box, the same
// element and markup renderValidation() uses for 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) {
try {
const provider = reviewedNetworkProvider(txInfo);
@@ -640,6 +660,11 @@ function init(_ctx) {
onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => {
// A press shows only its own refusal, if any.
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
const wallet = state.wallets[state.selectedWallet];
// Every Send button refuses a defective wallet before this screen,
// but the popup also reopens onto it from a saved view.
@@ -654,11 +679,7 @@ function init(_ctx) {
// Settings after a switch draws this screen again from the saved
// transaction, as a popup reopened onto it does.
if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
setVisible("confirm-network-changed-error", true);
return;
}
@@ -667,11 +688,7 @@ function init(_ctx) {
// address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) {
showError(
"confirm-tx-password-error",
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
setVisible("confirm-from-address-error", true);
return;
}
@@ -703,6 +720,10 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
// The wait, success and error screens show the amount as this screen
// does, so they are handed a copy carrying that text. pendingTx keeps
// the exact amount, which is the one signed.
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
let tx;
try {
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
@@ -719,19 +740,23 @@ function init(_ctx) {
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decryptedSecret = null;
txStatus.showWait(pendingTx, tx.hash);
txStatus.showWait(shownTx, tx.hash);
} catch (e) {
decryptedSecret = null;
// A fee over the bound is refused before anything is broadcast, so
// there is no transaction that may have reached the network to warn
// about: the message stays on the confirmation screen where the
// user can go back, rather than routing to the sent/failed screen.
// A gas limit or fee over its bound is refused before anything is
// broadcast, so there is no transaction that may have reached the
// network to warn about: the refusal stays on the confirmation
// screen where the user can go back, rather than routing to the
// sent/failed screen. Its sentence is fixed in index.html, so that
// its box can hold its space from the first paint; the reason in
// full, naming the bound, is logged.
if (e && e.approvalMismatch) {
showSendError(e.message);
log.errorf("send refused:", e.message);
setVisible("confirm-fee-bound-error", true);
return;
}
const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
txStatus.showError(shownTx, hash, e.shortMessage || e.message);
} finally {
$("btn-confirm-send").disabled = false;
$("btn-confirm-send").classList.remove("text-muted");
+322 -105
View File
@@ -24,55 +24,199 @@ const ROUTER_IFACE = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// 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 files also list `UnsupportedProtocol`, which is not a router.
// The Universal Router releases deployed at the addresses below, each with
// three things read from its source at the commit linked for it:
//
// 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
// - `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 deployment files Uniswap publishes, each address named by its key there
// and given with the release it runs. The files also list
// `UnsupportedProtocol`, which is not a router.
const ROUTER_ADDRESSES = new Map([
[
NETWORKS.mainnet.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json
[
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", ROUTER_V1], // UniversalRouterV1
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", ROUTER_V2], // UniversalRouterV2
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", ROUTER_V2_1], // UniversalRouterV2_1_1
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", ROUTER_V2_1], // UniversalRouterV2_1_2
],
],
[
NETWORKS.sepolia.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json
[
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", ROUTER_V2], // UniversalRouterV2
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", ROUTER_V2_1], // UniversalRouterV2_1_1
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", ROUTER_V2_1], // UniversalRouterV2_1_2
],
],
]);
// The mask the Universal Router deployment at `address` on the network with
// this chain id reads a command's type with, or null when `address` is not one
// of them. Address case is ignored.
function routerCommandTypeMask(address, chainId) {
// The release that the Universal Router deployment at `address` on the
// network with this chain id runs, or null when `address` is not one of them.
// Address case is ignored.
function routerRelease(address, chainId) {
const routers = ROUTER_ADDRESSES.get(chainId) || [];
const wanted = (address || "").toLowerCase();
const router = routers.find(([a]) => a.toLowerCase() === wanted);
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
// 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
@@ -122,34 +266,52 @@ function v2PairFor(chainId, tokenA, tokenB) {
);
}
// Universal Router command types, each read from its command byte with the
// 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.
// The Steps line's name for each command in the releases' `commands` (see
// ROUTER_V1).
const COMMAND_NAMES = {
0x00: "V3 Swap (Exact In)",
0x01: "V3 Swap (Exact Out)",
0x02: "Permit2 Transfer",
0x03: "Permit2 Permit Batch",
0x04: "Sweep",
0x05: "Transfer",
0x06: "Pay Portion",
0x07: "Pay Portion (Full Precision)",
0x08: "V2 Swap (Exact In)",
0x09: "V2 Swap (Exact Out)",
0x0a: "Permit2 Permit",
0x0b: "Wrap ETH",
0x0c: "Unwrap WETH",
0x0d: "Permit2 Transfer Batch",
0x0e: "Balance Check",
0x10: "V4 Swap",
0x11: "V3 Position Mgr Permit",
0x12: "V3 Position Mgr Call",
0x13: "V4 Initialize Pool",
0x14: "V4 Position Mgr Call",
0x21: "Execute Sub-Plan",
0x40: "Across Bridge Deposit",
V3_SWAP_EXACT_IN: "V3 Swap (Exact In)",
V3_SWAP_EXACT_OUT: "V3 Swap (Exact Out)",
PERMIT2_TRANSFER_FROM: "Permit2 Transfer",
PERMIT2_PERMIT_BATCH: "Permit2 Permit Batch",
SWEEP: "Sweep",
TRANSFER: "Transfer",
PAY_PORTION: "Pay Portion",
PAY_PORTION_FULL_PRECISION: "Pay Portion (Full Precision)",
V2_SWAP_EXACT_IN: "V2 Swap (Exact In)",
V2_SWAP_EXACT_OUT: "V2 Swap (Exact Out)",
PERMIT2_PERMIT: "Permit2 Permit",
WRAP_ETH: "Wrap ETH",
UNWRAP_WETH: "Unwrap WETH",
PERMIT2_TRANSFER_FROM_BATCH: "Permit2 Transfer Batch",
BALANCE_CHECK_ERC20: "Balance Check",
V4_SWAP: "V4 Swap",
V3_POSITION_MANAGER_PERMIT: "V3 Position Mgr Permit",
V3_POSITION_MANAGER_CALL: "V3 Position Mgr Call",
V4_INITIALIZE_POOL: "V4 Initialize Pool",
V4_POSITION_MANAGER_CALL: "V4 Position Mgr Call",
EXECUTE_SUB_PLAN: "Execute Sub-Plan",
ACROSS_V4_DEPOSIT_V3: "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,
@@ -339,7 +501,13 @@ function decodePermit2(input) {
}
}
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes.
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. The router passes the
// 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)
function decodeBalanceCheck(input) {
try {
@@ -448,10 +616,12 @@ function decodeSweep(input) {
// Decode TRANSFER (command 0x05) input bytes, and those of PAY_PORTION (0x06)
// and PAY_PORTION_FULL_PRECISION (0x07), which share its layout. Each pays
// `token` out of the router to the recipient. TRANSFER pays `amount`, or all
// the router holds when `amount` is CONTRACT_BALANCE. PAY_PORTION pays
// `amount` ten-thousandths of what the router holds, and
// PAY_PORTION_FULL_PRECISION `amount` 1e18ths of it.
// `token` out of the router to the recipient. TRANSFER pays `amount`, or, of a
// token other than ETH, all the router holds when `amount` is
// CONTRACT_BALANCE; it sends ETH as written, so a TRANSFER of
// CONTRACT_BALANCE of ETH (`token` the zero address) always reverts.
// PAY_PORTION pays `amount` ten-thousandths of what the router holds, and
// PAY_PORTION_FULL_PRECISION `amount` 1e18ths of it, of ETH too.
// - UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/modules/Payments.sol
// - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/Payments.sol
// ABI: (address token, address recipient, uint256 amount)
@@ -854,8 +1024,9 @@ function decodeV4Swap(input) {
// sent to any other contract is not a swap through the router, whatever that
// contract does with it.
function decode(data, toAddress, chainId, sources, fromAddress) {
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (commandTypeMask === null) return null;
const release = routerRelease(toAddress, chainId);
if (release === null) return null;
const { commandTypeMask, commands, allowRevert } = release;
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -968,7 +1139,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router adds only what it holds.
const isHop = (i, token, recipient) => {
if (i + 1 >= commandsBytes.length) return false;
if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false;
const nextCommand =
commands[commandsBytes[i + 1] & commandTypeMask];
if (nextCommand !== "V2_SWAP_EXACT_IN") return false;
const next = decodeV2SwapExactIn(inputs[i + 1]);
return (
next !== null &&
@@ -984,18 +1157,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask;
// Only the routers with the 0x7f mask have command 0x07 (see
// COMMAND_NAMES).
const name =
cmdId === 0x07 && commandTypeMask !== 0x7f
? null
: COMMAND_NAMES[cmdId];
// The command this release runs for the step (see ROUTER_V1),
// undefined for a type it names no command for.
const command = commands[cmdId];
// The router goes on to the next step if this one fails (see
// ROUTER_V1). The flag on any other command has no effect.
const allowedToFail =
(commandsBytes[i] & ALLOW_REVERT_FLAG) !== 0 &&
allowRevert.includes(cmdId);
commandNames.push(
name || "Command 0x" + cmdId.toString(16).padStart(2, "0"),
present(command)
? COMMAND_NAMES[command]
: "Command 0x" + cmdId.toString(16).padStart(2, "0"),
);
try {
if (cmdId === 0x21) {
if (command === "EXECUTE_SUB_PLAN") {
// EXECUTE_SUB_PLAN runs a further list of commands held in
// its input, which this decoder does not read. The step
// says so, and after this loop neither side is named.
@@ -1003,13 +1180,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
hasSubPlan = true;
commandNames[i] += " (its steps are not shown)";
payeeUnread = true;
} else if (cmdId === 0x0a) {
} else if (command === "PERMIT2_PERMIT") {
const p = decodePermit2(inputs[i]);
// A permit states both halves itself, so it may replace an
// input side an earlier hop established without breaking
// the invariant.
if (p) setInput(p.token, p.amount);
} else if (cmdId === 0x02) {
} else if (command === "PERMIT2_TRANSFER_FROM") {
const p = decodePermit2TransferFrom(inputs[i]);
if (!p) {
payeeUnread = true;
@@ -1028,17 +1205,37 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (!isHop(i, p.token, p.recipient)) {
addPayee(p.recipient);
}
} else if (cmdId === 0x0e) {
} else if (command === "BALANCE_CHECK_ERC20") {
const b = decodeBalanceCheck(inputs[i]);
// 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. Any
// other minBalance sets the output side as a swap does.
// It pays nobody.
if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance);
if (!b) {
// The router still runs a check whose input does not
// decode, such as an owner word with a nonzero top
// byte, of which it reads the last 20 bytes, so the
// owner may be any address. It sets no side, and the
// step says so.
commandNames[i] += " (could not be decoded)";
} 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 (cmdId === 0x00) {
} else if (command === "V3_SWAP_EXACT_IN") {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
@@ -1052,7 +1249,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
payeeUnread = true;
}
} else if (cmdId === 0x08) {
} else if (command === "V2_SWAP_EXACT_IN") {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
@@ -1063,7 +1260,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
payeeUnread = true;
}
} else if (cmdId === 0x09) {
} else if (command === "V2_SWAP_EXACT_OUT") {
// Buys exactly amountOut and spends at most amountInMax.
hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]);
@@ -1076,7 +1273,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
payeeUnread = true;
}
} else if (cmdId === 0x04) {
} else if (command === "SWEEP") {
const s = decodeSweep(inputs[i]);
if (s) {
addPayee(s.recipient);
@@ -1087,9 +1284,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
payeeUnread = true;
}
} else if (
cmdId === 0x05 ||
cmdId === 0x06 ||
(cmdId === 0x07 && commandTypeMask === 0x7f)
command === "TRANSFER" ||
command === "PAY_PORTION" ||
command === "PAY_PORTION_FULL_PRECISION"
) {
const t = decodeTransfer(inputs[i]);
if (t) {
@@ -1100,20 +1297,29 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// PAY_PORTION_FULL_PRECISION pays all the router
// holds of the token, as SWEEP does.
const all =
cmdId === 0x05
command === "TRANSFER"
? CONTRACT_BALANCE
: cmdId === 0x06
: command === "PAY_PORTION"
? 10000n
: 10n ** 18n;
if (t.amount === all && !isRouter(t.recipient)) {
// A TRANSFER of ETH has no such amount: the router
// sends ETH as written, so a TRANSFER of
// CONTRACT_BALANCE of ETH always reverts.
const ethTransfer =
command === "TRANSFER" &&
t.token ===
"0x0000000000000000000000000000000000000000";
if (
t.amount === all &&
!ethTransfer &&
!isRouter(t.recipient)
) {
heldByRouter.delete(t.token.toLowerCase());
}
} else {
payeeUnread = true;
}
} else if (cmdId === 0x40) {
// Only the routers with the 0x7f mask read a command type
// of 0x40.
} else if (command === "ACROSS_V4_DEPOSIT_V3") {
const a = decodeAcrossV4DepositV3(inputs[i]);
if (a) {
bridgeDeposit = true;
@@ -1136,7 +1342,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
payeeUnread = true;
}
} else if (cmdId === 0x0b) {
} else if (command === "WRAP_ETH") {
const w = decodeWrapEth(inputs[i]);
if (w) {
setInputOnce(
@@ -1147,7 +1353,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
payeeUnread = true;
}
} else if (cmdId === 0x10) {
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
for (const t of v4.takes) {
@@ -1189,7 +1395,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
setInputOnce(v4.tokenIn, v4.amountIn);
setOutput(v4.tokenOut, v4.amountOutMin);
}
} else if (cmdId === 0x0c) {
} else if (command === "UNWRAP_WETH") {
hasUnwrapWeth = true;
const u = decodeUnwrapWeth(inputs[i]);
if (u) {
@@ -1203,15 +1409,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
payeeUnread = true;
}
} else {
// Any other command may pay an address this decoder does
// not read: V3_SWAP_EXACT_OUT and
// PERMIT2_TRANSFER_FROM_BATCH among them.
// Any other step may pay an address this decoder does not
// read: V3_SWAP_EXACT_OUT, PERMIT2_TRANSFER_FROM_BATCH and
// the NFT commands among them, and a type the release
// names no command for.
payeeUnread = true;
}
} catch {
// Skip commands we can't decode. One may pay any address.
payeeUnread = true;
}
if (allowedToFail) {
commandNames[i] += " (allowed to fail)";
}
}
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
@@ -1361,7 +1571,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router, when the output stays in the router, and when a payout
// could not be read. The output is received by the sender only when
// some step pays the sender and there is no such line. When no step
// names anyone paid, one line says so.
// names anyone paid, one line says so. `paidTo` holds these lines and
// the token line after each move into the router.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({
@@ -1372,20 +1583,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (bridgeDeposit) {
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) {
const info = tokenInfo(m.token, sources);
// amountText() names the token only when it knows both its
// symbol and its scale.
const ofToken =
info.symbol && info.decimals !== null ? "" : " of " + m.token;
paidTo.push({
label: "Paid to",
value:
"The router, " +
amountText(m.amount, info).display +
ofToken +
" from the sender (later steps may not spend all of it)",
});
paidTo.push({
label: "Token moved into the router",
value: info.symbol
? info.symbol + " (" + m.token + ")"
: m.token,
address: m.token,
isToken: true,
});
}
if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
+17 -3
View File
@@ -442,11 +442,25 @@ describe("a confirmation screen whose From is not the selected address", () => {
expect(Transaction.from(signed).from).toBe(derived(1).address);
});
// The refusal's sentence is fixed in index.html, in a box of its own that
// holds its space from the first paint, so showing it moves nothing.
test("nothing is sent when the selected wallet does not hold it", async () => {
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
expect(node("confirm-tx-password-error").textContent).toBe(
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
expect(node("confirm-from-address-error").style.visibility).toBe(
"visible",
);
expect(node("confirm-tx-password-error").textContent).toBe("");
// Reviewing a send again draws the screen again, which hides it.
confirmTx.show({
from: derived(0).address,
to: RECIPIENT,
amount: "0.5",
token: "ETH",
});
await settle();
expect(node("confirm-from-address-error").style.visibility).toBe(
"hidden",
);
});
});
+415
View File
@@ -0,0 +1,415 @@
// The amount of the wallet's own send on the confirmation, wait, success and
// error screens (https://git.eeqj.de/sneak/AutistMask/issues/522).
//
// These screens printed the text from Send's amount field as it was typed or
// filled in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where the
// approval screen shows a site's send as `0.5000 ETH`. They now show it to four
// decimal places with the nonzero floor, as the approval screen does, and the
// transaction is still signed for the exact amount.
//
// Driven through the real Send screen, confirmation screen, Sign & Send and
// wait screen, with only the node, the explorer and the DOM stubbed.
"use strict";
// What the stub node answers, and the signed transactions it was handed. The
// token's own decimals() answers 6, as the explorer does.
const mockNode = {
balanceWei: 0n,
tokenUnits: 0n,
tokenDecimals: 6n,
broadcast: [],
broadcastError: null,
receipt: null,
};
// The token rows the stub explorer reports for the address.
const mockExplorer = { items: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// The selector of decimals(); every other contract call is balanceOf().
const DECIMALS = "0x313ce567";
class StubProvider {
async getBalance() {
return mockNode.balanceWei;
}
async call(tx) {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[
tx.data.startsWith(DECIMALS)
? mockNode.tokenDecimals
: mockNode.tokenUnits,
],
);
}
async lookupAddress() {
return null;
}
// 20 gwei reserved per gas.
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getNetwork() {
return { chainId: 1n };
}
async broadcastTransaction(signed) {
if (mockNode.broadcastError) throw mockNode.broadcastError;
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
async getTransactionReceipt() {
return mockNode.receipt;
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The explorer's token list, which refreshBalances() also fetches.
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => mockExplorer.items,
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The confirmation screen's Etherscan label lookup is the only fetch() these
// screens make; it fails, as it does offline.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
children: [],
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
this.children.push(child);
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
// The success screen hides whole sections through .parentElement.
get parentElement() {
return global.document.getElementById(id + "-parent");
},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const {
Interface,
Transaction,
Wallet,
getAddress,
parseEther,
} = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const { encryptWithPassword } = require("../src/shared/vault");
const { state } = require("../src/shared/state");
const { clearPrices } = require("../src/shared/prices");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const txStatus = require("../src/popup/views/txStatus");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const PASSWORD = "correct horse battery staple";
const GWEI = 1000000000n;
// 1.234567890123456789 ETH.
const BALANCE_WEI = 1234567890123456789n;
// A token the bundled list does not know, with enough holders to be listed,
// at 6 decimal places.
const TOKEN = "0x" + "d".repeat(40);
const transferIface = new Interface([
"function transfer(address to, uint256 amount)",
]);
let encryptedSecret;
// The confirmation screen Review leads to, once shown.
let confirmed = null;
function el(id) {
return global.document.getElementById(id);
}
function text(id) {
return el(id).textContent;
}
// show() reads the balance and then estimates the fee without awaiting
// either; this lets both settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// The address holds BALANCE_WEI and, when `tokenUnits` is given, that many
// base units of TOKEN, fetched and stored where the popup stores them. Send
// is then open for `token` ("ETH" or TOKEN) with the recipient entered.
async function openSend(token, tokenUnits = null) {
mockNode.balanceWei = BALANCE_WEI;
mockNode.tokenUnits = tokenUnits ?? 0n;
mockExplorer.items =
tokenUnits === null
? []
: [
{
value: String(tokenUnits),
token: {
type: "ERC-20",
address_hash: TOKEN,
symbol: "TOK",
name: "Token",
decimals: "6",
holders_count: "50000",
},
},
];
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
state.trackedTokens,
"mainnet",
);
send.init({
showConfirmTx: (info) => (confirmed = info),
pageClosed: new AbortController().signal,
});
confirmTx.init({});
send.resetSendValidation();
state.currentView = "send";
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = "";
}
// Press Review and show the confirmation screen with its fee estimate settled.
async function review() {
await el("btn-send-review").handlers.get("click")();
confirmTx.show(confirmed);
await settle();
}
async function signAndSend() {
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
}
// The one transaction the stub node was handed.
function broadcastTx() {
expect(mockNode.broadcast).toHaveLength(1);
return Transaction.from(mockNode.broadcast[0]);
}
beforeAll(async () => {
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
beforeEach(() => {
elements.clear();
clearPrices();
confirmed = null;
state.selectedToken = null;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
mockNode.broadcast = [];
mockNode.broadcastError = null;
mockNode.receipt = null;
txStatus.init({
doRefreshAndRender() {},
pageClosed: new AbortController().signal,
});
});
afterEach(() => {
txStatus.endWait();
});
describe("the wallet's own send shows its amount to four decimal places", () => {
test("for a short amount", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
expect(text("confirm-amount")).toBe("0.5000 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
expect(broadcastTx().value).toBe(parseEther("0.5"));
});
// Send accepts both. The approval screen writes the same value `0.5000`.
test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => {
await openSend("ETH");
el("send-amount").value = typed;
await review();
expect(text("confirm-amount")).toBe("0.5000 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
expect(broadcastTx().value).toBe(parseEther("0.5"));
});
test("for a token amount typed as .5", async () => {
// 100 TOK.
await openSend(TOKEN, 100000000n);
el("send-amount").value = ".5";
await review();
expect(text("confirm-amount")).toBe("0.5000 TOK");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 TOK");
const [, amount] = transferIface.decodeFunctionData(
"transfer",
broadcastTx().data,
);
expect(amount).toBe(500000n);
});
test("for a Max amount with many decimals", async () => {
await openSend("ETH");
await el("btn-send-max").handlers.get("click")();
// The balance minus 21000 gas at 20 gwei.
expect(el("send-amount").value).toBe("1.234147890123456789");
await review();
expect(text("confirm-amount")).toBe("1.2341 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("1.2341 ETH");
expect(broadcastTx().value).toBe(BALANCE_WEI - 21000n * 20n * GWEI);
});
// Cut to four decimals it would read 0.0000, so it is shown to its first
// significant digit instead, and signed in full.
test("for a tiny nonzero amount", async () => {
await openSend("ETH");
el("send-amount").value = "0.000012345";
await review();
expect(text("confirm-amount")).toBe("0.00001 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.00001 ETH");
expect(broadcastTx().value).toBe(parseEther("0.000012345"));
});
test("for a token amount", async () => {
// 100 TOK.
await openSend(TOKEN, 100000000n);
el("send-amount").value = "12.345678";
await review();
expect(text("confirm-amount")).toBe("12.3456 TOK");
await signAndSend();
expect(text("wait-tx-summary")).toBe("12.3456 TOK");
const tx = broadcastTx();
expect(tx.to).toBe(getAddress(TOKEN));
const [to, amount] = transferIface.decodeFunctionData(
"transfer",
tx.data,
);
expect(to).toBe(RECIPIENT);
expect(amount).toBe(12345678n);
});
test("on the success screen", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
await signAndSend();
// A popup opened later resumes the wait, and the receipt is there.
mockNode.receipt = { blockNumber: 21000000 };
txStatus.restoreWait();
await settle();
expect(state.currentView).toBe("success-tx");
expect(text("success-tx-summary")).toBe("0.5000 ETH");
});
test("on the error screen", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
mockNode.broadcastError = new Error("the node refused it");
await signAndSend();
expect(state.currentView).toBe("error-tx");
expect(text("error-tx-summary")).toBe("0.5000 ETH");
});
});
+167 -6
View File
@@ -11,20 +11,115 @@
//
// The check is driven here with a fake connected signer rather than a real
// one: populateTransaction() returns the fees the node would have produced,
// and sendTransaction() records whether the send actually happened. The real
// DOM path around it — reading the fee error into the reserved errors box — is
// covered by the Chrome e2e suite.
// and sendTransaction() records whether the send actually happened.
//
// The last test drives the real confirmation screen instead, with only the
// node and the DOM stubbed: a gas limit over its own bound at an ordinary fee
// is refused in the same box as a fee over the bound
// (https://git.eeqj.de/sneak/AutistMask/issues/583). That the box moves
// nothing on the screen is checked by the Chrome e2e suite.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
// What the stub node quotes for that test: a gas limit of 150,000,000 at a
// maximum fee of 2 gwei per gas, 0.3 ETH in all. And the signed transactions
// it was handed.
const mockNode = {
gasLimit: 150000000n,
maxFeePerGas: 2000000000n,
broadcast: [],
};
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.network = network;
}
async getNetwork() {
return this.network;
}
async getBalance() {
return actual.parseEther("5");
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getFeeData() {
return {
maxFeePerGas: mockNode.maxFeePerGas,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 1000000000n,
};
}
async estimateGas() {
return mockNode.gasLimit;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
// The screens a send that was not refused goes on to.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
querySelectorAll: () => [],
remove() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
};
const { Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const { populateVerifyAndSend } = confirmTx;
const {
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify");
@@ -98,3 +193,69 @@ describe("populateVerifyAndSend enforces the combined fee bound", () => {
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
});
});
describe("Sign & Send on the confirmation screen", () => {
const PRIVATE_KEY = "0x" + "11".repeat(32);
const PASSWORD = "correct horse battery staple";
let encryptedSecret;
beforeAll(async () => {
await loadState();
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
function el(id) {
return global.document.getElementById(id);
}
function shown(id) {
return el(id).style.visibility === "visible";
}
// show() reads the balance and estimates the fee without awaiting
// either; this lets both finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("refuses a gas limit over its bound at an ordinary fee in the box for a refused gas limit or fee, and sends nothing", async () => {
expect(mockNode.gasLimit).toBeGreaterThan(MAX_GAS_LIMIT);
expect(mockNode.gasLimit * mockNode.maxFeePerGas).toBeLessThan(
MAX_TOTAL_FEE,
);
const from = new Wallet(PRIVATE_KEY).address;
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: from }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.init({});
confirmTx.show({
from,
to: RECIPIENT,
ensName: null,
amount: "1",
token: "ETH",
tokenSymbol: null,
tokenDecimals: null,
max: false,
});
await settle();
expect(el("btn-confirm-send").disabled).toBe(false);
expect(shown("confirm-fee-bound-error")).toBe(false);
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
expect(shown("confirm-fee-bound-error")).toBe(true);
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
});
});
+7 -20
View File
@@ -333,7 +333,7 @@ function expectNativeTransferOn(networkId) {
"Native " + symbol + " transfer",
);
expect(el("confirm-amount").textContent).toBe(
"1 " + symbol + usdOn(networkId, "$2,000.00"),
"1.0000 " + symbol + usdOn(networkId, "$2,000.00"),
);
expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
@@ -440,12 +440,8 @@ describe.each([
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").style.visibility).toBe("visible");
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
expect(shown("confirm-network-changed-error")).toBe(true);
expect(shown("confirm-fee-bound-error")).toBe(false);
});
},
);
@@ -497,10 +493,7 @@ describe.each([
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
expect(shown("confirm-network-changed-error")).toBe(true);
});
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
@@ -579,10 +572,7 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(errors()).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
expect(shown("confirm-network-changed-error")).toBe(true);
});
const tokenSend = {
@@ -629,10 +619,7 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(errors()).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
expect(shown("confirm-network-changed-error")).toBe(true);
});
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
@@ -662,7 +649,7 @@ describe.each([
`href="${explorerUrl}/address/${TOKEN}"`,
);
expect(el("confirm-amount").textContent).toBe(
"10 USDC" + usdOn(reviewedOn, "$10.00"),
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
);
expect(el("confirm-balance").textContent).toBe(
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
+7 -4
View File
@@ -226,8 +226,9 @@ const RPC_RESULTS = {
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the
// reserve and the estimate collapse to the same number, and the gate tests
// stop being able to distinguish them.
function latestBlock() {
// stop being able to distinguish them. opts.baseFeeWei replaces its base fee,
// read at request time like every other fixture switch.
function latestBlock(opts) {
return {
hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32),
@@ -239,7 +240,7 @@ function latestBlock() {
gasUsed: "0xf4240",
miner: STUB_COUNTERPARTY,
extraData: "0x",
baseFeePerGas: hex(BASE_FEE_WEI),
baseFeePerGas: opts.baseFeeWei || hex(BASE_FEE_WEI),
transactions: [],
};
}
@@ -481,7 +482,7 @@ function rpcReply(req, opts, report) {
});
}
if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() });
return Object.assign(envelope, { result: latestBlock(opts) });
}
// The end of the dApp transaction round trip: the raw signed transaction
// the background hands to the node. It is recorded verbatim so a test can
@@ -625,6 +626,8 @@ function traceEnabled(raw) {
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
* defaults to zero, which is what every test that predates the funded
* fixture expects.
* @param {string} [opts.baseFeeWei] hex wei answered as the latest block's
* baseFeePerGas; defaults to BASE_FEE_WEI. Read at request time.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal.
* @param {boolean} [opts.holdBalance] hold every batch containing an
+217 -2
View File
@@ -2232,7 +2232,8 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, how tall the whole view is, and where Sign & Send sits in it.
// fee block says, how tall the whole view is, and where the password field and
// Sign & Send sit in it.
async function confirmState(page) {
return page.evaluate(() => {
const el = (id) => document.getElementById(id);
@@ -2245,6 +2246,9 @@ async function confirmState(page) {
};
return {
height: el("view-confirm-tx").getBoundingClientRect().height,
passwordTop:
el("confirm-tx-password").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
sendTop:
el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
@@ -2262,6 +2266,12 @@ async function confirmState(page) {
amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"),
// Its sentence is wrapped across lines in index.html.
feeBoundError: shown("confirm-fee-bound-error")
? el("confirm-fee-bound-error")
.textContent.replace(/\s+/g, " ")
.trim()
: "",
sendDisabled: el("btn-confirm-send").disabled,
};
});
@@ -2905,9 +2915,10 @@ async function goToTokenConfirm(env) {
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
const shown = await shownAmount(env.page);
assert(
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
shown === "0.2500 " + STUB_TOKEN.symbol,
"the confirmation screen is not showing the amount that was entered: " +
JSON.stringify(shown),
);
@@ -3057,6 +3068,210 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
}
});
// ------------------------- a send Sign & Send refuses moves nothing (#583)
//
// README, No Layout Shift. Sign & Send refuses a send on the confirmation
// screen when the active network changed since review and when its gas limit
// or fee is over its bound, and each refusal's sentence sits in a box that held
// its space from the first paint. This drives the total fee refusal, the longer
// of the two sentences.
// The base fee the stub node quotes once Sign & Send is pressed. ethers asks
// twice it plus the 1 gwei priority fee per gas, 50,001 gwei, so the 21,000-gas
// send would allow a fee of about 1.05 ETH, over the 1 ETH this wallet signs
// for (MAX_TOTAL_FEE in src/shared/approvalVerify.js).
const OVER_BOUND_BASE_FEE_WEI = 25000n * 10n ** 9n;
test("a send refused for its fee leaves the password field and Sign & Send where they were (#583)", async (env) => {
env.errors.expect("confirmTx logging the refused send", /send refused/);
try {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await waitForEstimate(env.page);
const before = await confirmState(env.page);
assert(
!before.sendDisabled && before.feeBoundError === "",
"the screen is not ready to send: " + JSON.stringify(before),
);
// Only now, with the screen drawn and its fee estimated, does the
// node quote a fee over the bound.
env.routeOpts.baseFeeWei = toHexWei(OVER_BOUND_BASE_FEE_WEI);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await env.page.waitForFunction(
() =>
getComputedStyle(
document.getElementById("confirm-fee-bound-error"),
).visibility === "visible",
null,
{ timeout: 60000 },
);
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused send: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send over the fee bound reached the RPC",
);
assert(
after.feeBoundError ===
"The gas limit or fee the RPC endpoint quoted for this" +
" transaction is more than this wallet will sign for, so" +
" it was not sent. Please go back and try again.",
"wrong fee refusal: " + JSON.stringify(after.feeBoundError),
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
} finally {
env.routeOpts.baseFeeWei = undefined;
await backToAddress(env.page);
}
});
// --------- a send from an address the selected wallet does not hold (#609)
//
// The selection is saved apart from the confirmation screen, so the popup can
// reopen onto the screen with a wallet selected that does not hold its From
// address, and Sign & Send then refuses the send. Another popup selecting the
// key wallet would save that selection; here it is written into storage, and
// the popup reopened.
test("a send from an address the selected wallet does not hold leaves the password field and Sign & Send where they were (#609)", async (env) => {
try {
// From the HD wallet's address.
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
// The screen's own save lands first, so it cannot write over the
// selection below.
await waitForPersisted(
env.page,
"currentView",
"confirm-tx",
"before selecting the key wallet",
);
await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
const record = {
...r.autistmask,
selectedWallet: 1,
selectedAddress: 0,
};
chrome.storage.local.set(
{ autistmask: record },
resolve,
);
});
}),
);
await reopenPopup(env, "confirm-tx");
await waitForEstimate(env.page);
// The refusal shown anywhere on the screen, not only in its own box:
// shown on the password error line instead, the test fails on the
// moves checked below rather than on the wait for it.
const refusal = env.page
.locator("#view-confirm-tx")
.getByText("does not hold the From address above")
.filter({ visible: true });
const before = await confirmState(env.page);
assert(
!before.sendDisabled && (await refusal.count()) === 0,
"the screen is not ready to send: " + JSON.stringify(before),
);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await refusal.waitFor({ timeout: 60000 });
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused From address: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send from an address the selected wallet does not hold" +
" reached the RPC",
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
// Only now, where the refusal is shown. Its sentence is wrapped across
// lines in index.html.
const shownIn = await refusal.evaluate((el) => ({
id: el.id,
text: el.textContent.replace(/\s+/g, " ").trim(),
}));
assert(
shownIn.id === "confirm-from-address-error" &&
shownIn.text ===
"The selected wallet does not hold the From address above," +
" so nothing was sent. Please go back and send again.",
"wrong From address refusal: " + JSON.stringify(shownIn),
);
} finally {
// A reopened popup restores no Send screen beneath this one, so Back
// lands on the selected address. Then Home, and the HD wallet's
// address, which the later tests send from.
if (await env.page.isVisible("#view-confirm-tx")) {
await env.page.click("#btn-confirm-back");
}
if (await env.page.isVisible("#view-send")) {
await env.page.click("#btn-send-back");
}
await visible(env.page, "#view-address");
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await openAddressDetail(env.page);
}
});
// ------------------------------ the wait for a receipt ending in error (#315)
//
// README.md (WaitTx) documents two ways the wait ends on the error screen: a
+3 -3
View File
@@ -245,7 +245,7 @@ describe.each([
test("the value", async () => {
await confirmSend("0.1");
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
expect(text("confirm-amount")).toBe("0.1 " + symbol);
expect(text("confirm-amount")).toBe("0.1000 " + symbol);
await approveTx(NETWORKS[networkId].chainId);
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
});
@@ -429,10 +429,10 @@ describe.each([
await global.document
.getElementById("btn-confirm-send")
.handlers.get("click")();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
state.networkId = activeNetworkId;
txStatus.restoreWait();
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
});
test("the transaction detail screen's type line and fee", async () => {
+13
View File
@@ -477,6 +477,19 @@ const CONTRACT = [
views: ["confirm-tx"],
restored: false,
},
// The amount line splits the amount to cut it to four decimals.
{
value: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: 42,
},
},
views: ["confirm-tx"],
restored: false,
},
// A chain id naming no network has no explorer to link to.
{
value: {
+12 -6
View File
@@ -306,7 +306,8 @@ describe("Max on an ETH send", () => {
openSend();
await pressMax();
await review();
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
expect(confirmed.amount).toBe(maxAfter(20n * GWEI));
expect(text("confirm-amount")).toBe("1.2341 ETH");
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(canSend()).toBe(true);
@@ -322,14 +323,16 @@ describe("Max on an ETH send", () => {
// Kept, the Send screen's amount would be refused for want of funds.
mockNode.feeData = fees(30n * GWEI);
await review();
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
expect(confirmed.amount).toBe(maxAfter(30n * GWEI));
expect(text("confirm-amount")).toBe("1.2339 ETH");
expect(canSend()).toBe(true);
// And falls when the screen is shown again, as on reopening the popup.
mockNode.feeData = fees(10n * GWEI);
confirmTx.restore();
await settle();
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
expect(confirmed.amount).toBe(maxAfter(10n * GWEI));
expect(text("confirm-amount")).toBe("1.2343 ETH");
expect(canSend()).toBe(true);
});
@@ -526,7 +529,8 @@ describe("Max on an ETH send", () => {
el("send-amount").handlers.get("input")();
mockNode.feeData = fees(30n * GWEI);
await review();
expect(text("confirm-amount")).toBe("0.5 ETH");
expect(confirmed.amount).toBe("0.5");
expect(text("confirm-amount")).toBe("0.5000 ETH");
});
});
@@ -540,7 +544,8 @@ describe("Max on a token send", () => {
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(confirmed.amount).toBe("1234.567890123456789012");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(canSend()).toBe(true);
});
@@ -553,7 +558,8 @@ describe("Max on a token send", () => {
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(confirmed.amount).toBe("1234.567890123456789012");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(canSend()).toBe(true);
});
+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";
await boot.click("btn-confirm-send");
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
expect(boot.node("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was reviewed",
);
expect(
boot.node("confirm-network-changed-error").style.visibility,
).toBe("visible");
// Refused before the password is even tried.
expect(boot.text("confirm-tx-password-error")).toBe("");
});
+15 -7
View File
@@ -987,13 +987,23 @@ describe("uniswap decoder", () => {
);
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
// not read. With the allow-revert flag (0x80) the command byte is 0xa1.
// not read. With the allow-revert flag (0x80) the command byte is 0xa1,
// and the router lets the step fail
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
test.each([
{ flag: "without", command: 0x21 },
{ flag: "with", command: 0xa1 },
{
flag: "without",
command: 0x21,
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
flag: "with",
command: 0xa1,
steps: "Execute Sub-Plan (its steps are not shown) (allowed to fail)",
},
])(
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
({ command }) => {
({ command, steps }) => {
const subPlan = encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR,
@@ -1013,9 +1023,7 @@ describe("uniswap decoder", () => {
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)",
);
expect(detail(result, "Steps").value).toBe(steps);
// The sub-plan names both currencies, so the lines must not say
// the calldata did not.
expect(detail(result, "Token In").value).toBe(
+808 -8
View File
@@ -1,6 +1,12 @@
// The address a swap's output is paid to
// (https://git.eeqj.de/sneak/AutistMask/issues/594 and
// https://git.eeqj.de/sneak/AutistMask/issues/599).
// https://git.eeqj.de/sneak/AutistMask/issues/599), the allow-revert flag
// on the steps that pay it
// (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router
// release runs for a command type
// (https://git.eeqj.de/sneak/AutistMask/issues/592), and the address whose
// balance a BALANCE_CHECK_ERC20 checks
// (https://git.eeqj.de/sneak/AutistMask/issues/600).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -45,6 +51,8 @@ const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
// UniversalRouterV1 on Ethereum Mainnet.
const ROUTER_V1 = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B";
// UniversalRouterV1_2_V2Support on Ethereum Mainnet.
const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER = "0x7777777777777777777777777777777777777777";
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
@@ -54,6 +62,8 @@ const ZERO = "0x0000000000000000000000000000000000000000";
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// A token the wallet does not know.
const UNKNOWN_TOKEN = "0x5555555555555555555555555555555555555555";
const ONE_WETH = 1000000000000000000n;
const USDT_2000 = 2000000000n;
const USDC_1990 = 1990000000n;
@@ -147,6 +157,20 @@ function transfer(command, token, recipient, amount) {
];
}
// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend.
function permit2Permit(token, amount) {
return [
0x0a,
coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[[[token, amount, 0, 0], ROUTER, 0n], "0x"],
),
];
}
function permit2TransferFrom(token, recipient, amount) {
return [
0x02,
@@ -201,16 +225,20 @@ function unwrapWeth(recipient) {
return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])];
}
function balanceCheck(token, minBalance) {
// BALANCE_CHECK_ERC20 (0x0e) of `owner`'s balance of `token`.
function balanceCheck(token, minBalance, owner = USER) {
return [
0x0e,
coder.encode(
["address", "address", "uint256"],
[USER, token, minBalance],
[owner, token, minBalance],
),
];
}
// `step` with the allow-revert flag set on its command byte.
const flagged = ([command, input]) => [command | 0x80, input];
// `step`, a [command or action, input] pair, with the top byte of the input's
// word `index` set. The decoder cannot read that word as an address.
function withDirtyWord([id, input], index) {
@@ -623,8 +651,24 @@ describe("each transfer command's recipient", () => {
"PERMIT2_TRANSFER_FROM into the router as $kind names what it moves, and nothing is received",
({ r }) => {
const result = decode(movedThenSwapped(r));
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Paid to",
"Token moved into the router",
"Min. paid out",
"Steps",
"Deadline",
]);
expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: "USDT (" + USDT + ")",
address: USDT,
isToken: true,
});
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
},
);
@@ -655,17 +699,22 @@ describe("each transfer command's recipient", () => {
},
);
test("a move into the router of a token the wallet does not know names the token", () => {
const unknown = "0x5555555555555555555555555555555555555555";
test("a move into the router of a token the wallet does not know gives the token's address", () => {
const result = decode(
execute(
permit2TransferFrom(unknown, ADDRESS_THIS, 5n),
permit2TransferFrom(UNKNOWN_TOKEN, ADDRESS_THIS, 5n),
swapToWeth(MSG_SENDER),
),
);
expect(paidTo(result)).toEqual([
movedIntoRouter("5 base units (decimals unknown) of " + unknown),
movedIntoRouter("5 base units (decimals unknown)"),
]);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: UNKNOWN_TOKEN,
address: UNKNOWN_TOKEN,
isToken: true,
});
});
test("UniversalRouterV1 pays a PERMIT2_TRANSFER_FROM to address(1) or address(2) as written, and maps a TRANSFER's", () => {
@@ -743,6 +792,32 @@ describe("a payout of part of the router's output", () => {
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
// The router sends ETH as written, so a TRANSFER of CONTRACT_BALANCE of
// ETH always reverts; PAY_PORTION reads the router's ETH balance.
test("a TRANSFER of CONTRACT_BALANCE of ETH pays out nothing; a PAY_PORTION of 10,000 bips of ETH pays out all of it", () => {
const ethToRouter = v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
);
const transferred = decode(
execute(
ethToRouter,
transfer(0x05, ZERO, MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(detail(transferred, "Token Out").value).toBe("ETH");
expect(paidTo(transferred)).toEqual([STAYS_IN_ROUTER]);
expect(detail(transferred, "Min. received")).toBeUndefined();
const portion = decode(
execute(ethToRouter, transfer(0x06, ZERO, MSG_SENDER, 10000n)),
);
expect(labels(portion)).toEqual(PAID_TO_USER);
expect(detail(portion, "Min. received").value).toBe("1.0000 ETH");
});
// An interface fee: a share of the output to another address, the rest
// to the user. The swap's minimum includes the fee, so it is not what the
// user receives.
@@ -1313,6 +1388,709 @@ describe("paths whose recipients were untested", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/602: the top bit of a command
// byte (0x80) is the allow-revert flag. The router lets a step with it fail
// and goes on to the next step only for some commands, none of them a swap or
// a payment; on every other command the flag has no effect.
describe("the allow-revert flag", () => {
// UniversalRouterV2_1_1 on Ethereum Mainnet.
const ROUTER_V2_1_1 = "0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA";
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
// `steps` with the flag set on the one at `index`.
const flagStep = (steps, index) =>
steps.map((s, i) => (i === index ? flagged(s) : s));
const usdcToRouter = v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
const wethToPair = v3SwapExactIn(PAIR, USDT_2000, 0n, USDT, WETH);
const pairSpends = v2Swap(
0x08,
MSG_SENDER,
0n,
USDC_1990,
[WETH, USDC],
false,
);
// The flag is set on the step at `index`: a swap, a payment, or the V2
// swap that a payment to its pair hops into.
test.each([
{
kind: "V2_SWAP_EXACT_IN",
steps: [
v2Swap(0x08, MSG_SENDER, USDT_2000, ONE_WETH, [USDT, WETH]),
],
index: 0,
},
{
kind: "V2_SWAP_EXACT_OUT",
steps: [
v2Swap(0x09, MSG_SENDER, ONE_WETH, USDT_2000, [USDT, WETH]),
],
index: 0,
},
{
kind: "V3_SWAP_EXACT_IN",
steps: [swapToWeth(MSG_SENDER)],
index: 0,
},
{
kind: "V4_SWAP",
steps: [v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))],
index: 0,
},
{
kind: "SWEEP",
steps: [usdcToRouter, sweep(USDC, MSG_SENDER)],
index: 1,
},
{
kind: "TRANSFER",
steps: [
usdcToRouter,
transfer(0x05, USDC, MSG_SENDER, CONTRACT_BALANCE),
],
index: 1,
},
{
kind: "PAY_PORTION",
steps: [usdcToRouter, transfer(0x06, USDC, MSG_SENDER, 10000n)],
index: 1,
},
{
kind: "PAY_PORTION_FULL_PRECISION",
steps: [usdcToRouter, transfer(0x07, USDC, MSG_SENDER, 10n ** 18n)],
index: 1,
},
{
kind: "UNWRAP_WETH",
steps: [swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)],
index: 1,
},
{
kind: "WRAP_ETH",
steps: [
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
ONE_WETH,
USDC_1990,
WETH,
USDC,
false,
),
],
index: 0,
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
steps: [
usdcToRouter,
acrossDeposit(USER, USER, USDC, CONTRACT_BALANCE),
],
index: 1,
},
{
kind: "PERMIT2_TRANSFER_FROM to the pair the next V2 swap spends",
steps: [permit2TransferFrom(WETH, PAIR, ONE_WETH), pairSpends],
index: 0,
},
{
kind: "a swap paid to the pair the next V2 swap spends",
steps: [wethToPair, pairSpends],
index: 0,
},
{
kind: "the V2 swap after a swap paid to its pair",
steps: [wethToPair, pairSpends],
index: 1,
},
])(
"$kind reads the same with the flag as without it",
({ steps, index }) => {
const plain = decode(execute(...steps), USER, ROUTER_V2_1_2);
const withFlag = decode(
execute(...flagStep(steps, index)),
USER,
ROUTER_V2_1_2,
);
expect(withFlag).toEqual(plain);
expect(detail(withFlag, "Steps").value).not.toContain(
"allowed to fail",
);
},
);
test.each([
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
{ router: "UniversalRouterV2", address: ROUTER },
{ router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 },
])(
"on $router, a BALANCE_CHECK_ERC20 with the flag is allowed to fail and does not replace the swap's minimum",
({ address }) => {
const check = balanceCheck(WETH, 2n * ONE_WETH);
const plain = decode(
execute(swapToWeth(MSG_SENDER), check),
USER,
address,
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check",
);
expect(detail(plain, "Min. received").value).toBe("2.0000 WETH");
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(check)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (allowed to fail)",
);
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
},
);
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH",
);
const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
);
});
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
test.each([
{ router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
mark: "",
},
{
router: "UniversalRouterV2",
address: ROUTER,
mark: " (allowed to fail)",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
mark: " (allowed to fail)",
},
])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag",
({ address, mark }) => {
const steps = [
permit2Permit(USDT, USDT_2000),
swapToWeth(MSG_SENDER),
];
const plain = decode(execute(...steps), USER, address);
const withFlag = decode(
execute(...flagStep(steps, 0)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"Permit2 Permit" + mark + " → V3 Swap (Exact In)",
);
const withoutSteps = (r) =>
r.details.filter((d) => d.label !== "Steps");
expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain));
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
},
);
test("a step the decoder does not read is marked too", () => {
const positionManagerCall = [0x12, "0x"];
const plain = decode(
execute(swapToWeth(MSG_SENDER), positionManagerCall),
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → V3 Position Mgr Call",
);
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(positionManagerCall)),
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → V3 Position Mgr Call (allowed to fail)",
);
expect(paidTo(withFlag)).toEqual([UNDECODED]);
});
// A command from each router's list, by its name in that release's
// Commands.sol.
test.each([
{
router: "UniversalRouterV1",
address: ROUTER_V1,
command: "FOUNDATION",
id: 0x1c,
name: "Foundation",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
command: "ELEMENT_MARKET",
id: 0x1e,
name: "Element Market",
},
{
router: "UniversalRouterV2",
address: ROUTER,
command: "PERMIT2_PERMIT_BATCH",
id: 0x03,
name: "Permit2 Permit Batch",
},
{
router: "UniversalRouterV2_1_1",
address: ROUTER_V2_1_1,
command: "V4_INITIALIZE_POOL",
id: 0x13,
name: "V4 Initialize Pool",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
command: "V4_POSITION_MANAGER_CALL",
id: 0x14,
name: "V4 Position Mgr Call",
},
])(
"on $router, $command with the flag is listed as '$name (allowed to fail)'",
({ address, id, name }) => {
const step = [id, "0x"];
const plain = decode(
execute(swapToWeth(MSG_SENDER), step),
USER,
address,
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → " + name,
);
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(step)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → " + name + " (allowed to fail)",
);
},
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/592: each release runs its own
// command for a command type, as its Commands.sol names it. The decoder named
// and read every type as UniversalRouterV2 does, so on the two older routers
// an NFT command read as a V4 swap, and on UniversalRouterV1, which reverts on
// 0x0e, that type read as a balance check that set the minimum.
describe("the command each release runs for a command type", () => {
// Inputs UniversalRouterV2 reads as a V4 swap of 2,000 USDT for at least
// 1,990 USDC paid to the user, and as a check that the user holds at
// least 1 WETH.
const swapInput = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))[1];
const checkInput = balanceCheck(WETH, ONE_WETH)[1];
// The lines of a transaction whose one step the decoder does not read.
const NOT_READ = [
"Protocol",
"Token In",
"Token Out",
"Paid to",
"Steps",
"Deadline",
];
test.each([
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x0e,
input: checkInput,
name: "Command 0x0e",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x10,
input: swapInput,
name: "Seaport",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x11,
input: swapInput,
name: "LooksRare 721",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x12,
input: swapInput,
name: "NFTX",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x13,
input: swapInput,
name: "CryptoPunks",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x14,
input: swapInput,
name: "LooksRare 1155",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x15,
input: swapInput,
name: "Owner Check 721",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x16,
input: swapInput,
name: "Owner Check 1155",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x17,
input: swapInput,
name: "Sweep ERC-721",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x18,
input: swapInput,
name: "X2Y2 721",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x19,
input: swapInput,
name: "Sudoswap",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x1a,
input: swapInput,
name: "NFT20",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x1b,
input: swapInput,
name: "X2Y2 1155",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x1c,
input: swapInput,
name: "Foundation",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1,
id: 0x1d,
input: swapInput,
name: "Sweep ERC-1155",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x10,
input: swapInput,
name: "Seaport V1.5",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x11,
input: swapInput,
name: "LooksRare V2",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x12,
input: swapInput,
name: "NFTX",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x13,
input: swapInput,
name: "CryptoPunks",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x14,
input: swapInput,
name: "Command 0x14",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x15,
input: swapInput,
name: "Owner Check 721",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x16,
input: swapInput,
name: "Owner Check 1155",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x17,
input: swapInput,
name: "Sweep ERC-721",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x18,
input: swapInput,
name: "X2Y2 721",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x19,
input: swapInput,
name: "Sudoswap",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x1a,
input: swapInput,
name: "NFT20",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x1b,
input: swapInput,
name: "X2Y2 1155",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x1c,
input: swapInput,
name: "Foundation",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x1d,
input: swapInput,
name: "Sweep ERC-1155",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x1e,
input: swapInput,
name: "Element Market",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x20,
input: swapInput,
name: "Seaport V1.4",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
id: 0x22,
input: swapInput,
name: "Approve ERC-20",
},
{
router: "UniversalRouterV2",
address: ROUTER,
id: 0x11,
input: swapInput,
name: "V3 Position Mgr Permit",
},
{
router: "UniversalRouterV2",
address: ROUTER,
id: 0x12,
input: swapInput,
name: "V3 Position Mgr Call",
},
{
router: "UniversalRouterV2",
address: ROUTER,
id: 0x13,
input: swapInput,
name: "V4 Initialize Pool",
},
])(
"on $router, '$name' is a step the decoder does not read",
({ address, id, input, name }) => {
const result = decode(execute([id, input]), USER, address);
expect(detail(result, "Steps").value).toBe(name);
expect(result.name).toBe("Uniswap Swap");
expect(labels(result)).toEqual(NOT_READ);
expect(paidTo(result)).toEqual([UNDECODED]);
},
);
test.each([
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
{ router: "UniversalRouterV2", address: ROUTER },
])(
"on $router, 0x0e is a balance check, which sets the minimum",
({ address }) => {
const result = decode(execute([0x0e, checkInput]), USER, address);
expect(detail(result, "Steps").value).toBe("Balance Check");
expect(detail(result, "Token Out").address).toBe(WETH);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
},
);
test("on UniversalRouterV2, 0x10 is a V4 swap", () => {
const result = decode(execute([0x10, swapInput]), USER, ROUTER);
expect(detail(result, "Steps").value).toBe("V4 Swap");
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Min. received").value).toBe("1990.0000 USDC");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/600: on every release that has
// BALANCE_CHECK_ERC20, the router checks the balance of the owner as written,
// without map(), so address(1) and address(2) there are those addresses, not
// the sender and the router. Only a check of the sender's own balance says
// anything about what the user receives.
describe.each([
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
{ router: "UniversalRouterV2", address: ROUTER },
{ router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 },
])("on $router, the owner of a BALANCE_CHECK_ERC20", ({ address }) => {
// A swap of 2,000 USDT for WETH with no minimum, paid to the user.
const swap = v3SwapExactIn(MSG_SENDER, USDT_2000, 0n, USDT, WETH);
const NO_MINIMUM = "None (no minimum guaranteed)";
test("the sender: the check sets the minimum, unless the router lets it fail", () => {
const check = balanceCheck(WETH, ONE_WETH, USER);
const plain = decode(execute(swap, check), USER, address);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check",
);
expect(detail(plain, "Min. received").value).toBe("1.0000 WETH");
// The sender as the From address gives it, in lower case.
expect(
decode(execute(swap, check), USER.toLowerCase(), address),
).toEqual(plain);
const withFlag = decode(execute(swap, flagged(check)), USER, address);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (allowed to fail)",
);
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
});
const notTheSender = [
{ kind: "address(1)", owner: MSG_SENDER },
{ kind: "address(2)", owner: ADDRESS_THIS },
{ kind: "another address", owner: OTHER },
];
test.each(notTheSender)(
"$kind: after a swap paid to the user, the check sets no minimum and is said to be of another address",
({ owner }) => {
const check = balanceCheck(WETH, ONE_WETH, owner);
const plain = decode(execute(swap, check), USER, address);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (of another address)",
);
expect(paidTo(plain)).toEqual([]);
expect(detail(plain, "Min. received").value).toBe(NO_MINIMUM);
const withFlag = decode(
execute(swap, flagged(check)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (of another address) (allowed to fail)",
);
expect(paidTo(withFlag)).toEqual([]);
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
},
);
test.each(notTheSender)(
"$kind: a check alone names no output token and states no minimum",
({ owner }) => {
const check = balanceCheck(WETH, ONE_WETH, owner);
for (const step of [check, flagged(check)]) {
const result = decode(execute(step), USER, address);
expect(detail(result, "Token Out").value).toBe(UNNAMED);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
}
},
);
test("an owner word with a nonzero top byte: the check is said not to decode and sets no minimum", () => {
// The router checks the balance of the address in the word's last 20
// bytes, here OTHER's.
const check = withDirtyWord(balanceCheck(WETH, ONE_WETH, OTHER), 0);
const plain = decode(execute(swap, check), USER, address);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (could not be decoded)",
);
expect(detail(plain, "Min. received").value).toBe(NO_MINIMUM);
const withFlag = decode(execute(swap, flagged(check)), USER, address);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (could not be decoded) (allowed to fail)",
);
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
});
});
// The DOM stub both screens are drawn into.
function makeElement(id) {
const classes = new Set();
@@ -1459,3 +2237,25 @@ describe("the screens judge the output against the transaction's sender", () =>
expect(toOther).not.toContain("Min. received");
});
});
// The address of each token moved into the router, a known one and one the
// wallet does not know, is shown as every other address is: with an explorer
// link, and copied when clicked. Neither token is the swap's.
describe("the screens show each token moved into the router as an address", () => {
const twoMovesThenSwap = execute(
permit2TransferFrom(USDC, ADDRESS_THIS, USDC_1990),
permit2TransferFrom(UNKNOWN_TOKEN, ADDRESS_THIS, 5n),
swapToWeth(MSG_SENDER),
);
test.each([
{ screen: "approval", show: showApproval },
{ screen: "transaction detail", show: showDetail },
])("the $screen screen", async ({ show }) => {
const html = await show(twoMovesThenSwap);
for (const token of [USDC, UNKNOWN_TOKEN]) {
expect(html).toContain('data-copy="' + token + '"');
expect(html).toContain("/address/" + token + '"');
}
});
});
+11 -10
View File
@@ -71,6 +71,8 @@ function exactInParams(currencyIn, path, amountIn, amountOutMin) {
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
// uint256 minBalance). It names the output token and nothing about the input.
// Its owner is USER, the transaction's sender: a check of any other address
// names no output token.
function balanceCheck(token, minBalance) {
return coder.encode(
["address", "address", "uint256"],
@@ -78,8 +80,13 @@ function balanceCheck(token, minBalance) {
);
}
// A transaction from USER.
function decode(data) {
return uniswap.decode(data, ROUTER, "0x1", {}, USER);
}
function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, "0x1", {});
const decoded = decode(data);
expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label);
}
@@ -113,9 +120,7 @@ describe("an execute() whose input currency never decoded", () => {
});
test("does not name ETH in the swap title either", () => {
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Uniswap Swap",
);
expect(decode(data()).name).toBe("Uniswap Swap");
});
});
@@ -125,9 +130,7 @@ describe("an execute() that names only an output token", () => {
test("refuses the input rather than defaulting it to ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL);
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Uniswap Swap",
);
expect(decode(data()).name).toBe("Uniswap Swap");
});
test("still states the output it did establish", () => {
@@ -175,9 +178,7 @@ describe("native ETH in, which V4 spells as the zero address", () => {
expect(detail(data, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
expect(uniswap.decode(data, ROUTER, "0x1", {}).name).toBe(
"Swap ETH → USDT",
);
expect(decode(data).name).toBe("Swap ETH → USDT");
});
test("WRAP_ETH is still ETH at 18 decimals", () => {