Author SHA1 Message Date
sneak 46a39fd96b fix: the swap decoder reads who the router's transfer commands pay (closes #599)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION (0x07, on the routers with
the 0x7f mask), PERMIT2_TRANSFER_FROM and V4 TAKE_PORTION were payouts the
decoder did not read, so a swap using one read Min. paid out with an unknown
payee. Each recipient is now read under the rule swap steps follow: 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
part leaves the rest in the router. UniversalRouterV1 pays a
PERMIT2_TRANSFER_FROM to address(1) or address(2) as written. 0x07 and
ACROSS_V4_DEPOSIT_V3 (0x40) are named in Steps; the deposit stays unread.

Model: opus-5-5
2026-10-19 09:56:19 +00:00
27 changed files with 465 additions and 5012 deletions
+91 -264
View File
@@ -418,10 +418,6 @@ Each group of these assertions records a coverage key and a final case demands
the exact set, so a section that silently stopped running reddens the suite
instead of quietly shrinking it.
It also covers the red banner on the Settings screen: switching the network
there to Sepolia must put up the `[TESTNET]` banner, and switching back to
mainnet must take it down, both while Settings is still on screen.
It also covers the confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands,
the fee block quoting the expected cost and the reserve separately, the distinct
@@ -434,15 +430,6 @@ reserve while sitting on the same side of the estimate, so swapping the two in
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
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
@@ -1090,29 +1077,23 @@ 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 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.
`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.
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`).
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.
**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`.
@@ -1189,8 +1170,14 @@ number. They can read:
`amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. Transaction Decoding
below gives the steps whose amounts the line shows.
whole open delta", so the calldata states no quantity. The line shows the
amount of the step that sets the input side: the last `PERMIT2_PERMIT` step if
there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH`
or V4 swap step that names an input token or amount. A V4 step with a swap
action sets the input side when no earlier step set it, even when it names
neither, as described below. A V2 exact-out step gives its `amountInMax`, and
a V4 swap step the `amountIn` of the swap action that spends its input
currency.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
@@ -1206,13 +1193,7 @@ 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. 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.
`minBalance` sets both lines, as a swap step does.
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
@@ -1771,18 +1752,14 @@ 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), 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
- 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
- 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
@@ -1816,10 +1793,6 @@ view would leave a wallet one click from deletion.
estimated, and it says the wallet does not know how many decimal places
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,
@@ -1837,7 +1810,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 its own box, no screen change. After a switch,
and review it again." in the errors box, no screen change. After a switch,
"Back" from Settings draws this screen again from the saved transaction,
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
@@ -1851,21 +1824,13 @@ 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 → 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
hold the From address → full-sentence error on the password error line,
nothing sent (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**
@@ -1885,17 +1850,14 @@ view would leave a wallet one click from deletion.
no recipient." instead
- Transaction hash: full hash (tap to copy) + etherscan link
- Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The screen
describes the transaction on the network it was sent on, whichever network is
active: the receipt is asked of that network's RPC endpoint, the one Settings
has for it, and the To and hash links open that network's explorer. The wait
is persisted: closing and reopening the popup resumes the poll, with the
elapsed counter and the timeout deadline still measured from the original
broadcast. A lookup that fails is retried on the next tick rather than counted
as a missing receipt, because a failed lookup says nothing about the
transaction; but six failures in a row (60 seconds at the poll cadence) end
the wait, so an RPC that never answers cannot leave it running indefinitely.
Any lookup that answers resets that count.
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
persisted: closing and reopening the popup resumes the poll, with the elapsed
counter and the timeout deadline still measured from the original broadcast. A
lookup that fails is retried on the next tick rather than counted as a missing
receipt, because a failed lookup says nothing about the transaction; but six
failures in a row (60 seconds at the poll cadence) end the wait, so an RPC
that never answers cannot leave it running indefinitely. Any lookup that
answers resets that count.
- **Transitions**:
- Receipt found → **SuccessTx**
- A lookup that answers "no receipt" 60 seconds or more after broadcast →
@@ -1916,12 +1878,9 @@ view would leave a wallet one click from deletion.
- Amount + symbol
- To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx
- Block number + etherscan link
- Block number
- Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button
- **Behavior**: As on WaitTx, every etherscan link on the screen opens the
explorer of the network the transaction was sent on, whichever network is
active.
- **Transitions**:
- "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
@@ -1941,9 +1900,6 @@ view would leave a wallet one click from deletion.
- Transaction hash section (hidden if broadcast failed before getting hash):
full hash (tap to copy) + etherscan link
- "Done" button
- **Behavior**: As on WaitTx, every etherscan link on the screen opens the
explorer of the network the transaction was sent on, whichever network is
active.
- **Transitions**:
- "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
@@ -1966,8 +1922,7 @@ view would leave a wallet one click from deletion.
#### TransactionDetail (`transaction`)
- **When**: User tapped a transaction row on Home, AddressDetail, or
AddressToken. Its etherscan links open the explorer of the network the
transaction was sent on, whichever network is active.
AddressToken.
- **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button
@@ -2368,19 +2323,15 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a
transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, what each swap step the user pays for spends of a token other
than `Token In`, what each `PERMIT2_TRANSFER_FROM` moves and where to,
steps, deadline, and unless a swap pays only the From address, a `Paid to`
line for each other address it pays, or saying that a token stays in the
router or who it pays is unknown (see Transaction Decoding)
amounts, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying that its output
stays in the router or who it pays is unknown (see Transaction Decoding)
- 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 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
- 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
- Network and nonce
- Raw data: full calldata displayed inline (shown if present)
- Password input and an error line
@@ -2999,44 +2950,18 @@ 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 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
`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
`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`
@@ -3048,62 +2973,19 @@ Currently supported:
itself, which holds what it is paid until a later step pays it out; a payment
to the router, as `address(2)` or by its own address, is not counted as paid
out. `UniversalRouterV1` alone pays a `PERMIT2_TRANSFER_FROM` to `address(1)`
or `address(2)` as those addresses, and the decoder names them. A
`PERMIT2_TRANSFER_FROM` into the router is named with the amount it moves,
because the decoder does not check that later steps spend all of it.
`ACROSS_V4_DEPOSIT_V3` pays the bridge, which pays the deposit's recipient on
another network and refunds a deposit nobody fills to its depositor; the
router passes both on as written, so `address(1)` and `address(2)` there are
addresses, and each that is not the transaction's sender is named. A
`BALANCE_CHECK_ERC20` pays nobody: the router checks that its owner holds at
least `minBalance` of its token, and reads the owner as written, so
`address(1)` and `address(2)` there are addresses too. A check of any address
but the transaction's sender's says nothing about what the user receives: it
sets neither `Token Out` nor the minimum, and it is listed as
`Balance Check (of another address)`. A check whose input does not decode,
such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router any token, the swap's output or another, a
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
paid it into ETH, that token stays in the router unless a later step pays out
or spends all the router holds of it: a `SWEEP` of that token to anyone but
the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of
`CONTRACT_BALANCE` when it is ETH, a `TRANSFER` of `CONTRACT_BALANCE`, a
`PAY_PORTION` of 10,000 bips or a `PAY_PORTION_FULL_PRECISION` of 1e18 of that
token to anyone but the router, an `ACROSS_V4_DEPOSIT_V3` of
`CONTRACT_BALANCE` of that token, or of ETH when it sets `useNative`, or a V2
or V3 exact-in swap step or V4 `SETTLE` that pays `CONTRACT_BALANCE` of that
token from the router (`payerIsUser` false). V2 and V3 exact-in swap steps and
V4 `SETTLE`s that pay WETH from the router also spend all the WETH a
`WRAP_ETH` paid it once together they spend the amount the `WRAP_ETH` states,
as a swap paid in ETH does, but only when that amount is not
`CONTRACT_BALANCE` and the router held no WETH from an earlier step when the
`WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1
WETH from it reads as leaving the WETH there. A `SETTLE` of `OPEN_DELTA`
(zero), and a `SETTLE_ALL`, pay only what the step still owes of their token
when they run. The decoder takes that to be the amounts the step's swap
actions state they spend of that token only when every action in the step
could be read, every swap action names the token it spends and the token it
buys (a swap action with an empty path swaps nothing), the `SETTLE` or
`SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that token and
comes after every swap action, no swap action buys that token, no `TAKE`,
`TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a stated amount
adds to what the step owes), and the swap actions that spend it state a
quantity together; otherwise it does not know the amount, and such a `SETTLE`
from the router counts as spending nothing. So the rest of a V4 `SETTLE` of
more than its swap spends, taken to the router, the WETH of a `WRAP_ETH`, and
a token a swap pays the router on the way to the output, each stays in the
router when later steps do not spend all of it, where anyone can take it in a
later transaction. The router sends ETH as written, so a `TRANSFER` of
`CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
token to the V2 pair that the very next step spends, as a V2 exact-in of that
token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
the router enforces that swap's own minimum. The decoder checks that the
address paid is that pair, deriving it from the V2 factory and pair init code
hash the network's routers were deployed with and the first two tokens of that
step's path; a payment to any other address is named. (Sepolia's
or `address(2)` as those addresses, and the decoder names them. 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, or a `TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or
a `PAY_PORTION_FULL_PRECISION` of 1e18 of that token. 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
@@ -3111,75 +2993,20 @@ Currently supported:
`TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
because the swap's minimum includes the fee. The minimum reads `Min. received`
only when some step pays the transaction's sender (the From address on
TxApproval, the sender the explorer reports on TransactionDetail) and no
`Paid to` line is shown. `Paid to` lines after `Token Out` name each other
address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
for each move into the router, followed by a `Token moved into the router`
line that gives the moved token's address as the `Token In` line does; one
reads `The router, where it stays (no later step pays it out)` for each token
that stays in the router, followed, for any token but the output, by a
`Token moved into the router` line giving that token in the same way (ETH by
name); and one more reads `Unknown (could not be decoded)` when a step that
may pay an address went unread. When no step pays anyone, a single line reads
`Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the
minimum reads `Min. paid out`. The `Amount` line gives the amount of the step
that sets the input side: the last `PERMIT2_PERMIT` step if there is one,
otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH` or V4 swap
step that names an input token or amount. A V4 step with a swap action sets
the input side when no earlier step set it, even when it names neither (see
Display Consistency). A V2 exact-out step gives its `amountInMax`, and a V4
swap step the `amountIn` of the swap action that spends its input currency.
Each later V2 or V3 exact-in or V2 exact-out step whose `payerIsUser` is true
also takes its amount, found in the same way, from the user's wallet. So does
every V4 step but the one that sets the input side, with a swap action or
without: it takes what its `SETTLE`s paid by the user (`payerIsUser` true) and
its `SETTLE_ALL`s pay, for each token on its own. A `SETTLE` of any amount but
`OPEN_DELTA` pays that amount, and a `SETTLE` of `OPEN_DELTA` or a
`SETTLE_ALL` pays what the step owes, as above. What a step takes of
`Token In` is added to the line's, unless a `PERMIT2_PERMIT` set the input
side, whose amount the line keeps. A permit whose spender is the router and
which the router does not let fail caps what the router may take of its token
after it; any other permit caps nothing the router takes. So a V3 exact-in of
600 USDT and a V2 exact-in of 400 USDT, both paid by the user, read
`1000.0000 USDT`. When either amount is not a quantity (a V4 open delta,
`ALREADY_PAID`, or an amount the decoder does not know), the total is not
known and there is no `Amount` line. A step paid from the router spends what
the router holds and adds nothing, and so does a V2 exact-in whose `amountIn`
is `ALREADY_PAID`, which takes nothing from the user. What a step takes of a
token other than `Token In`, or of `Token In` when the decoder does not know
the amount or when a permit that caps nothing set the input side, gives three
lines after the `Amount` line (after `Token In` when there is none), in step
order: `Moved from the sender`, its amount, worded as the `Amount` line words
one, or `Unknown (could not be decoded)` when the decoder does not know it;
`Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A step may also take
from the user's wallet what the decoder did not read: a V4 step, other than
the one that sets the input side, whose input or one of whose `SETTLE`s or
`SETTLE_ALL`s it cannot read, which the router still runs; a V2 or V3 exact-in
or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it cannot read;
and every `PERMIT2_TRANSFER_FROM_BATCH`. Each such step gives one
`Moved from the sender` line, among those lines in step order, reading
`Unknown (could not be decoded)`, and there is then no `Amount` line, whose
figure might be too low. Then each `PERMIT2_TRANSFER_FROM` but one into the
router, in step order, gives three lines: `Moved from the sender`, the amount
it moves, for example `1000000.0000 USDT`; `Token moved`, giving the token's
address as the `Token In` line does; and `Moved to`, which reads
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and
`Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. These lines change neither the
`Paid to` lines nor the minimum's label. A step goes unread when its recipient
does not decode as an address, or when it is a command the decoder does not
read: every command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
`V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
`SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
`ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
(`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and
the NFT commands among them), or a type its release names no command for.
TxApproval, the sender the explorer reports on TransactionDetail), no step
pays any other address, no step that may pay an address went unread, and the
output does not stay in the router. Otherwise it reads `Min. paid out`, a
`Paid to` line after `Token Out` names each other address paid, one reads
`The router, where it stays (no later step pays it out)` when the output stays
in the router, and one more reads `Unknown (could not be decoded)` when such a
step went unread, or `Unknown (not named in the calldata)` when no step pays
anyone. A step goes unread when its recipient does not decode as an address,
or when it is a command 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`, `WRAP_ETH`, `UNWRAP_WETH` and
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`,
`EXECUTE_SUB_PLAN` and `ACROSS_V4_DEPOSIT_V3` among them).
### Non-Goals Forever
-27
View File
@@ -697,33 +697,6 @@
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
+5 -18
View File
@@ -127,33 +127,28 @@ function isRenderableDecoded(value) {
}
// The pending transaction confirmTx.show() renders: `token` reaches
// 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.
// 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.
function isRenderablePendingTx(value) {
return (
isRecord(value) &&
isText(value.token) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
isText(value.amount) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
);
}
// The stored transaction transactionDetail.render() shows. contractAddress is
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
// `chainId` picks the network whose explorer the links open, so when it is
// there it has to name one.
function isRenderableTx(value) {
return (
isRecord(value) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress)) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
(!value.contractAddress || isAddressText(value.contractAddress))
);
}
@@ -205,19 +200,11 @@ function renderView(view, state, views) {
if (!data.hash) return false;
if (!isAddressText(data.to)) return false;
if (!isRenderableDecoded(data.decoded)) return false;
// `chainId` picks the network whose explorer the links open, so
// when it is there it has to name one. Likewise on error-tx.
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
return false;
}
views.txStatus.renderSuccess();
return true;
case "error-tx":
if (!data.message) return false;
if (!isAddressText(data.to)) return false;
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
return false;
}
views.txStatus.renderError();
return true;
default:
+60 -72
View File
@@ -23,10 +23,12 @@ const {
goBack,
onViewLeave,
formatFee,
reviewedNetworkProvider,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
@@ -43,8 +45,8 @@ const {
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The amount, the balance line and the fee reserve go through it, as the
// approval screen's amounts do.
// The balance line and the fee reserve go through it, as the approval screen's
// amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
@@ -98,32 +100,8 @@ function valueWithUsd(text, usdAmount) {
return text;
}
// 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.
// 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.
function renderAmount(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const rawSymbol = isErc20
@@ -132,7 +110,7 @@ function renderAmount(txInfo) {
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
$("confirm-amount").textContent = valueWithUsd(
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
txInfo.amount + " " + displaySymbol(rawSymbol),
amountUsd,
);
}
@@ -313,13 +291,9 @@ function show(txInfo) {
renderValidation(txInfo);
// Reset password field and error, and any refusal of an earlier Sign &
// Send.
// Reset password field and error
$("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";
@@ -456,6 +430,20 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// The node of the network this transaction was reviewed for, which need not be
// the active one: the popup can reopen onto this screen after Settings switched
// the network. Its RPC URL is the one Settings has for that network: the
// active network's is state.rpcUrl, any other's the one remembered for it (see
// applyChainSwitchFields()).
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
async function estimateGas(txInfo) {
try {
const provider = reviewedNetworkProvider(txInfo);
@@ -568,17 +556,16 @@ async function estimateGas(txInfo) {
}
}
// 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.
// 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.
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request;
if (tx.token === "ETH") {
@@ -606,6 +593,15 @@ 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);
@@ -613,9 +609,6 @@ async function checkRecipientHistory(txInfo) {
fromAddress: txInfo.from,
chainId: txInfo.chainId,
});
// The user may have left this transaction while the checks were in
// flight; its warnings must not reach another recipient's screen.
if (pendingTx !== txInfo) return;
for (const w of asyncWarnings) {
if (w.type === "contract") {
$("confirm-contract-warning").style.visibility = "visible";
@@ -644,11 +637,6 @@ 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.
@@ -663,7 +651,11 @@ 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) {
setVisible("confirm-network-changed-error", true);
showSendError(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
return;
}
@@ -672,7 +664,11 @@ function init(_ctx) {
// address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) {
setVisible("confirm-from-address-error", true);
showError(
"confirm-tx-password-error",
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
return;
}
@@ -704,10 +700,6 @@ 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);
@@ -724,23 +716,19 @@ 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(shownTx, tx.hash);
txStatus.showWait(pendingTx, tx.hash);
} catch (e) {
decryptedSecret = null;
// 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.
// 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.
if (e && e.approvalMismatch) {
log.errorf("send refused:", e.message);
setVisible("confirm-fee-bound-error", true);
showSendError(e.message);
return;
}
const hash = tx ? tx.hash : null;
txStatus.showError(shownTx, hash, e.shortMessage || e.message);
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
} finally {
$("btn-confirm-send").disabled = false;
$("btn-confirm-send").classList.remove("text-muted");
+1 -18
View File
@@ -19,7 +19,6 @@ const {
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
@@ -129,8 +128,7 @@ function showView(name) {
}
// Create or update the debug/insecure warning banner.
// Called on every view switch, after the settings debug toggle changes, and
// after a network switch in Settings.
// Called on every view switch and after the settings debug toggle changes.
// The banner is shown when the compile-time DEBUG constant is true OR when
// the user has enabled runtime debug mode via the settings easter egg, OR
// when the active network is a testnet.
@@ -608,20 +606,6 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
// The node of the network a transaction was reviewed for, the one its chain id
// names, which need not be the active one: the popup can reopen onto ConfirmTx
// or WaitTx after Settings switched the network. Its RPC URL is the one
// Settings has for that network: the active network's is state.rpcUrl, any
// other's the one remembered for it (see applyChainSwitchFields()).
function reviewedNetworkProvider(txInfo) {
const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js, the explorer of the network with `chainId`, or of
// the active network when no chain id is given; only the path segment is
@@ -777,7 +761,6 @@ module.exports = {
attachCopyHandlers,
etherscanLinkHtml,
explorerUrl,
reviewedNetworkProvider,
EXT_ICON,
truncateMiddle,
isoDate,
+1 -3
View File
@@ -343,13 +343,11 @@ function init(ctx) {
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
resetRenderedViews();
// Settings itself, and the testnet banner above it, are brought up
// to date for the new network here.
// Settings itself is brought up to date for the new network here.
markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
updateDebugBanner(state.currentView);
showFlash("Switched to " + net.name + ".");
});
+14 -24
View File
@@ -48,16 +48,16 @@ function getTransactionType(tx) {
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
}
function txAddressHtml(address, ensName, title, chainId) {
function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address);
return (
`<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName, chainId })
renderAddressHtml(address, { title, ensName })
);
}
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
function txHashHtml(hash) {
const link = explorerUrl("tx", hash);
const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink;
}
@@ -83,10 +83,10 @@ function show(tx) {
method: tx.method || null,
contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's,
// and the links open its explorer: the active network can change,
// in Settings or when the user approves a site's request, before
// a later popup shows this screen again.
// the fee are in its native currency, not the active network's:
// the active network can change, in Settings or when the user
// approves a site's request, before a later popup shows this
// screen again.
chainId: tx.chainId,
},
};
@@ -96,24 +96,18 @@ function show(tx) {
function render() {
const tx = state.viewData.tx;
if (!tx) return;
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash, tx.chainId);
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
const fromTitle = addressTitle(tx.from, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml(
tx.from,
tx.fromEns,
fromTitle,
tx.chainId,
);
// A contract creation has no recipient: transactions.js gives it `to: ""`.
if (tx.to) {
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-to").innerHTML = txAddressHtml(
tx.to,
tx.toEns,
toTitle,
tx.chainId,
);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
} else {
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
@@ -152,7 +146,7 @@ function render() {
if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress, tx.chainId);
const link = explorerUrl("token", tx.contractAddress);
// Hand-rolled rather than renderAddressHtml() because the
// link goes to the explorer's /token/ page, not /address/.
// Same two-row shape though: dot and link on the strip, the
@@ -210,11 +204,7 @@ function showDetailField(sectionId, contentId, value) {
function populateOnChainDetails(txData, chainId) {
// Block number
if (txData.block_number != null) {
const blockLink = explorerUrl(
"block",
String(txData.block_number),
chainId,
);
const blockLink = explorerUrl("block", String(txData.block_number));
const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block");
if (blockSection && blockEl) {
@@ -350,9 +340,9 @@ async function loadFullTxDetails(txHash, chainId) {
if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
}
detailsHtml += renderAddressHtml(d.address, { chainId });
detailsHtml += renderAddressHtml(d.address);
} else if (d.address) {
detailsHtml += renderAddressHtml(d.address, { chainId });
detailsHtml += renderAddressHtml(d.address);
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
+28 -38
View File
@@ -14,13 +14,10 @@ const {
displaySymbol,
clearViewStack,
tokenLabel,
reviewedNetworkProvider,
} = require("./helpers");
const { state } = require("../../shared/state");
const {
SUPPORTED_CHAIN_IDS,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
// Receipt poll cadence and the deadline after which the wait is reported as
@@ -64,22 +61,21 @@ function endWait() {
}
// A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`). The links below open the explorer of the network
// with `chainId`, or of the active network when none is given.
function toAddressHtml(address, chainId) {
// writes `to: toAddr || ""`).
function toAddressHtml(address) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title, chainId });
return renderAddressHtml(address, { title });
}
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
function txHashHtml(hash) {
const link = explorerUrl("tx", hash);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
}
function blockNumberHtml(blockNumber, chainId) {
function blockNumberHtml(blockNumber) {
const num = String(blockNumber);
const link = explorerUrl("block", num, chainId);
const link = explorerUrl("block", num);
return copyableHtml(num) + etherscanLinkHtml(link);
}
@@ -95,11 +91,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: the active network can change, in
// Settings or when the user approves a site's request, while this screen
// is open or before a later popup resumes it. The links on all three
// screens open that network's explorer, and this screen's receipt is
// asked of that network's node, for the same reason. A null token is a
// swap whose amount was not decoded (see showTxApproval() in
// approval.js): the amount says so, and no symbol follows it.
// is open or before a later popup resumes it. A null token is a swap whose
// amount was not decoded (see showTxApproval() in approval.js): the amount
// says so, and no symbol follows it.
let symbol = "";
if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId);
@@ -108,8 +102,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
attachCopyHandlers("view-wait-tx");
// Persisted so closing and reopening the popup resumes this wait
@@ -134,7 +128,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
renderElapsed();
}, 1000);
const provider = reviewedNetworkProvider(txInfo);
const provider = getProvider(state.rpcUrl, state.networkId);
let consecutiveFailures = 0;
async function poll() {
@@ -213,11 +207,10 @@ function showWait(txInfo, txHash) {
// an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
// the node the receipt is asked of and the explorer the links open, so it has
// to name a network. txInfo.token and txInfo.tokenSymbol are deliberately
// unchecked: they are compared and coalesced rather than dereferenced, and
// tokenSymbol is null for ETH.
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
// ETH.
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -233,7 +226,6 @@ function restoreWait() {
// path itself created.
if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false;
if (!SUPPORTED_CHAIN_IDS.has(info.chainId)) return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
return false;
}
@@ -258,13 +250,12 @@ function showSuccess(txInfo, txHash, blockNumber) {
hash: txHash,
blockNumber: blockNumber,
decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
};
renderSuccess();
ctx.doRefreshAndRender();
}
function decodedDetailsHtml(decoded, chainId) {
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
if (decoded.name) {
@@ -282,9 +273,9 @@ function decodedDetailsHtml(decoded, chainId) {
if (d.isToken) {
const sym = tokenLabel(d.address) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += toAddressHtml(d.address, chainId);
html += toAddressHtml(d.address);
} else {
html += toAddressHtml(d.address, chainId);
html += toAddressHtml(d.address);
}
} else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
@@ -313,16 +304,16 @@ function renderSuccess() {
toSection.classList.remove("hidden");
$("success-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("success-tx-to").innerHTML = toAddressHtml(d.to);
}
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId);
$("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
$("success-tx-hash").innerHTML = txHashHtml(d.hash);
// Show decoded calldata details if present
const decodedEl = $("success-tx-decoded");
if (decodedEl && hasDecoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId);
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
@@ -348,7 +339,6 @@ function showError(txInfo, txHash, message) {
to: txInfo.to,
hash: txHash || null,
message: message,
chainId: txInfo.chainId,
};
renderError();
}
@@ -358,11 +348,11 @@ function renderError() {
if (!d || !d.message) return;
$("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-to").innerHTML = toAddressHtml(d.to);
$("error-tx-message").textContent = d.message;
if (d.hash) {
$("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
$("error-tx-hash").innerHTML = txHashHtml(d.hash);
$("error-tx-hash-section").classList.remove("hidden");
attachCopyHandlers("view-error-tx");
} else {
+172 -854
View File
File diff suppressed because it is too large Load Diff
+3 -17
View File
@@ -442,25 +442,11 @@ describe("a confirmation screen whose From is not the selected address", () => {
expect(Transaction.from(signed).from).toBe(derived(1).address);
});
// 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-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",
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.",
);
});
});
-415
View File
@@ -1,415 +0,0 @@
// The amount of the wallet's own send on the confirmation, wait, success and
// error screens (https://git.eeqj.de/sneak/AutistMask/issues/522).
//
// These screens printed the text from Send's amount field as it was typed or
// filled in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where the
// approval screen shows a site's send as `0.5000 ETH`. They now show it to four
// decimal places with the nonzero floor, as the approval screen does, and the
// transaction is still signed for the exact amount.
//
// Driven through the real Send screen, confirmation screen, Sign & Send and
// wait screen, with only the node, the explorer and the DOM stubbed.
"use strict";
// What the stub node answers, and the signed transactions it was handed. The
// token's own decimals() answers 6, as the explorer does.
const mockNode = {
balanceWei: 0n,
tokenUnits: 0n,
tokenDecimals: 6n,
broadcast: [],
broadcastError: null,
receipt: null,
};
// The token rows the stub explorer reports for the address.
const mockExplorer = { items: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// The selector of decimals(); every other contract call is balanceOf().
const DECIMALS = "0x313ce567";
class StubProvider {
async getBalance() {
return mockNode.balanceWei;
}
async call(tx) {
return actual.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[
tx.data.startsWith(DECIMALS)
? mockNode.tokenDecimals
: mockNode.tokenUnits,
],
);
}
async lookupAddress() {
return null;
}
// 20 gwei reserved per gas.
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getNetwork() {
return { chainId: 1n };
}
async broadcastTransaction(signed) {
if (mockNode.broadcastError) throw mockNode.broadcastError;
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
async getTransactionReceipt() {
return mockNode.receipt;
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The explorer's token list, which refreshBalances() also fetches.
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => mockExplorer.items,
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The confirmation screen's Etherscan label lookup is the only fetch() these
// screens make; it fails, as it does offline.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
children: [],
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
this.children.push(child);
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
// The success screen hides whole sections through .parentElement.
get parentElement() {
return global.document.getElementById(id + "-parent");
},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const {
Interface,
Transaction,
Wallet,
getAddress,
parseEther,
} = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const { encryptWithPassword } = require("../src/shared/vault");
const { state } = require("../src/shared/state");
const { clearPrices } = require("../src/shared/prices");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const txStatus = require("../src/popup/views/txStatus");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const PASSWORD = "correct horse battery staple";
const GWEI = 1000000000n;
// 1.234567890123456789 ETH.
const BALANCE_WEI = 1234567890123456789n;
// A token the bundled list does not know, with enough holders to be listed,
// at 6 decimal places.
const TOKEN = "0x" + "d".repeat(40);
const transferIface = new Interface([
"function transfer(address to, uint256 amount)",
]);
let encryptedSecret;
// The confirmation screen Review leads to, once shown.
let confirmed = null;
function el(id) {
return global.document.getElementById(id);
}
function text(id) {
return el(id).textContent;
}
// show() reads the balance and then estimates the fee without awaiting
// either; this lets both settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// The address holds BALANCE_WEI and, when `tokenUnits` is given, that many
// base units of TOKEN, fetched and stored where the popup stores them. Send
// is then open for `token` ("ETH" or TOKEN) with the recipient entered.
async function openSend(token, tokenUnits = null) {
mockNode.balanceWei = BALANCE_WEI;
mockNode.tokenUnits = tokenUnits ?? 0n;
mockExplorer.items =
tokenUnits === null
? []
: [
{
value: String(tokenUnits),
token: {
type: "ERC-20",
address_hash: TOKEN,
symbol: "TOK",
name: "Token",
decimals: "6",
holders_count: "50000",
},
},
];
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
state.trackedTokens,
"mainnet",
);
send.init({
showConfirmTx: (info) => (confirmed = info),
pageClosed: new AbortController().signal,
});
confirmTx.init({});
send.resetSendValidation();
state.currentView = "send";
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = "";
}
// Press Review and show the confirmation screen with its fee estimate settled.
async function review() {
await el("btn-send-review").handlers.get("click")();
confirmTx.show(confirmed);
await settle();
}
async function signAndSend() {
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
}
// The one transaction the stub node was handed.
function broadcastTx() {
expect(mockNode.broadcast).toHaveLength(1);
return Transaction.from(mockNode.broadcast[0]);
}
beforeAll(async () => {
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
beforeEach(() => {
elements.clear();
clearPrices();
confirmed = null;
state.selectedToken = null;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
mockNode.broadcast = [];
mockNode.broadcastError = null;
mockNode.receipt = null;
txStatus.init({
doRefreshAndRender() {},
pageClosed: new AbortController().signal,
});
});
afterEach(() => {
txStatus.endWait();
});
describe("the wallet's own send shows its amount to four decimal places", () => {
test("for a short amount", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
expect(text("confirm-amount")).toBe("0.5000 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
expect(broadcastTx().value).toBe(parseEther("0.5"));
});
// Send accepts both. The approval screen writes the same value `0.5000`.
test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => {
await openSend("ETH");
el("send-amount").value = typed;
await review();
expect(text("confirm-amount")).toBe("0.5000 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
expect(broadcastTx().value).toBe(parseEther("0.5"));
});
test("for a token amount typed as .5", async () => {
// 100 TOK.
await openSend(TOKEN, 100000000n);
el("send-amount").value = ".5";
await review();
expect(text("confirm-amount")).toBe("0.5000 TOK");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.5000 TOK");
const [, amount] = transferIface.decodeFunctionData(
"transfer",
broadcastTx().data,
);
expect(amount).toBe(500000n);
});
test("for a Max amount with many decimals", async () => {
await openSend("ETH");
await el("btn-send-max").handlers.get("click")();
// The balance minus 21000 gas at 20 gwei.
expect(el("send-amount").value).toBe("1.234147890123456789");
await review();
expect(text("confirm-amount")).toBe("1.2341 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("1.2341 ETH");
expect(broadcastTx().value).toBe(BALANCE_WEI - 21000n * 20n * GWEI);
});
// Cut to four decimals it would read 0.0000, so it is shown to its first
// significant digit instead, and signed in full.
test("for a tiny nonzero amount", async () => {
await openSend("ETH");
el("send-amount").value = "0.000012345";
await review();
expect(text("confirm-amount")).toBe("0.00001 ETH");
await signAndSend();
expect(text("wait-tx-summary")).toBe("0.00001 ETH");
expect(broadcastTx().value).toBe(parseEther("0.000012345"));
});
test("for a token amount", async () => {
// 100 TOK.
await openSend(TOKEN, 100000000n);
el("send-amount").value = "12.345678";
await review();
expect(text("confirm-amount")).toBe("12.3456 TOK");
await signAndSend();
expect(text("wait-tx-summary")).toBe("12.3456 TOK");
const tx = broadcastTx();
expect(tx.to).toBe(getAddress(TOKEN));
const [to, amount] = transferIface.decodeFunctionData(
"transfer",
tx.data,
);
expect(to).toBe(RECIPIENT);
expect(amount).toBe(12345678n);
});
test("on the success screen", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
await signAndSend();
// A popup opened later resumes the wait, and the receipt is there.
mockNode.receipt = { blockNumber: 21000000 };
txStatus.restoreWait();
await settle();
expect(state.currentView).toBe("success-tx");
expect(text("success-tx-summary")).toBe("0.5000 ETH");
});
test("on the error screen", async () => {
await openSend("ETH");
el("send-amount").value = "0.5";
await review();
mockNode.broadcastError = new Error("the node refused it");
await signAndSend();
expect(state.currentView).toBe("error-tx");
expect(text("error-tx-summary")).toBe("0.5000 ETH");
});
});
+6 -167
View File
@@ -11,115 +11,20 @@
//
// The check is driven here with a fake connected signer rather than a real
// one: populateTransaction() returns the fees the node would have produced,
// 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.
// 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.
// 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: [],
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
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");
});
// 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 { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
const {
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify");
@@ -193,69 +98,3 @@ 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();
});
});
-221
View File
@@ -1,221 +0,0 @@
// A late answer for a confirmation screen the user has left changes nothing on
// the screen shown since (https://git.eeqj.de/sneak/AutistMask/issues/575).
//
// The confirmation screen asks the node and the explorer about its transaction
// without waiting for the answers: the recipient checks, which can show three
// warning boxes, the balance read and the fee estimate. Back, a new recipient
// and Review again show the screen for another transaction while those
// questions may still be open. The recipient checks showed their boxes
// whenever they answered, so a box about the first recipient could appear on
// the second recipient's screen.
//
// Driven through the real confirmation screen, with only the node, the
// explorer and the DOM stubbed. Each test holds back the first screen's answer
// to one question, and gives it once the screen it tests is shown.
"use strict";
// The From address of every transaction here. The jest.mock() factory below
// may only name outside variables whose names start with "mock".
const mockFrom = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
// Every answer is a mock, so a test can hold one back. Every address has
// sent one transaction and has no code, and only the From address holds
// anything: 5 ETH.
class StubProvider {}
Object.assign(StubProvider.prototype, {
getBalance: jest.fn(async (address) =>
address === mockFrom ? actual.parseEther("5") : 0n,
),
getCode: jest.fn(async () => "0x"),
getTransactionCount: jest.fn(async () => 1),
// 10 gwei expected, 20 gwei reserved per gas.
getFeeData: jest.fn(async () => ({
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
})),
estimateGas: jest.fn(async () => 21000n),
});
return { ...actual, JsonRpcProvider: StubProvider };
});
// An explorer address page whose title is `title`.
function explorerPage(title) {
return { ok: true, text: async () => "<title>" + title + "</title>" };
}
// The explorer labels no address.
global.fetch = jest.fn(async () => explorerPage("Etherscan"));
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
addEventListener() {},
querySelectorAll: () => [],
remove() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
};
const { JsonRpcProvider, parseEther } = require("ethers");
const { loadState } = require("../src/shared/state");
const confirmTx = require("../src/popup/views/confirmTx");
const node = JsonRpcProvider.prototype;
const FIRST = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SECOND = "0xE2E0000000000000000000000000000000000E2e";
function el(id) {
return global.document.getElementById(id);
}
function shown(id) {
return el(id).style.visibility === "visible";
}
// Show the confirmation screen for 1 ETH to `to` on mainnet, as Review on the
// Send screen does.
function review(to) {
confirmTx.show({
from: mockFrom,
to,
ensName: null,
amount: "1",
token: "ETH",
tokenSymbol: null,
tokenDecimals: null,
max: false,
});
}
// show() does not wait for its questions; this lets every answer not held
// back arrive.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// Hold back the next answer `question` gives. Returns the function that gives
// it.
function holdNextAnswer(question) {
let give;
question.mockImplementationOnce(() => new Promise((r) => (give = r)));
return (answer) => give(answer);
}
beforeAll(async () => {
await loadState();
});
beforeEach(() => {
elements.clear();
});
// The three boxes the recipient checks show, each with the question whose
// answer shows it for FIRST, and that answer. FIRST holds no ETH, so the
// answer that it has sent no transaction is what shows the box saying so.
describe.each([
{
box: "confirm-contract-warning",
question: node.getCode,
answer: "0x6080604052",
},
{
box: "confirm-recipient-warning",
question: node.getTransactionCount,
answer: 0,
},
{
box: "confirm-etherscan-warning",
question: global.fetch,
answer: explorerPage(
"Fake_Phishing1 | Address: " + FIRST + " | Etherscan",
),
},
])("the $box box", ({ box, question, answer }) => {
test("is shown when the answer about the recipient arrives while its screen is shown", async () => {
const give = holdNextAnswer(question);
review(FIRST);
await settle();
expect(shown(box)).toBe(false);
give(answer);
await settle();
expect(shown(box)).toBe(true);
});
test("is not shown on the next recipient's screen when the answer about the first arrives after that screen is shown", async () => {
const give = holdNextAnswer(question);
review(FIRST);
await settle();
review(SECOND);
await settle();
expect(shown(box)).toBe(false);
give(answer);
await settle();
expect(shown(box)).toBe(false);
});
});
test("a balance read answered after the next screen is shown leaves that screen's balance and balance check as they are", async () => {
const give = holdNextAnswer(node.getBalance);
review(FIRST);
await settle();
review(SECOND);
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
give(parseEther("0.5"));
await settle();
expect(el("confirm-balance").textContent).toBe("5.0000 ETH");
expect(shown("confirm-balance-error")).toBe(false);
expect(el("btn-confirm-send").disabled).toBe(false);
});
test("a fee estimate answered after the next screen is shown leaves that screen's fee as it is", async () => {
const give = holdNextAnswer(node.getFeeData);
review(FIRST);
await settle();
review(SECOND);
await settle();
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
// 100 times the fee the second screen was quoted.
give({
maxFeePerGas: 2000000000000n,
maxPriorityFeePerGas: 100000000000n,
gasPrice: 1000000000000n,
});
await settle();
expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH");
expect(el("confirm-fee-reserve").textContent).toBe(
"up to 0.0004 ETH reserved",
);
});
+20 -7
View File
@@ -333,7 +333,7 @@ function expectNativeTransferOn(networkId) {
"Native " + symbol + " transfer",
);
expect(el("confirm-amount").textContent).toBe(
"1.0000 " + symbol + usdOn(networkId, "$2,000.00"),
"1 " + symbol + usdOn(networkId, "$2,000.00"),
);
expect(el("confirm-balance").textContent).toBe(
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
@@ -440,8 +440,12 @@ describe.each([
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(shown("confirm-network-changed-error")).toBe(true);
expect(shown("confirm-fee-bound-error")).toBe(false);
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.",
);
});
},
);
@@ -493,7 +497,10 @@ describe.each([
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(shown("confirm-network-changed-error")).toBe(true);
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
});
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
@@ -572,7 +579,10 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(shown("confirm-network-changed-error")).toBe(true);
expect(errors()).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
});
const tokenSend = {
@@ -619,7 +629,10 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(shown("confirm-network-changed-error")).toBe(true);
expect(errors()).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
});
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
@@ -649,7 +662,7 @@ describe.each([
`href="${explorerUrl}/address/${TOKEN}"`,
);
expect(el("confirm-amount").textContent).toBe(
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
"10 USDC" + usdOn(reviewedOn, "$10.00"),
);
expect(el("confirm-balance").textContent).toBe(
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
-1
View File
@@ -192,7 +192,6 @@ describe("the wait, success and error screens", () => {
amount: "0.0000",
token: "ETH",
tokenSymbol: null,
chainId: "0x1",
};
const transfer = { ...creation, to: RECIPIENT };
+4 -7
View File
@@ -226,9 +226,8 @@ const RPC_RESULTS = {
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// 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. opts.baseFeeWei replaces its base fee,
// read at request time like every other fixture switch.
function latestBlock(opts) {
// stop being able to distinguish them.
function latestBlock() {
return {
hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32),
@@ -240,7 +239,7 @@ function latestBlock(opts) {
gasUsed: "0xf4240",
miner: STUB_COUNTERPARTY,
extraData: "0x",
baseFeePerGas: opts.baseFeeWei || hex(BASE_FEE_WEI),
baseFeePerGas: hex(BASE_FEE_WEI),
transactions: [],
};
}
@@ -482,7 +481,7 @@ function rpcReply(req, opts, report) {
});
}
if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock(opts) });
return Object.assign(envelope, { result: latestBlock() });
}
// The end of the dApp transaction round trip: the raw signed transaction
// the background hands to the node. It is recorded verbatim so a test can
@@ -626,8 +625,6 @@ function traceEnabled(raw) {
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
* 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
+2 -276
View File
@@ -1824,65 +1824,6 @@ test("the Settings assertions above all ran (#229)", async (env) => {
);
});
// ------------------------------------------------ testnet banner (#511)
// This suite runs a release build, so the red banner at the top reads
// "[TESTNET]" on Sepolia and is absent on mainnet. A network switch in
// Settings must change it while Settings is still on screen; it used to
// change only when the next screen came up
// (https://git.eeqj.de/sneak/AutistMask/issues/511).
// The banner's text, or null when there is none, once the switch to `net`
// has finished. The switch's flash is the last thing its handler does, and
// showView() clears the flash, so the banner read while that flash is up is
// the one the switch left on the screen it was made from.
async function bannerAfterSwitch(page, net) {
await page.waitForFunction(
(text) => document.getElementById("flash-msg").textContent === text,
"Switched to " + net.name + ".",
);
return page.evaluate(() => {
const banner = document.getElementById("debug-banner");
return {
text: banner === null ? null : banner.textContent,
onSettings: !document
.getElementById("view-settings")
.classList.contains("hidden"),
};
});
}
test("a network switch in Settings shows and removes the testnet banner at once (#511)", async (env) => {
const { mainnet, sepolia } = NETWORKS;
await openSettings(env.page);
assert(
(await env.page.locator("#debug-banner").count()) === 0,
"a banner is up on mainnet before the switch",
);
await env.page.selectOption("#settings-network", "sepolia");
let after = await bannerAfterSwitch(env.page, sepolia);
assert(after.onSettings, "Settings left the screen on the switch");
assert(
after.text === "[TESTNET]",
"after the switch to Sepolia, Settings shows the banner " +
JSON.stringify(after.text) +
', expected "[TESTNET]"',
);
await env.page.selectOption("#settings-network", "mainnet");
after = await bannerAfterSwitch(env.page, mainnet);
assert(after.onSettings, "Settings left the screen on the switch back");
assert(
after.text === null,
"after the switch back to mainnet, Settings still shows the banner " +
JSON.stringify(after.text),
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-main");
});
// ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this
@@ -2291,8 +2232,7 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, how tall the whole view is, and where the password field and
// Sign & Send sit in it.
// fee block says, how tall the whole view is, and where Sign & Send sits in it.
async function confirmState(page) {
return page.evaluate(() => {
const el = (id) => document.getElementById(id);
@@ -2305,9 +2245,6 @@ 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,
@@ -2325,12 +2262,6 @@ 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,
};
});
@@ -2974,10 +2905,9 @@ 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 === "0.2500 " + STUB_TOKEN.symbol,
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
"the confirmation screen is not showing the amount that was entered: " +
JSON.stringify(shown),
);
@@ -3127,210 +3057,6 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
}
});
// ------------------------- a send Sign & Send refuses moves nothing (#583)
//
// README, No Layout Shift. Sign & Send refuses a send on the confirmation
// screen when the active network changed since review and when its gas limit
// or fee is over its bound, and each refusal's sentence sits in a box that held
// its space from the first paint. This drives the total fee refusal, the longer
// of the two sentences.
// The base fee the stub node quotes once Sign & Send is pressed. ethers asks
// twice it plus the 1 gwei priority fee per gas, 50,001 gwei, so the 21,000-gas
// send would allow a fee of about 1.05 ETH, over the 1 ETH this wallet signs
// for (MAX_TOTAL_FEE in src/shared/approvalVerify.js).
const OVER_BOUND_BASE_FEE_WEI = 25000n * 10n ** 9n;
test("a send refused for its fee leaves the password field and Sign & Send where they were (#583)", async (env) => {
env.errors.expect("confirmTx logging the refused send", /send refused/);
try {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await waitForEstimate(env.page);
const before = await confirmState(env.page);
assert(
!before.sendDisabled && before.feeBoundError === "",
"the screen is not ready to send: " + JSON.stringify(before),
);
// Only now, with the screen drawn and its fee estimated, does the
// node quote a fee over the bound.
env.routeOpts.baseFeeWei = toHexWei(OVER_BOUND_BASE_FEE_WEI);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await env.page.waitForFunction(
() =>
getComputedStyle(
document.getElementById("confirm-fee-bound-error"),
).visibility === "visible",
null,
{ timeout: 60000 },
);
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused send: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send over the fee bound reached the RPC",
);
assert(
after.feeBoundError ===
"The gas limit or fee the RPC endpoint quoted for this" +
" transaction is more than this wallet will sign for, so" +
" it was not sent. Please go back and try again.",
"wrong fee refusal: " + JSON.stringify(after.feeBoundError),
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
} finally {
env.routeOpts.baseFeeWei = undefined;
await backToAddress(env.page);
}
});
// --------- a send from an address the selected wallet does not hold (#609)
//
// The selection is saved apart from the confirmation screen, so the popup can
// reopen onto the screen with a wallet selected that does not hold its From
// address, and Sign & Send then refuses the send. Another popup selecting the
// key wallet would save that selection; here it is written into storage, and
// the popup reopened.
test("a send from an address the selected wallet does not hold leaves the password field and Sign & Send where they were (#609)", async (env) => {
try {
// From the HD wallet's address.
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
// The screen's own save lands first, so it cannot write over the
// selection below.
await waitForPersisted(
env.page,
"currentView",
"confirm-tx",
"before selecting the key wallet",
);
await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
const record = {
...r.autistmask,
selectedWallet: 1,
selectedAddress: 0,
};
chrome.storage.local.set(
{ autistmask: record },
resolve,
);
});
}),
);
await reopenPopup(env, "confirm-tx");
await waitForEstimate(env.page);
// The refusal shown anywhere on the screen, not only in its own box:
// shown on the password error line instead, the test fails on the
// moves checked below rather than on the wait for it.
const refusal = env.page
.locator("#view-confirm-tx")
.getByText("does not hold the From address above")
.filter({ visible: true });
const before = await confirmState(env.page);
assert(
!before.sendDisabled && (await refusal.count()) === 0,
"the screen is not ready to send: " + JSON.stringify(before),
);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await refusal.waitFor({ timeout: 60000 });
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused From address: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send from an address the selected wallet does not hold" +
" reached the RPC",
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
// Only now, where the refusal is shown. Its sentence is wrapped across
// lines in index.html.
const shownIn = await refusal.evaluate((el) => ({
id: el.id,
text: el.textContent.replace(/\s+/g, " ").trim(),
}));
assert(
shownIn.id === "confirm-from-address-error" &&
shownIn.text ===
"The selected wallet does not hold the From address above," +
" so nothing was sent. Please go back and send again.",
"wrong From address refusal: " + JSON.stringify(shownIn),
);
} finally {
// A reopened popup restores no Send screen beneath this one, so Back
// lands on the selected address. Then Home, and the HD wallet's
// address, which the later tests send from.
if (await env.page.isVisible("#view-confirm-tx")) {
await env.page.click("#btn-confirm-back");
}
if (await env.page.isVisible("#view-send")) {
await env.page.click("#btn-send-back");
}
await visible(env.page, "#view-address");
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await openAddressDetail(env.page);
}
});
// ------------------------------ the wait for a receipt ending in error (#315)
//
// 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.1000 " + symbol);
expect(text("confirm-amount")).toBe("0.1 " + 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.1000 " + symbol);
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
state.networkId = activeNetworkId;
txStatus.restoreWait();
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
});
test("the transaction detail screen's type line and fee", async () => {
+1 -38
View File
@@ -441,17 +441,6 @@ const CONTRACT = [
views: ["error-tx"],
restored: false,
},
// A chain id naming no network has no explorer to link to.
{
value: { hash: "0x1", to: ADDRESS, chainId: "0x5" },
views: ["success-tx"],
restored: false,
},
{
value: { message: "boom", to: ADDRESS, chainId: "0x5" },
views: ["error-tx"],
restored: false,
},
// transaction passes on `data.tx`.
{
value: { tx: { hash: "0x1" } },
@@ -470,19 +459,6 @@ const CONTRACT = [
views: ["transaction"],
restored: false,
},
// A chain id naming no network has no explorer to link to.
{
value: {
tx: {
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
chainId: "0x5",
},
},
views: ["transaction"],
restored: false,
},
// confirm-tx passes on `data.pendingTx`.
{
value: { pendingTx: { amount: "1" } },
@@ -501,19 +477,6 @@ 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: {
@@ -845,7 +808,7 @@ const WELL_FORMED_DATA = {
},
pendingWait: {
hash: "0x1",
txInfo: { to: ADDRESS, amount: "1", chainId: "0x1" },
txInfo: { to: ADDRESS, amount: "1" },
broadcastTime: BROADCAST_TIME,
},
};
+6 -12
View File
@@ -306,8 +306,7 @@ describe("Max on an ETH send", () => {
openSend();
await pressMax();
await review();
expect(confirmed.amount).toBe(maxAfter(20n * GWEI));
expect(text("confirm-amount")).toBe("1.2341 ETH");
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
expect(canSend()).toBe(true);
@@ -323,16 +322,14 @@ describe("Max on an ETH send", () => {
// Kept, the Send screen's amount would be refused for want of funds.
mockNode.feeData = fees(30n * GWEI);
await review();
expect(confirmed.amount).toBe(maxAfter(30n * GWEI));
expect(text("confirm-amount")).toBe("1.2339 ETH");
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " 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(confirmed.amount).toBe(maxAfter(10n * GWEI));
expect(text("confirm-amount")).toBe("1.2343 ETH");
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
expect(canSend()).toBe(true);
});
@@ -529,8 +526,7 @@ describe("Max on an ETH send", () => {
el("send-amount").handlers.get("input")();
mockNode.feeData = fees(30n * GWEI);
await review();
expect(confirmed.amount).toBe("0.5");
expect(text("confirm-amount")).toBe("0.5000 ETH");
expect(text("confirm-amount")).toBe("0.5 ETH");
});
});
@@ -544,8 +540,7 @@ describe("Max on a token send", () => {
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(confirmed.amount).toBe("1234.567890123456789012");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(canSend()).toBe(true);
});
@@ -558,8 +553,7 @@ describe("Max on a token send", () => {
await pressMax();
expect(el("send-amount").value).toBe("1234.567890123456789012");
await review();
expect(confirmed.amount).toBe("1234.567890123456789012");
expect(text("confirm-amount")).toBe("1234.5678 TOK");
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
expect(canSend()).toBe(true);
});
+1 -6
View File
@@ -225,12 +225,7 @@ describe("a popup already open when the stored profile becomes unreadable", () =
viewData: {
pendingWait: {
hash: "0x1",
txInfo: {
to: ADDRESS,
amount: "1",
token: "ETH",
chainId: "0x1",
},
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
broadcastTime: Date.now(),
},
},
+2 -45
View File
@@ -8,10 +8,6 @@
// tracks. It decodes against the address the explorer reports the transaction
// was sent to.
//
// Its links open the explorer of the network the transaction was sent on,
// whichever network is active
// (https://git.eeqj.de/sneak/AutistMask/issues/584).
//
// The row is the one the real history parser makes from the explorer's answer,
// opened through transactionDetail.show(), which is what a click on the row
// calls. Driven against a minimal DOM stub in the shape
@@ -39,7 +35,6 @@ globalThis.chrome = {
};
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail");
@@ -82,11 +77,7 @@ function mockExplorerAnswer(url) {
};
}
if (url === EXPLORER + "/transactions/" + TX_HASH) {
return {
to: { hash: TOKEN },
raw_input: TRANSFER_DATA,
block_number: 100,
};
return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA };
}
return { items: [] };
}
@@ -163,11 +154,7 @@ beforeEach(() => {
});
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.mainnet.chainId,
);
const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1);
expect(row.to).toBe(RECIPIENT);
expect(row.contractAddress).toBe(TOKEN.toLowerCase());
@@ -187,33 +174,3 @@ test("a token transfer's decoded details name the token contract and the amount
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
// Mainnet is active and the row was read from Sepolia's explorer. The stub
// explorer answers whichever network asks, so every link on the screen is
// drawn.
test("with mainnet active, a Sepolia transaction's links open the Sepolia explorer", async () => {
state.networkId = "mainnet";
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
const sepolia = "https://sepolia.etherscan.io";
const links = (id) =>
[...node(id).innerHTML.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
expect(links("tx-detail-hash")).toEqual([`${sepolia}/tx/${TX_HASH}`]);
expect(links("tx-detail-from")).toEqual([`${sepolia}/address/${HOLDER}`]);
expect(links("tx-detail-to")).toEqual([`${sepolia}/address/${RECIPIENT}`]);
expect(links("tx-detail-token-contract")).toEqual([
`${sepolia}/token/${TOKEN.toLowerCase()}`,
]);
expect(links("tx-detail-block")).toEqual([`${sepolia}/block/100`]);
expect(links("tx-detail-calldata-details")).toEqual([
`${sepolia}/address/${TOKEN}`,
`${sepolia}/address/${RECIPIENT}`,
]);
});
+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-network-changed-error").style.visibility,
).toBe("visible");
expect(boot.node("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was reviewed",
);
// Refused before the password is even tried.
expect(boot.text("confirm-tx-password-error")).toBe("");
});
+3 -149
View File
@@ -29,13 +29,8 @@ jest.mock("../src/shared/log", () => ({
}));
const mockReceiptLookup = jest.fn();
// The RPC URL and network of every node a wait was given.
const mockNodes = [];
jest.mock("../src/shared/balances", () => ({
getProvider: (rpcUrl, networkId) => {
mockNodes.push({ rpcUrl, networkId });
return { getTransactionReceipt: mockReceiptLookup };
},
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
refreshBalances: jest.fn(),
}));
@@ -112,7 +107,6 @@ global.chrome = { storage };
const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const { log } = require("../src/shared/log");
@@ -124,7 +118,6 @@ const TX_INFO = {
amount: "0.0050",
token: "ETH",
tokenSymbol: null,
chainId: NETWORKS.mainnet.chainId,
};
// True when a view element is not hidden.
@@ -144,11 +137,7 @@ beforeEach(() => {
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
elements.clear();
mockReceiptLookup.mockReset();
mockNodes.length = 0;
log.errorf.mockClear();
state.networkId = "mainnet";
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
state.networkEndpoints = {};
state.wallets = [];
state.viewData = {};
state.viewStack = [];
@@ -360,14 +349,14 @@ describe("WaitTx persistence across popup close", () => {
// out of restoreView().
{
hash: TX_HASH,
txInfo: { ...TX_INFO, to: 42 },
txInfo: { to: 42, amount: "0.0050" },
broadcastTime: Date.now(),
},
// Otherwise complete but an array: only Array.isArray() rejects
// it, since typeof [] is "object" and the fields are present.
{
hash: TX_HASH,
txInfo: Object.assign([], TX_INFO),
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
broadcastTime: Date.now(),
},
{
@@ -375,18 +364,6 @@ describe("WaitTx persistence across popup close", () => {
txInfo: { to: RECIPIENT },
broadcastTime: Date.now(),
},
// Otherwise complete but for a chain id naming no network: there
// is no node to ask for the receipt, and no explorer to link to.
{
hash: TX_HASH,
txInfo: { ...TX_INFO, chainId: undefined },
broadcastTime: Date.now(),
},
{
hash: TX_HASH,
txInfo: { ...TX_INFO, chainId: "0x5" },
broadcastTime: Date.now(),
},
]) {
state.viewData = { pendingWait: bad };
expect(txStatus.restoreWait()).toBe(false);
@@ -522,129 +499,6 @@ describe("WaitTx when the popup closes", () => {
});
});
// The popup can reopen onto WaitTx after Settings switched the network. The
// wait asked the active network's node for the receipt, never found it, and
// reported a timeout, and its links went to the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/584).
describe("WaitTx for a Sepolia transaction with mainnet active", () => {
const SEPOLIA_NODE = "https://sepolia.node.example.invalid";
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
beforeEach(() => {
state.rpcUrl = "https://mainnet.node.example.invalid";
state.networkEndpoints = { sepolia: { rpcUrl: SEPOLIA_NODE } };
});
test("the receipt is asked of the Sepolia node Settings has", async () => {
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
state.viewData = {
pendingWait: {
txInfo: sepoliaTx,
hash: TX_HASH,
broadcastTime: Date.now(),
},
};
expect(txStatus.restoreWait()).toBe(true);
await jest.advanceTimersByTimeAsync(0);
expect(mockNodes).toEqual([
{ rpcUrl: SEPOLIA_NODE, networkId: "sepolia" },
]);
expect(state.currentView).toBe("success-tx");
});
test("the recipient and hash link to the Sepolia explorer", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait(sepoliaTx, TX_HASH);
expect(getElement("wait-tx-to").innerHTML).toContain(
`href="https://sepolia.etherscan.io/address/${RECIPIENT}"`,
);
expect(getElement("wait-tx-hash").innerHTML).toContain(
`href="https://sepolia.etherscan.io/tx/${TX_HASH}"`,
);
});
});
// SuccessTx and ErrorTx follow WaitTx and describe the same transaction, but
// their links went to the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/622). Each test checks the
// screen again after rendering it from its stored view data alone, as a
// reopened popup does.
describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active", () => {
const SEPOLIA_EXPLORER = "https://sepolia.etherscan.io";
const TOKEN = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
test("the recipient, block and hash on SuccessTx link to the Sepolia explorer", async () => {
function expectSepoliaLinks() {
expect(getElement("success-tx-to").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
expect(getElement("success-tx-block").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/block/21000000"`,
);
expect(getElement("success-tx-hash").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
);
}
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait(sepoliaTx, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectSepoliaLinks();
elements.clear();
txStatus.renderSuccess();
expectSepoliaLinks();
});
test("the addresses in SuccessTx's decoded action link to the Sepolia explorer", async () => {
function expectSepoliaLinks() {
const html = getElement("success-tx-decoded").innerHTML;
expect(html).toContain(
`href="${SEPOLIA_EXPLORER}/address/${TOKEN}"`,
);
expect(html).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
}
const decoded = {
name: "Transfer",
details: [
{ label: "Token", address: TOKEN, isToken: true },
{ label: "Recipient", address: RECIPIENT },
],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(state.currentView).toBe("success-tx");
expectSepoliaLinks();
elements.clear();
txStatus.renderSuccess();
expectSepoliaLinks();
});
test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => {
function expectSepoliaLinks() {
expect(getElement("error-tx-to").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`,
);
expect(getElement("error-tx-hash").innerHTML).toContain(
`href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`,
);
}
txStatus.showError(sepoliaTx, TX_HASH, "The transaction failed.");
expectSepoliaLinks();
elements.clear();
txStatus.renderError();
expectSepoliaLinks();
});
});
describe("wait-tx is a view the popup may reopen onto", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills
+9 -556
View File
@@ -739,9 +739,7 @@ describe("uniswap decoder", () => {
});
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
// UNWRAP_WETH makes the output ETH. That step is paid by the user, so the
// USDC the exact-out step bought stays in the router, and the minimum is
// not received (https://git.eeqj.de/sneak/AutistMask/issues/606).
// UNWRAP_WETH makes the output ETH.
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute(
solidityPacked(
@@ -769,7 +767,7 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. paid out").value).toBe("0.4000 ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
});
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
@@ -989,23 +987,13 @@ 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,
// and the router lets the step fail
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
// not read. With the allow-revert flag (0x80) the command byte is 0xa1.
test.each([
{
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)",
},
{ flag: "without", command: 0x21 },
{ flag: "with", command: 0xa1 },
])(
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
({ command, steps }) => {
({ command }) => {
const subPlan = encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR,
@@ -1025,7 +1013,9 @@ describe("uniswap decoder", () => {
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe(steps);
expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)",
);
// The sub-plan names both currencies, so the lines must not say
// the calldata did not.
expect(detail(result, "Token In").value).toBe(
@@ -1289,543 +1279,6 @@ describe("uniswap decoder", () => {
expect(detail(result, "Amount").value).not.toContain("500000000000");
});
// https://git.eeqj.de/sneak/AutistMask/issues/625 — a route split across
// two swap steps the user pays for, with no PERMIT2_PERMIT, takes both
// steps' amounts from the wallet. On next at 671b4d3 the Amount line read
// only the first step's 600.0000 USDT.
describe("a swap split across steps the user pays for", () => {
const USDT_600 = 600000000n;
const USDT_400 = 400000000n;
const DAI_400 = 400000000000000000000n;
const OTHER_ADDR = "0x1111111111111111111111111111111111111111";
const UNDECODED = "Unknown (could not be decoded)";
// The lines between Token In and Token Out, as [label, value].
function linesBetweenTokens(result) {
const lines = result.details.map((d) => [d.label, d.value]);
const from = lines.findIndex(([label]) => label === "Token In");
const to = lines.findIndex(([label]) => label === "Token Out");
return lines.slice(from + 1, to);
}
// execute() of these command bytes and inputs, decoded as sent by
// the user.
function decodeSteps(commands, inputs) {
const data = buildExecute(
solidityPacked(
commands.map(() => "uint8"),
commands,
),
inputs,
9999999999n,
);
return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
}
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the
// user, which sets the input side.
const V3_600 = encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]);
// V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of
// `currency` to `recipient`.
const settle = (currency, amount, payerIsUser) =>
coder.encode(
["address", "uint256", "bool"],
[currency, amount, payerIsUser],
);
const take = (currency, recipient, amount) =>
coder.encode(
["address", "address", "uint256"],
[currency, recipient, amount],
);
// `input` with the top byte of its word at `index` set. This decoder
// cannot read such a word as an address or a length; the router reads
// its low bytes and runs the step.
const dirtyWord = (input, index) => {
const at = 2 + index * 64;
return input.slice(0, at) + "ff" + input.slice(at + 2);
};
test("a split across a V3 and a V2 step the user pays for shows the total", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Token In").address.toLowerCase()).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Amount").rawValue).toBe("1000.0000");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
// In the order of Uniswap's own V4 routes: the swap, then a SETTLE of
// OPEN_DELTA by the user, which pays what the swap spends.
test("a split across a V3 step and a V4 step the user pays for shows the total", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_400,
390000000n,
),
encodeV4Settle(USDT_ADDR), // paid by the user
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
test("a second step the user pays for that spends another token shows that token's amount", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(
USER_ADDR,
400000000000000000000n, // 400 DAI
390000000n,
[DAI_ADDR, USDC_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
]);
});
// Every later V4 step takes from the sender what its SETTLEs paid by
// the user and its SETTLE_ALLs pay, each currency on its own, with a
// swap action or without. Each of these read 600.0000 USDT alone
// before.
const USDT_FOR_USDC_400 = encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_400,
390000000n,
);
const DAI_FOR_USDC_400 = encodeV4ExactInSingle(
DAI_ADDR,
USDC_ADDR,
DAI_400,
390000000n,
);
const DAI_GROUP = [
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
test.each([
{
kind: "a V4 step paying USDT and DAI by a SETTLE_ALL of each",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
DAI_FOR_USDC_400,
encodeV4SettleAll(USDT_ADDR, USDT_400),
encodeV4SettleAll(DAI_ADDR, DAI_400),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "1000.0000 USDT"], ...DAI_GROUP],
},
{
kind: "a V4 step with a SETTLE of USDT by the user, then a SETTLE of WETH from the router",
actions: [
V4_SETTLE_ID,
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ALL_ID,
],
params: [
settle(USDT_ADDR, USDT_400, true),
settle(WETH_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE
USDT_FOR_USDC_400,
encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, 0n, 1n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "1000.0000 USDT"]],
},
{
kind: "a V4 step with no swap action: a SETTLE of 400 USDT by the user and a TAKE of it to another address",
actions: [V4_SETTLE_ID, V4_TAKE_ID],
params: [
settle(USDT_ADDR, USDT_400, true),
take(USDT_ADDR, OTHER_ADDR, USDT_400),
],
lines: [["Amount", "1000.0000 USDT"]],
},
// The SETTLE's amount, not the swap's, is what the user pays.
{
kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address",
actions: [
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
V4_TAKE_ALL_ID,
],
params: [
settle(USDT_ADDR, 5000000000n, true),
USDT_FOR_USDC_400,
take(USDT_ADDR, OTHER_ADDR, 0n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "5600.0000 USDT"]],
},
// A SETTLE_ALL pays all the step owes, here what the TAKE takes as
// well as what the swap spends, so its amount is not known.
{
kind: "a V4 step with no swap action: a TAKE of 400 USDT to another address, then a SETTLE_ALL of USDT",
actions: [V4_TAKE_ID, V4_SETTLE_ALL_ID],
params: [
take(USDT_ADDR, OTHER_ADDR, USDT_400),
encodeV4SettleAll(USDT_ADDR, USDT_400),
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
],
},
{
kind: "a V4 step swapping 400 USDT, with a TAKE of 400 USDT to another address before its SETTLE_ALL of USDT",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
take(USDT_ADDR, OTHER_ADDR, USDT_400),
encodeV4SettleAll(USDT_ADDR, 800000000n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
],
},
])(
"a V3 step of 600 USDT, then $kind",
({ actions, params, lines }) => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// A step that may take from the sender what the decoder did not read
// leaves no Amount figure, which may be too low, and one line says so.
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
test.each([
{
kind: "a V4 step whose SETTLE by the user cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
dirtyWord(settle(USDT_ADDR, USDT_400, true), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
{
kind: "a V4 step whose SETTLE_ALL cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
dirtyWord(encodeV4SettleAll(USDT_ADDR, USDT_400), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
{
kind: "a V4 step whose swap action and SETTLE_ALL cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
UNREAD_SWAP,
dirtyWord(encodeV4SettleAll(WETH_ADDR, ONE_WETH), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
])("a V3 step of 600 USDT, then $kind", ({ actions, params }) => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
});
test.each([
{
kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
commands: [0x00, 0x10],
inputs: [
V3_600,
dirtyWord(
encodeV4Swap(new Uint8Array([V4_SETTLE_ID]), [
settle(USDT_ADDR, USDT_400, true),
]),
2,
),
],
},
{
kind: "a V4 step with no swap action whose SETTLE_ALL cannot be read, then a V3 step of 600 USDT",
commands: [0x10, 0x00],
inputs: [
encodeV4Swap(
new Uint8Array([V4_TAKE_ID, V4_SETTLE_ALL_ID]),
[
take(USDT_ADDR, OTHER_ADDR, USDT_400),
dirtyWord(
encodeV4SettleAll(USDT_ADDR, USDT_400),
0,
),
],
),
V3_600,
],
},
{
kind: "a V3 step of 600 USDT, then a V2 step the user pays for whose input cannot be read",
commands: [0x00, 0x08],
inputs: [
V3_600,
dirtyWord(
encodeV2SwapExactIn(OTHER_ADDR, USDT_400, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
0,
),
],
},
{
kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM whose input cannot be read",
commands: [0x00, 0x02],
inputs: [
V3_600,
dirtyWord(
coder.encode(
["address", "address", "uint160"],
[USDT_ADDR, OTHER_ADDR, USDT_400],
),
1,
),
],
},
{
kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x00, 0x0d],
inputs: [
V3_600,
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
),
],
},
])("$kind", ({ commands, inputs }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
});
// A PERMIT2_PERMIT that sets the input side keeps its own amount: the
// steps it lets the router spend do not add to it.
test("a split after a PERMIT2_PERMIT shows the permit's amount", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x00, 0x08]),
[
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), // 5000 USDT
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("5000.0000 USDT");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
// A permit to another spender, or one the router lets fail (0x80 on
// the command byte), caps nothing the router takes, so what each
// later step takes of its token gets its own lines.
test.each([
{
kind: "to another spender",
command: 0x0a,
spender: OTHER_ADDR,
},
{
kind: "that the router lets fail",
command: 0x8a,
spender: ROUTER_ADDR,
},
])(
"a split after a PERMIT2_PERMIT $kind shows what each step takes",
({ command, spender }) => {
const result = decodeSteps(
[command, 0x00, 0x08],
[
encodePermit2(USDT_ADDR, 1000000n, spender), // 1 USDT
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
// The V3 step names its token as its path spells it, in
// lower case.
const moved = ["Moved to", "The swap step that spends it"];
expect(linesBetweenTokens(result)).toEqual([
["Amount", "1.0000 USDT"],
["Moved from the sender", "600.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR.toLowerCase() + ")"],
moved,
["Moved from the sender", "400.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
moved,
]);
},
);
// A V2 exact-out step makes every amount the swap steps spend a maximum,
// the total included.
test("a split with a V2 exact-out step the user pays for shows the total as a maximum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x09]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactOut(USER_ADDR, 390000000n, USDT_400, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
// the shape the issue measured: a V4 step that states a minimum of zero
// and names no output currency.
File diff suppressed because it is too large Load Diff
+10 -11
View File
@@ -71,8 +71,6 @@ 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"],
@@ -80,13 +78,8 @@ function balanceCheck(token, minBalance) {
);
}
// A transaction from USER.
function decode(data) {
return uniswap.decode(data, ROUTER, "0x1", {}, USER);
}
function detail(data, label) {
const decoded = decode(data);
const decoded = uniswap.decode(data, ROUTER, "0x1", {});
expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label);
}
@@ -120,7 +113,9 @@ describe("an execute() whose input currency never decoded", () => {
});
test("does not name ETH in the swap title either", () => {
expect(decode(data()).name).toBe("Uniswap Swap");
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Uniswap Swap",
);
});
});
@@ -130,7 +125,9 @@ describe("an execute() that names only an output token", () => {
test("refuses the input rather than defaulting it to ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL);
expect(decode(data()).name).toBe("Uniswap Swap");
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
"Uniswap Swap",
);
});
test("still states the output it did establish", () => {
@@ -178,7 +175,9 @@ describe("native ETH in, which V4 spells as the zero address", () => {
expect(detail(data, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
expect(decode(data).name).toBe("Swap ETH → USDT");
expect(uniswap.decode(data, ROUTER, "0x1", {}).name).toBe(
"Swap ETH → USDT",
);
});
test("WRAP_ETH is still ETH at 18 decimals", () => {