Compare commits
1
Commits
next
..
6686b4096c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6686b4096c |
@@ -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`.
|
||||
@@ -1212,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
|
||||
@@ -1777,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
|
||||
@@ -1822,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,
|
||||
@@ -1843,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
|
||||
@@ -1857,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**
|
||||
@@ -1891,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 →
|
||||
@@ -1922,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**
|
||||
@@ -1947,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**
|
||||
@@ -1972,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
|
||||
@@ -2374,18 +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 `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
|
||||
@@ -3004,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`
|
||||
@@ -3059,92 +2979,49 @@ Currently supported:
|
||||
`ACROSS_V4_DEPOSIT_V3` pays the bridge, which pays the deposit's recipient on
|
||||
another network and refunds a deposit nobody fills to its depositor; the
|
||||
router passes both on as written, so `address(1)` and `address(2)` there are
|
||||
addresses, and each that is not the transaction's sender is named. 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. The router pays a `SETTLE` of
|
||||
`OPEN_DELTA` (zero) only what its step still owes of that token when the
|
||||
`SETTLE` runs, so one from the router counts as spending the amounts the
|
||||
step's swap actions state they spend of that token only when every action in
|
||||
the step could be read, every swap action names the token it spends and the
|
||||
token it buys (a swap action with an empty path swaps nothing), the `SETTLE`
|
||||
is the step's only `SETTLE` or `SETTLE_ALL` of that token and comes after
|
||||
every swap action, no swap action buys that token, and the swap actions that
|
||||
spend it state a quantity together; otherwise it counts as spending nothing.
|
||||
So the rest of a V4 `SETTLE` of more than its swap spends, taken to the
|
||||
router, the WETH of a `WRAP_ETH`, and a token a swap pays the router on the
|
||||
way to the output, each stays in the router when later steps do not spend all
|
||||
of it, where anyone can take it in a later transaction. The router sends ETH
|
||||
as written, so a `TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and
|
||||
pays out nothing. A step paying a token to the V2 pair that the very next step
|
||||
spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID`
|
||||
(zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes
|
||||
pay the next V2 pair directly, and the router enforces that swap's own
|
||||
minimum. The decoder checks that the address paid is that pair, deriving it
|
||||
from the V2 factory and pair init code hash the network's routers were
|
||||
deployed with and the first two tokens of that step's path; a payment to any
|
||||
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was
|
||||
deployed with placeholders for both values, so no V2 swap step can succeed on
|
||||
that router.) Every other payment counts, in whichever step and of whatever
|
||||
token, a share of the output included: an interface fee paid to another
|
||||
address by `PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the
|
||||
minimum reads `Min. paid out`, because the swap's minimum includes the fee.
|
||||
The minimum reads `Min. received` only when some step pays the transaction's
|
||||
sender (the From address on TxApproval, the sender the explorer reports on
|
||||
TransactionDetail) and no `Paid to` line is shown. `Paid to` lines after
|
||||
`Token Out` name each other address paid; one reads
|
||||
addresses, and each that is not the transaction's sender is named. When a swap
|
||||
step, `TAKE` or `TAKE_PORTION` pays the router the swap's output token, the
|
||||
output stays in the router unless a later step pays all the router holds of it
|
||||
to anyone but the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is
|
||||
WETH, a `TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a
|
||||
`PAY_PORTION_FULL_PRECISION` of 1e18 of that token, or an
|
||||
`ACROSS_V4_DEPOSIT_V3` of `CONTRACT_BALANCE` of that token, or of ETH when it
|
||||
sets `useNative`. The router sends ETH as written, so a `TRANSFER` of
|
||||
`CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
|
||||
token to the V2 pair that the very next step spends, as a V2 exact-in of that
|
||||
token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
|
||||
hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
|
||||
the router enforces that swap's own minimum. The decoder checks that the
|
||||
address paid is that pair, deriving it from the V2 factory and pair init code
|
||||
hash the network's routers were deployed with and the first two tokens of that
|
||||
step's path; a payment to any other address is named. (Sepolia's
|
||||
`UniversalRouterV1_2_V2Support` was deployed with placeholders for both
|
||||
values, so no V2 swap step can succeed on that router.) Every other payment
|
||||
counts, in whichever step and of whatever token, a share of the output
|
||||
included: an interface fee paid to another address by `PAY_PORTION`,
|
||||
`TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
|
||||
because the swap's minimum includes the fee. The minimum reads `Min. received`
|
||||
only when some step pays the transaction's sender (the From address on
|
||||
TxApproval, the sender the explorer reports on TransactionDetail) and no
|
||||
`Paid to` line is shown. `Paid to` lines after `Token Out` name each other
|
||||
address paid; one reads
|
||||
`The bridge, which pays the deposit's recipient on another network` for a
|
||||
bridge deposit; one reads, for example,
|
||||
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
|
||||
for each move into the router, followed by a `Token moved into the router`
|
||||
line that gives the moved token's address as the `Token In` line does; one
|
||||
reads `The router, where it stays (no later step pays it out)` for each token
|
||||
that stays in the router, followed, for any token but the output, by a
|
||||
`Token moved into the router` line giving that token in the same way (ETH by
|
||||
name); and one more reads `Unknown (could not be decoded)` when a step that
|
||||
may pay an address went unread. When no step pays anyone, a single line reads
|
||||
`Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the
|
||||
minimum reads `Min. paid out`. After the `Amount` line (after `Token In` when
|
||||
there is none), 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.
|
||||
reads `The router, where it stays (no later step pays it out)` when the output
|
||||
stays in the router; and one more reads `Unknown (could not be decoded)` when
|
||||
a step that may pay an address went unread. When no step pays anyone, a single
|
||||
line reads `Unknown (not named in the calldata)`. Wherever there is a
|
||||
`Paid to` line, the minimum reads `Min. paid out`. A step goes unread when its
|
||||
recipient does not decode as an address, or when it is a command the decoder
|
||||
does not read: every command other than `PERMIT2_PERMIT`,
|
||||
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
|
||||
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
|
||||
`PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
|
||||
`UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`,
|
||||
`PERMIT2_TRANSFER_FROM_BATCH` and `EXECUTE_SUB_PLAN` among them).
|
||||
|
||||
### Non-Goals Forever
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
@@ -644,11 +640,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 +654,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 +667,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 +703,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 +719,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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 + ".");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
+133
-534
@@ -5,7 +5,6 @@ const {
|
||||
Interface,
|
||||
AbiCoder,
|
||||
getBytes,
|
||||
getAddress,
|
||||
formatUnits,
|
||||
getCreate2Address,
|
||||
keccak256,
|
||||
@@ -25,199 +24,55 @@ const ROUTER_IFACE = new Interface([
|
||||
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
||||
]);
|
||||
|
||||
// The Universal Router releases deployed at the addresses below, each with
|
||||
// three things read from its source at the commit linked for it:
|
||||
//
|
||||
// - `commandTypeMask`, the mask it reads a command's type with: its dispatcher
|
||||
// runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits above the
|
||||
// mask are the allow-revert flag (0x80) or ignored. The mask grew between
|
||||
// releases. Each is `COMMAND_TYPE_MASK` in the release's
|
||||
// `contracts/libraries/Commands.sol`, the same file as in the source
|
||||
// verified for its addresses.
|
||||
// - `commands`, the command it runs for each command type, by the name that
|
||||
// file gives it. Releases give some types different commands. A type the
|
||||
// file names no command for, or only a placeholder, is left out: the router
|
||||
// reverts on it, except UniversalRouterV1_2_V2Support on 0x14, which does
|
||||
// nothing. decode() names and reads each step as its release's command.
|
||||
// - `allowRevert`, the command types it lets fail without reverting the
|
||||
// transaction. Its `execute()`, in `contracts/UniversalRouter.sol`, runs each
|
||||
// step through `dispatch()`, in `contracts/base/Dispatcher.sol`, and reverts
|
||||
// the whole transaction when a step fails, unless the step's command byte
|
||||
// has the allow-revert flag set: then it goes on to the next step. Only for
|
||||
// these command types does `dispatch()` return a failure. Every other
|
||||
// command, among them each V2, V3 and V4 swap and each payment of ETH or an
|
||||
// ERC-20 token, succeeds or reverts the whole transaction itself, so the
|
||||
// flag has no effect on it.
|
||||
//
|
||||
// UniversalRouterV1: https://github.com/Uniswap/universal-router/tree/cda697da221e24ca143bc4f55023694a2a16796f/contracts
|
||||
const ROUTER_V1 = {
|
||||
commandTypeMask: 0x1f,
|
||||
commands: {
|
||||
0x00: "V3_SWAP_EXACT_IN",
|
||||
0x01: "V3_SWAP_EXACT_OUT",
|
||||
0x02: "PERMIT2_TRANSFER_FROM",
|
||||
0x03: "PERMIT2_PERMIT_BATCH",
|
||||
0x04: "SWEEP",
|
||||
0x05: "TRANSFER",
|
||||
0x06: "PAY_PORTION",
|
||||
0x08: "V2_SWAP_EXACT_IN",
|
||||
0x09: "V2_SWAP_EXACT_OUT",
|
||||
0x0a: "PERMIT2_PERMIT",
|
||||
0x0b: "WRAP_ETH",
|
||||
0x0c: "UNWRAP_WETH",
|
||||
0x0d: "PERMIT2_TRANSFER_FROM_BATCH",
|
||||
0x10: "SEAPORT",
|
||||
0x11: "LOOKS_RARE_721",
|
||||
0x12: "NFTX",
|
||||
0x13: "CRYPTOPUNKS",
|
||||
0x14: "LOOKS_RARE_1155",
|
||||
0x15: "OWNER_CHECK_721",
|
||||
0x16: "OWNER_CHECK_1155",
|
||||
0x17: "SWEEP_ERC721",
|
||||
0x18: "X2Y2_721",
|
||||
0x19: "SUDOSWAP",
|
||||
0x1a: "NFT20",
|
||||
0x1b: "X2Y2_1155",
|
||||
0x1c: "FOUNDATION",
|
||||
0x1d: "SWEEP_ERC1155",
|
||||
},
|
||||
// Its NFT market calls (0x10 to 0x14, 0x18 to 0x1c) and NFT owner checks
|
||||
// (0x15, 0x16).
|
||||
allowRevert: [
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
|
||||
],
|
||||
};
|
||||
// UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/tree/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts
|
||||
const ROUTER_V1_2 = {
|
||||
commandTypeMask: 0x3f,
|
||||
commands: {
|
||||
0x00: "V3_SWAP_EXACT_IN",
|
||||
0x01: "V3_SWAP_EXACT_OUT",
|
||||
0x02: "PERMIT2_TRANSFER_FROM",
|
||||
0x03: "PERMIT2_PERMIT_BATCH",
|
||||
0x04: "SWEEP",
|
||||
0x05: "TRANSFER",
|
||||
0x06: "PAY_PORTION",
|
||||
0x08: "V2_SWAP_EXACT_IN",
|
||||
0x09: "V2_SWAP_EXACT_OUT",
|
||||
0x0a: "PERMIT2_PERMIT",
|
||||
0x0b: "WRAP_ETH",
|
||||
0x0c: "UNWRAP_WETH",
|
||||
0x0d: "PERMIT2_TRANSFER_FROM_BATCH",
|
||||
0x0e: "BALANCE_CHECK_ERC20",
|
||||
0x10: "SEAPORT_V1_5",
|
||||
0x11: "LOOKS_RARE_V2",
|
||||
0x12: "NFTX",
|
||||
0x13: "CRYPTOPUNKS",
|
||||
0x15: "OWNER_CHECK_721",
|
||||
0x16: "OWNER_CHECK_1155",
|
||||
0x17: "SWEEP_ERC721",
|
||||
0x18: "X2Y2_721",
|
||||
0x19: "SUDOSWAP",
|
||||
0x1a: "NFT20",
|
||||
0x1b: "X2Y2_1155",
|
||||
0x1c: "FOUNDATION",
|
||||
0x1d: "SWEEP_ERC1155",
|
||||
0x1e: "ELEMENT_MARKET",
|
||||
0x20: "SEAPORT_V1_4",
|
||||
0x21: "EXECUTE_SUB_PLAN",
|
||||
0x22: "APPROVE_ERC20",
|
||||
},
|
||||
// BALANCE_CHECK_ERC20 (0x0e), its NFT market calls (0x10 to 0x13, 0x18 to
|
||||
// 0x1c, 0x1e, 0x20), its NFT owner checks (0x15, 0x16) and
|
||||
// EXECUTE_SUB_PLAN (0x21).
|
||||
allowRevert: [
|
||||
0x0e, 0x10, 0x11, 0x12, 0x13, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
|
||||
0x1e, 0x20, 0x21,
|
||||
],
|
||||
};
|
||||
// UniversalRouterV2: https://github.com/Uniswap/universal-router/tree/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts
|
||||
const ROUTER_V2 = {
|
||||
commandTypeMask: 0x3f,
|
||||
commands: {
|
||||
0x00: "V3_SWAP_EXACT_IN",
|
||||
0x01: "V3_SWAP_EXACT_OUT",
|
||||
0x02: "PERMIT2_TRANSFER_FROM",
|
||||
0x03: "PERMIT2_PERMIT_BATCH",
|
||||
0x04: "SWEEP",
|
||||
0x05: "TRANSFER",
|
||||
0x06: "PAY_PORTION",
|
||||
0x08: "V2_SWAP_EXACT_IN",
|
||||
0x09: "V2_SWAP_EXACT_OUT",
|
||||
0x0a: "PERMIT2_PERMIT",
|
||||
0x0b: "WRAP_ETH",
|
||||
0x0c: "UNWRAP_WETH",
|
||||
0x0d: "PERMIT2_TRANSFER_FROM_BATCH",
|
||||
0x0e: "BALANCE_CHECK_ERC20",
|
||||
0x10: "V4_SWAP",
|
||||
0x11: "V3_POSITION_MANAGER_PERMIT",
|
||||
0x12: "V3_POSITION_MANAGER_CALL",
|
||||
0x13: "V4_INITIALIZE_POOL",
|
||||
0x14: "V4_POSITION_MANAGER_CALL",
|
||||
0x21: "EXECUTE_SUB_PLAN",
|
||||
},
|
||||
// PERMIT2_PERMIT_BATCH (0x03), PERMIT2_PERMIT (0x0a), BALANCE_CHECK_ERC20
|
||||
// (0x0e), V3_POSITION_MANAGER_PERMIT, V3_POSITION_MANAGER_CALL,
|
||||
// V4_INITIALIZE_POOL and V4_POSITION_MANAGER_CALL (0x11 to 0x14), and
|
||||
// EXECUTE_SUB_PLAN (0x21).
|
||||
allowRevert: [0x03, 0x0a, 0x0e, 0x11, 0x12, 0x13, 0x14, 0x21],
|
||||
};
|
||||
// UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/tree/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts
|
||||
// UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/tree/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts
|
||||
// The three files are the same in both releases.
|
||||
const ROUTER_V2_1 = {
|
||||
commandTypeMask: 0x7f,
|
||||
// UniversalRouterV2's, and two more.
|
||||
commands: {
|
||||
...ROUTER_V2.commands,
|
||||
0x07: "PAY_PORTION_FULL_PRECISION",
|
||||
0x40: "ACROSS_V4_DEPOSIT_V3",
|
||||
},
|
||||
// The same as UniversalRouterV2's.
|
||||
allowRevert: ROUTER_V2.allowRevert,
|
||||
};
|
||||
|
||||
// The Universal Router deployments on each network, by chain id, copied from
|
||||
// the deployment files Uniswap publishes, each address named by its key there
|
||||
// and given with the release it runs. The files also list
|
||||
// `UnsupportedProtocol`, which is not a router.
|
||||
// the deployment files Uniswap publishes, each address named by its key there.
|
||||
// The files also list `UnsupportedProtocol`, which is not a router.
|
||||
//
|
||||
// Beside each address is the mask that router reads a command's type with:
|
||||
// its dispatcher runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits
|
||||
// above the mask are the allow-revert flag (0x80) or ignored. The mask grew
|
||||
// between releases. Each is `COMMAND_TYPE_MASK` in the release's
|
||||
// `contracts/libraries/Commands.sol`, the same file as in the source verified
|
||||
// for that address:
|
||||
// - 0x1f, UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Commands.sol
|
||||
// - 0x3f, UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/libraries/Commands.sol
|
||||
// - 0x3f, UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/libraries/Commands.sol
|
||||
// - 0x7f, UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/libraries/Commands.sol
|
||||
// - 0x7f, UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/libraries/Commands.sol
|
||||
const ROUTER_ADDRESSES = new Map([
|
||||
[
|
||||
NETWORKS.mainnet.chainId,
|
||||
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json
|
||||
[
|
||||
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", ROUTER_V1], // UniversalRouterV1
|
||||
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support
|
||||
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", ROUTER_V2], // UniversalRouterV2
|
||||
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", ROUTER_V2_1], // UniversalRouterV2_1_1
|
||||
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", ROUTER_V2_1], // UniversalRouterV2_1_2
|
||||
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1
|
||||
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
|
||||
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2
|
||||
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1
|
||||
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2
|
||||
],
|
||||
],
|
||||
[
|
||||
NETWORKS.sepolia.chainId,
|
||||
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json
|
||||
[
|
||||
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support
|
||||
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", ROUTER_V2], // UniversalRouterV2
|
||||
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", ROUTER_V2_1], // UniversalRouterV2_1_1
|
||||
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", ROUTER_V2_1], // UniversalRouterV2_1_2
|
||||
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
|
||||
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2
|
||||
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1
|
||||
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// The release that the Universal Router deployment at `address` on the
|
||||
// network with this chain id runs, or null when `address` is not one of them.
|
||||
// Address case is ignored.
|
||||
function routerRelease(address, chainId) {
|
||||
// The mask the Universal Router deployment at `address` on the network with
|
||||
// this chain id reads a command's type with, or null when `address` is not one
|
||||
// of them. Address case is ignored.
|
||||
function routerCommandTypeMask(address, chainId) {
|
||||
const routers = ROUTER_ADDRESSES.get(chainId) || [];
|
||||
const wanted = (address || "").toLowerCase();
|
||||
const router = routers.find(([a]) => a.toLowerCase() === wanted);
|
||||
return router ? router[1] : null;
|
||||
}
|
||||
|
||||
// The allow-revert flag on a command byte (see ROUTER_V1).
|
||||
const ALLOW_REVERT_FLAG = 0x80;
|
||||
|
||||
// The Uniswap V2 factory and pair init code hash the Universal Routers on each
|
||||
// network were deployed with. A V2 swap step swaps through the pair its path
|
||||
// starts at, which the router finds as `UniswapV2Library.pairFor()` does: the
|
||||
@@ -267,52 +122,34 @@ function v2PairFor(chainId, tokenA, tokenB) {
|
||||
);
|
||||
}
|
||||
|
||||
// The Steps line's name for each command in the releases' `commands` (see
|
||||
// ROUTER_V1).
|
||||
// Universal Router command types, each read from its command byte with the
|
||||
// router's mask (see ROUTER_ADDRESSES). Only the routers with the 0x7f mask
|
||||
// have 0x07 and 0x40: 0x40 is above the other masks, and the other routers
|
||||
// revert on 0x07, so decode() names it there as it names a command not
|
||||
// listed here.
|
||||
const COMMAND_NAMES = {
|
||||
V3_SWAP_EXACT_IN: "V3 Swap (Exact In)",
|
||||
V3_SWAP_EXACT_OUT: "V3 Swap (Exact Out)",
|
||||
PERMIT2_TRANSFER_FROM: "Permit2 Transfer",
|
||||
PERMIT2_PERMIT_BATCH: "Permit2 Permit Batch",
|
||||
SWEEP: "Sweep",
|
||||
TRANSFER: "Transfer",
|
||||
PAY_PORTION: "Pay Portion",
|
||||
PAY_PORTION_FULL_PRECISION: "Pay Portion (Full Precision)",
|
||||
V2_SWAP_EXACT_IN: "V2 Swap (Exact In)",
|
||||
V2_SWAP_EXACT_OUT: "V2 Swap (Exact Out)",
|
||||
PERMIT2_PERMIT: "Permit2 Permit",
|
||||
WRAP_ETH: "Wrap ETH",
|
||||
UNWRAP_WETH: "Unwrap WETH",
|
||||
PERMIT2_TRANSFER_FROM_BATCH: "Permit2 Transfer Batch",
|
||||
BALANCE_CHECK_ERC20: "Balance Check",
|
||||
V4_SWAP: "V4 Swap",
|
||||
V3_POSITION_MANAGER_PERMIT: "V3 Position Mgr Permit",
|
||||
V3_POSITION_MANAGER_CALL: "V3 Position Mgr Call",
|
||||
V4_INITIALIZE_POOL: "V4 Initialize Pool",
|
||||
V4_POSITION_MANAGER_CALL: "V4 Position Mgr Call",
|
||||
EXECUTE_SUB_PLAN: "Execute Sub-Plan",
|
||||
ACROSS_V4_DEPOSIT_V3: "Across Bridge Deposit",
|
||||
// The NFT commands of UniversalRouterV1 and UniversalRouterV1_2_V2Support,
|
||||
// and the latter's APPROVE_ERC20.
|
||||
SEAPORT: "Seaport",
|
||||
SEAPORT_V1_4: "Seaport V1.4",
|
||||
SEAPORT_V1_5: "Seaport V1.5",
|
||||
LOOKS_RARE_721: "LooksRare 721",
|
||||
LOOKS_RARE_1155: "LooksRare 1155",
|
||||
LOOKS_RARE_V2: "LooksRare V2",
|
||||
NFTX: "NFTX",
|
||||
CRYPTOPUNKS: "CryptoPunks",
|
||||
OWNER_CHECK_721: "Owner Check 721",
|
||||
OWNER_CHECK_1155: "Owner Check 1155",
|
||||
SWEEP_ERC721: "Sweep ERC-721",
|
||||
SWEEP_ERC1155: "Sweep ERC-1155",
|
||||
X2Y2_721: "X2Y2 721",
|
||||
X2Y2_1155: "X2Y2 1155",
|
||||
SUDOSWAP: "Sudoswap",
|
||||
NFT20: "NFT20",
|
||||
FOUNDATION: "Foundation",
|
||||
ELEMENT_MARKET: "Element Market",
|
||||
APPROVE_ERC20: "Approve ERC-20",
|
||||
0x00: "V3 Swap (Exact In)",
|
||||
0x01: "V3 Swap (Exact Out)",
|
||||
0x02: "Permit2 Transfer",
|
||||
0x03: "Permit2 Permit Batch",
|
||||
0x04: "Sweep",
|
||||
0x05: "Transfer",
|
||||
0x06: "Pay Portion",
|
||||
0x07: "Pay Portion (Full Precision)",
|
||||
0x08: "V2 Swap (Exact In)",
|
||||
0x09: "V2 Swap (Exact Out)",
|
||||
0x0a: "Permit2 Permit",
|
||||
0x0b: "Wrap ETH",
|
||||
0x0c: "Unwrap WETH",
|
||||
0x0d: "Permit2 Transfer Batch",
|
||||
0x0e: "Balance Check",
|
||||
0x10: "V4 Swap",
|
||||
0x11: "V3 Position Mgr Permit",
|
||||
0x12: "V3 Position Mgr Call",
|
||||
0x13: "V4 Initialize Pool",
|
||||
0x14: "V4 Position Mgr Call",
|
||||
0x21: "Execute Sub-Plan",
|
||||
0x40: "Across Bridge Deposit",
|
||||
};
|
||||
|
||||
// The swap's Amount and Min. received lines land on the same approval screen,
|
||||
@@ -338,8 +175,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
|
||||
// address this decoder did not read.
|
||||
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
|
||||
|
||||
// The Paid to line for each token paid to the router, the swap's output or
|
||||
// another, that no later step pays out of the router.
|
||||
// The Paid to line when the swap's output is paid to the router and no later
|
||||
// step pays it out of the router.
|
||||
const STAYS_IN_ROUTER =
|
||||
"The router, where it stays (no later step pays it out)";
|
||||
|
||||
@@ -414,16 +251,12 @@ const NO_MINIMUM = "None (no minimum guaranteed)";
|
||||
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
||||
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
|
||||
|
||||
// WETH, the token WRAP_ETH turns ETH into and UNWRAP_WETH turns into ETH, on
|
||||
// each network, by chain id: `weth9` in the deployment parameters
|
||||
// V2_FACTORIES cites, which every router in ROUTER_ADDRESSES was deployed
|
||||
// with (their constructor arguments on the explorer).
|
||||
const WETH = new Map([
|
||||
[NETWORKS.mainnet.chainId, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"],
|
||||
[NETWORKS.sepolia.chainId, "0xfff9976782d46cc05630d1f6ebab18b2324d6b14"],
|
||||
]);
|
||||
// Either one counts on either network as the WETH UNWRAP_WETH turns into ETH.
|
||||
const WETH_ADDRESSES = [...WETH.values()];
|
||||
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
|
||||
// Either one counts on either network.
|
||||
const WETH_ADDRESSES = [
|
||||
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
|
||||
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
|
||||
];
|
||||
|
||||
// Two recipient values the router reads as placeholders, not as addresses:
|
||||
// MSG_SENDER pays the caller of execute(), which is the transaction's sender,
|
||||
@@ -488,21 +321,6 @@ function amountText(raw, info) {
|
||||
};
|
||||
}
|
||||
|
||||
// The line giving a moved token under `label`, in the shape of the Token In
|
||||
// line, so the screens show it as they show that one. ETH has no address.
|
||||
function tokenMovedLine(label, token, sources) {
|
||||
const info = tokenInfo(token, sources);
|
||||
if (!present(info.address)) {
|
||||
return { label, value: info.symbol };
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value: info.symbol ? info.symbol + " (" + token + ")" : token,
|
||||
address: token,
|
||||
isToken: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Decode PERMIT2_PERMIT (command 0x0a) input bytes.
|
||||
// ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce),
|
||||
// address spender, uint256 sigDeadline), bytes signature
|
||||
@@ -521,13 +339,7 @@ function decodePermit2(input) {
|
||||
}
|
||||
}
|
||||
|
||||
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. The router passes the
|
||||
// check when `owner` holds at least `minBalance` of `token`, and reads `owner`
|
||||
// as written, without map(), on every release that has the command:
|
||||
// - UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/base/Dispatcher.sol
|
||||
// - UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol
|
||||
// - UniversalRouterV2_1_1 and UniversalRouterV2_1_2, the same file at both
|
||||
// commits: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/base/Dispatcher.sol
|
||||
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes.
|
||||
// ABI: (address owner, address token, uint256 minBalance)
|
||||
function decodeBalanceCheck(input) {
|
||||
try {
|
||||
@@ -542,7 +354,6 @@ function decodeBalanceCheck(input) {
|
||||
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
||||
// address[] path, bool payerIsUser)
|
||||
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
|
||||
// With `payerIsUser` false the router pays the input from its own balance.
|
||||
function decodeV2SwapExactIn(input) {
|
||||
try {
|
||||
const d = coder.decode(
|
||||
@@ -556,7 +367,6 @@ function decodeV2SwapExactIn(input) {
|
||||
path: d[3],
|
||||
tokenIn: d[3][0],
|
||||
tokenOut: d[3][d[3].length - 1],
|
||||
payerIsUser: d[4],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -596,7 +406,6 @@ function decodeV3Path(pathHex) {
|
||||
// Decode V3_SWAP_EXACT_IN (command 0x00) input bytes.
|
||||
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
||||
// bytes path, bool payerIsUser)
|
||||
// With `payerIsUser` false the router pays the input from its own balance.
|
||||
function decodeV3SwapExactIn(input) {
|
||||
try {
|
||||
const d = coder.decode(
|
||||
@@ -611,7 +420,6 @@ function decodeV3SwapExactIn(input) {
|
||||
amountOutMin: d[2],
|
||||
tokenIn: path.tokenIn,
|
||||
tokenOut: path.tokenOut,
|
||||
payerIsUser: d[4],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -901,10 +709,7 @@ function totalAmount(amounts) {
|
||||
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
|
||||
// a share of the currency, so it does not name the output side as TAKE does.
|
||||
// `recipientUnread` is set when the step has a payout this decoder did not
|
||||
// read. `settledFromRouter` is every SETTLE that the router pays from its own
|
||||
// balance (`payerIsUser` false), as its currency and the amount it pays: a
|
||||
// quantity, CONTRACT_BALANCE (all the router holds), or null when this
|
||||
// decoder cannot tell (see below).
|
||||
// read.
|
||||
function decodeV4Swap(input) {
|
||||
try {
|
||||
const d = coder.decode(["bytes", "bytes[]"], input);
|
||||
@@ -914,15 +719,6 @@ function decodeV4Swap(input) {
|
||||
let settleToken = null;
|
||||
let takeToken = null;
|
||||
const takes = [];
|
||||
// Every SETTLE paid from the router, as its currency, its amount as
|
||||
// written and its index among the step's actions.
|
||||
const routerSettles = [];
|
||||
// The currency of every SETTLE and SETTLE_ALL.
|
||||
const settledCurrencies = [];
|
||||
// The index of the step's last swap action, read or not, or -1.
|
||||
let lastSwapAction = -1;
|
||||
// Set when an action of the step could not be read.
|
||||
let actionUnread = false;
|
||||
let recipientUnread = false;
|
||||
// The step's swap actions that could be read, as decodeV4SwapAction()
|
||||
// reads them.
|
||||
@@ -942,19 +738,10 @@ function decodeV4Swap(input) {
|
||||
params[i],
|
||||
);
|
||||
settleToken = s[0];
|
||||
settledCurrencies.push(s[0]);
|
||||
if (!s[2]) {
|
||||
routerSettles.push({
|
||||
currency: s[0],
|
||||
amount: s[1],
|
||||
index: i,
|
||||
});
|
||||
}
|
||||
} else if (actionId === V4_SETTLE_ALL) {
|
||||
// SETTLE_ALL: (address currency, uint256 maxAmount)
|
||||
const s = coder.decode(["address", "uint256"], params[i]);
|
||||
settleToken = s[0];
|
||||
settledCurrencies.push(s[0]);
|
||||
} else if (actionId === V4_TAKE) {
|
||||
// TAKE: (address currency, address recipient, uint256 amount)
|
||||
const t = coder.decode(
|
||||
@@ -981,23 +768,18 @@ function decodeV4Swap(input) {
|
||||
actionId === V4_SWAP_EXACT_OUT_SINGLE ||
|
||||
actionId === V4_SWAP_EXACT_OUT
|
||||
) {
|
||||
lastSwapAction = i;
|
||||
try {
|
||||
swaps.push(decodeV4SwapAction(actionId, params[i]));
|
||||
} catch {
|
||||
swapUndecoded = true;
|
||||
actionUnread = true;
|
||||
}
|
||||
} else {
|
||||
// V4Router reverts on any action not named above, so
|
||||
// none of them pays anyone. This decoder reads none.
|
||||
actionUnread = true;
|
||||
}
|
||||
// V4Router reverts on any action not named above, so none
|
||||
// of them pays anyone.
|
||||
} catch {
|
||||
// Skip sub-actions we can't decode. A TAKE, TAKE_ALL or
|
||||
// TAKE_PORTION among them is a payout this decoder did not
|
||||
// read.
|
||||
actionUnread = true;
|
||||
if (
|
||||
actionId === V4_TAKE ||
|
||||
actionId === V4_TAKE_ALL ||
|
||||
@@ -1008,41 +790,6 @@ function decodeV4Swap(input) {
|
||||
}
|
||||
}
|
||||
|
||||
// What each SETTLE paid from the router pays. A quantity or
|
||||
// CONTRACT_BALANCE pays as written. A SETTLE of OPEN_DELTA (zero)
|
||||
// pays whatever the step still owes of its currency when it runs.
|
||||
// That is what the step's swap actions state they spend of it only
|
||||
// in the plain shape of Uniswap's own routes, so only then is it
|
||||
// given that amount: every action in the step was read; every swap
|
||||
// action names the currency it spends and the one it buys (an
|
||||
// exact-in action with an empty path swaps nothing); the SETTLE is
|
||||
// the step's only SETTLE or SETTLE_ALL of its currency and comes
|
||||
// after every swap action; no swap action buys that currency; and
|
||||
// the swap actions that spend it state a quantity together (see
|
||||
// totalAmount()). Otherwise it is given null.
|
||||
const settledFromRouter = routerSettles.map((s) => {
|
||||
if (s.amount !== 0n) {
|
||||
return { currency: s.currency, amount: s.amount };
|
||||
}
|
||||
const stated = totalAmount(
|
||||
swaps
|
||||
.filter((w) => w.tokenIn === s.currency)
|
||||
.map((w) => w.amountIn),
|
||||
);
|
||||
const paysWhatSwapsSpend =
|
||||
!actionUnread &&
|
||||
swaps.every((w) => present(w.tokenIn) && present(w.tokenOut)) &&
|
||||
settledCurrencies.filter((c) => c === s.currency).length ===
|
||||
1 &&
|
||||
s.index > lastSwapAction &&
|
||||
!swaps.some((w) => w.tokenOut === s.currency) &&
|
||||
typeof stated === "bigint";
|
||||
return {
|
||||
currency: s.currency,
|
||||
amount: paysWhatSwapsSpend ? stated : null,
|
||||
};
|
||||
});
|
||||
|
||||
// Rule 3.
|
||||
if (swapUndecoded) {
|
||||
return {
|
||||
@@ -1051,7 +798,6 @@ function decodeV4Swap(input) {
|
||||
amountIn: null,
|
||||
amountOutMin: null,
|
||||
takes,
|
||||
settledFromRouter,
|
||||
recipientUnread,
|
||||
swapUndecoded,
|
||||
hasSwapAction: true,
|
||||
@@ -1093,7 +839,6 @@ function decodeV4Swap(input) {
|
||||
amountIn,
|
||||
amountOutMin,
|
||||
takes,
|
||||
settledFromRouter,
|
||||
recipientUnread,
|
||||
swapUndecoded,
|
||||
hasSwapAction: swaps.length > 0,
|
||||
@@ -1111,9 +856,8 @@ function decodeV4Swap(input) {
|
||||
// sent to any other contract is not a swap through the router, whatever that
|
||||
// contract does with it.
|
||||
function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
const release = routerRelease(toAddress, chainId);
|
||||
if (release === null) return null;
|
||||
const { commandTypeMask, commands, allowRevert } = release;
|
||||
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
|
||||
if (commandTypeMask === null) return null;
|
||||
try {
|
||||
const parsed = ROUTER_IFACE.parseTransaction({ data });
|
||||
if (!parsed) return null;
|
||||
@@ -1186,51 +930,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
// router, as { token, amount }. Later steps may leave part of it
|
||||
// there: nothing here checks that they spend all of it.
|
||||
const movedIntoRouter = [];
|
||||
// What each PERMIT2_TRANSFER_FROM not into the router moves from the
|
||||
// sender, in step order, whoever counts as paid, as
|
||||
// { token, recipient, amount, to }: `to` says where it goes, the
|
||||
// sender or the V2 pair the next swap spends, and is null when it
|
||||
// goes to `recipient` as written. A move into the router is in
|
||||
// `movedIntoRouter`, whose lines give its amount already.
|
||||
const movedFromSender = [];
|
||||
// Each token a step paid to the router that no later step paid out
|
||||
// or spent in full, by its address, lowercased, ETH as the zero
|
||||
// address. The router is paid what a swap step, V4 TAKE or
|
||||
// TAKE_PORTION pays it, the WETH of a WRAP_ETH into it, and the ETH
|
||||
// of an UNWRAP_WETH into it, in place of the WETH it held. All the
|
||||
// router holds of a token is paid out or spent by a SWEEP of that
|
||||
// token, an UNWRAP_WETH of WETH, a WRAP_ETH of CONTRACT_BALANCE of
|
||||
// ETH, a TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION or
|
||||
// ACROSS_V4_DEPOSIT_V3 of all of it, or a swap step or V4 SETTLE paid
|
||||
// from the router (see spendFromRouter()). The value is the amount the
|
||||
// router holds, in base units, when a WRAP_ETH stated it, otherwise
|
||||
// null.
|
||||
const heldByRouter = new Map();
|
||||
// The tokens, lowercased, that a swap step or a V4 TAKE or
|
||||
// TAKE_PORTION paid to the router and no later step paid out of it in
|
||||
// full: a SWEEP of that token, an UNWRAP_WETH of WETH, or a TRANSFER,
|
||||
// PAY_PORTION, PAY_PORTION_FULL_PRECISION or ACROSS_V4_DEPOSIT_V3 of
|
||||
// all the router holds of that token.
|
||||
const heldByRouter = new Set();
|
||||
const isRouter = (recipient) => {
|
||||
const r = recipient.toLowerCase();
|
||||
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
|
||||
};
|
||||
// A swap step or V4 SETTLE pays `amount` of `token` from the router's
|
||||
// own balance. CONTRACT_BALANCE pays all of it. Any other quantity
|
||||
// counts against the amount a WRAP_ETH stated, and the steps pay all
|
||||
// of it once their amounts reach that one: a swap paid in ETH wraps
|
||||
// what its swap steps then spend. An amount that is not a quantity,
|
||||
// such as the null decodeV4Swap() gives a SETTLE whose payment it
|
||||
// cannot tell, or one of a token whose amount is not known, pays out
|
||||
// nothing this decoder can see.
|
||||
const spendFromRouter = (token, amount) => {
|
||||
const t = token.toLowerCase();
|
||||
const held = heldByRouter.get(t);
|
||||
if (amount === CONTRACT_BALANCE) {
|
||||
heldByRouter.delete(t);
|
||||
} else if (typeof held === "bigint" && typeof amount === "bigint") {
|
||||
if (amount >= held) {
|
||||
heldByRouter.delete(t);
|
||||
} else {
|
||||
heldByRouter.set(t, held - amount);
|
||||
}
|
||||
}
|
||||
};
|
||||
// A step pays `recipient`, an address other than the sender's or the
|
||||
// router's, as written.
|
||||
const addPaidElsewhere = (recipient) => {
|
||||
@@ -1244,9 +953,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
const addPayee = (recipient, output) => {
|
||||
const r = recipient.toLowerCase();
|
||||
if (isRouter(recipient)) {
|
||||
if (present(output)) {
|
||||
heldByRouter.set(output.toLowerCase(), null);
|
||||
}
|
||||
if (present(output)) heldByRouter.add(output.toLowerCase());
|
||||
} else if (r === MSG_SENDER || r === sender) {
|
||||
paidToSender = true;
|
||||
} else {
|
||||
@@ -1263,9 +970,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
// router adds only what it holds.
|
||||
const isHop = (i, token, recipient) => {
|
||||
if (i + 1 >= commandsBytes.length) return false;
|
||||
const nextCommand =
|
||||
commands[commandsBytes[i + 1] & commandTypeMask];
|
||||
if (nextCommand !== "V2_SWAP_EXACT_IN") return false;
|
||||
if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false;
|
||||
const next = decodeV2SwapExactIn(inputs[i + 1]);
|
||||
return (
|
||||
next !== null &&
|
||||
@@ -1281,22 +986,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
|
||||
for (let i = 0; i < commandsBytes.length; i++) {
|
||||
const cmdId = commandsBytes[i] & commandTypeMask;
|
||||
// The command this release runs for the step (see ROUTER_V1),
|
||||
// undefined for a type it names no command for.
|
||||
const command = commands[cmdId];
|
||||
// The router goes on to the next step if this one fails (see
|
||||
// ROUTER_V1). The flag on any other command has no effect.
|
||||
const allowedToFail =
|
||||
(commandsBytes[i] & ALLOW_REVERT_FLAG) !== 0 &&
|
||||
allowRevert.includes(cmdId);
|
||||
// Only the routers with the 0x7f mask have command 0x07 (see
|
||||
// COMMAND_NAMES).
|
||||
const name =
|
||||
cmdId === 0x07 && commandTypeMask !== 0x7f
|
||||
? null
|
||||
: COMMAND_NAMES[cmdId];
|
||||
commandNames.push(
|
||||
present(command)
|
||||
? COMMAND_NAMES[command]
|
||||
: "Command 0x" + cmdId.toString(16).padStart(2, "0"),
|
||||
name || "Command 0x" + cmdId.toString(16).padStart(2, "0"),
|
||||
);
|
||||
|
||||
try {
|
||||
if (command === "EXECUTE_SUB_PLAN") {
|
||||
if (cmdId === 0x21) {
|
||||
// EXECUTE_SUB_PLAN runs a further list of commands held in
|
||||
// its input, which this decoder does not read. The step
|
||||
// says so, and after this loop neither side is named.
|
||||
@@ -1304,13 +1005,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
hasSubPlan = true;
|
||||
commandNames[i] += " (its steps are not shown)";
|
||||
payeeUnread = true;
|
||||
} else if (command === "PERMIT2_PERMIT") {
|
||||
} else if (cmdId === 0x0a) {
|
||||
const p = decodePermit2(inputs[i]);
|
||||
// A permit states both halves itself, so it may replace an
|
||||
// input side an earlier hop established without breaking
|
||||
// the invariant.
|
||||
if (p) setInput(p.token, p.amount);
|
||||
} else if (command === "PERMIT2_TRANSFER_FROM") {
|
||||
} else if (cmdId === 0x02) {
|
||||
const p = decodePermit2TransferFrom(inputs[i]);
|
||||
if (!p) {
|
||||
payeeUnread = true;
|
||||
@@ -1324,55 +1025,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
// pays address(1) and address(2) as written (see
|
||||
// decodePermit2TransferFrom()).
|
||||
addPaidElsewhere(p.recipient);
|
||||
movedFromSender.push({ ...p, to: null });
|
||||
} else if (isRouter(p.recipient)) {
|
||||
movedIntoRouter.push(p);
|
||||
} else if (isHop(i, p.token, p.recipient)) {
|
||||
movedFromSender.push({
|
||||
...p,
|
||||
to: "The V2 pair the next swap spends",
|
||||
});
|
||||
} else {
|
||||
} else if (!isHop(i, p.token, p.recipient)) {
|
||||
addPayee(p.recipient);
|
||||
movedFromSender.push({
|
||||
...p,
|
||||
to:
|
||||
p.recipient.toLowerCase() === MSG_SENDER
|
||||
? "The sender"
|
||||
: null,
|
||||
});
|
||||
}
|
||||
} else if (command === "BALANCE_CHECK_ERC20") {
|
||||
} else if (cmdId === 0x0e) {
|
||||
const b = decodeBalanceCheck(inputs[i]);
|
||||
if (!b) {
|
||||
// The router still runs a check whose input does not
|
||||
// decode, such as an owner word with a nonzero top
|
||||
// byte, of which it reads the last 20 bytes, so the
|
||||
// owner may be any address. It sets no side, and the
|
||||
// step says so.
|
||||
commandNames[i] += " (could not be decoded)";
|
||||
} else if (b.owner.toLowerCase() !== sender) {
|
||||
// The router reads the owner as written (see
|
||||
// decodeBalanceCheck()), so address(1) and address(2)
|
||||
// are not the sender and the router here. A check of
|
||||
// any address but the sender's says nothing about
|
||||
// what the user receives: it sets no side, and the
|
||||
// step says so.
|
||||
commandNames[i] += " (of another address)";
|
||||
} else {
|
||||
// The router passes this check whenever the owner
|
||||
// holds at least minBalance, so a zero one guarantees
|
||||
// nothing and does not replace a minimum an earlier
|
||||
// step stated. A check the router lets fail guarantees
|
||||
// nothing either, so it is read as a zero one. Any
|
||||
// other minBalance sets the output side as a swap
|
||||
// does. It pays nobody.
|
||||
const minBalance = allowedToFail ? 0n : b.minBalance;
|
||||
if (!(minBalance === 0n && present(minOutput))) {
|
||||
setOutput(b.token, minBalance);
|
||||
}
|
||||
// The router passes this check whenever the owner holds at
|
||||
// least minBalance, so a zero one guarantees nothing and
|
||||
// does not replace a minimum an earlier step stated. Any
|
||||
// other minBalance sets the output side as a swap does.
|
||||
// It pays nobody.
|
||||
if (b && !(b.minBalance === 0n && present(minOutput))) {
|
||||
setOutput(b.token, b.minBalance);
|
||||
}
|
||||
} else if (command === "V3_SWAP_EXACT_IN") {
|
||||
} else if (cmdId === 0x00) {
|
||||
const s = decodeV3SwapExactIn(inputs[i]);
|
||||
if (s) {
|
||||
setInputOnce(s.tokenIn, s.amountIn);
|
||||
@@ -1380,30 +1048,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
// the last swap step determines the final output token
|
||||
// and minimum received amount.
|
||||
setOutput(s.tokenOut, s.amountOutMin);
|
||||
if (!s.payerIsUser) {
|
||||
spendFromRouter(s.tokenIn, s.amountIn);
|
||||
}
|
||||
if (!isHop(i, s.tokenOut, s.recipient)) {
|
||||
addPayee(s.recipient, s.tokenOut);
|
||||
}
|
||||
} else {
|
||||
payeeUnread = true;
|
||||
}
|
||||
} else if (command === "V2_SWAP_EXACT_IN") {
|
||||
} else if (cmdId === 0x08) {
|
||||
const s = decodeV2SwapExactIn(inputs[i]);
|
||||
if (s) {
|
||||
setInputOnce(s.tokenIn, s.amountIn);
|
||||
setOutput(s.tokenOut, s.amountOutMin);
|
||||
if (!s.payerIsUser) {
|
||||
spendFromRouter(s.tokenIn, s.amountIn);
|
||||
}
|
||||
if (!isHop(i, s.tokenOut, s.recipient)) {
|
||||
addPayee(s.recipient, s.tokenOut);
|
||||
}
|
||||
} else {
|
||||
payeeUnread = true;
|
||||
}
|
||||
} else if (command === "V2_SWAP_EXACT_OUT") {
|
||||
} else if (cmdId === 0x09) {
|
||||
// Buys exactly amountOut and spends at most amountInMax.
|
||||
hasV2ExactOut = true;
|
||||
const s = decodeV2SwapExactOut(inputs[i]);
|
||||
@@ -1416,7 +1078,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
} else {
|
||||
payeeUnread = true;
|
||||
}
|
||||
} else if (command === "SWEEP") {
|
||||
} else if (cmdId === 0x04) {
|
||||
const s = decodeSweep(inputs[i]);
|
||||
if (s) {
|
||||
addPayee(s.recipient);
|
||||
@@ -1427,9 +1089,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
payeeUnread = true;
|
||||
}
|
||||
} else if (
|
||||
command === "TRANSFER" ||
|
||||
command === "PAY_PORTION" ||
|
||||
command === "PAY_PORTION_FULL_PRECISION"
|
||||
cmdId === 0x05 ||
|
||||
cmdId === 0x06 ||
|
||||
(cmdId === 0x07 && commandTypeMask === 0x7f)
|
||||
) {
|
||||
const t = decodeTransfer(inputs[i]);
|
||||
if (t) {
|
||||
@@ -1440,16 +1102,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
// PAY_PORTION_FULL_PRECISION pays all the router
|
||||
// holds of the token, as SWEEP does.
|
||||
const all =
|
||||
command === "TRANSFER"
|
||||
cmdId === 0x05
|
||||
? CONTRACT_BALANCE
|
||||
: command === "PAY_PORTION"
|
||||
: cmdId === 0x06
|
||||
? 10000n
|
||||
: 10n ** 18n;
|
||||
// A TRANSFER of ETH has no such amount: the router
|
||||
// sends ETH as written, so a TRANSFER of
|
||||
// CONTRACT_BALANCE of ETH always reverts.
|
||||
const ethTransfer =
|
||||
command === "TRANSFER" &&
|
||||
cmdId === 0x05 &&
|
||||
t.token ===
|
||||
"0x0000000000000000000000000000000000000000";
|
||||
if (
|
||||
@@ -1462,7 +1124,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
} else {
|
||||
payeeUnread = true;
|
||||
}
|
||||
} else if (command === "ACROSS_V4_DEPOSIT_V3") {
|
||||
} else if (cmdId === 0x40) {
|
||||
// Only the routers with the 0x7f mask read a command type
|
||||
// of 0x40.
|
||||
const a = decodeAcrossV4DepositV3(inputs[i]);
|
||||
if (a) {
|
||||
bridgeDeposit = true;
|
||||
@@ -1485,7 +1149,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
} else {
|
||||
payeeUnread = true;
|
||||
}
|
||||
} else if (command === "WRAP_ETH") {
|
||||
} else if (cmdId === 0x0b) {
|
||||
const w = decodeWrapEth(inputs[i]);
|
||||
if (w) {
|
||||
setInputOnce(
|
||||
@@ -1493,36 +1157,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
w.amount,
|
||||
);
|
||||
addPayee(w.recipient);
|
||||
// CONTRACT_BALANCE wraps all the router's ETH.
|
||||
if (w.amount === CONTRACT_BALANCE) {
|
||||
heldByRouter.delete(
|
||||
"0x0000000000000000000000000000000000000000",
|
||||
);
|
||||
}
|
||||
// Into the router, the router holds the WETH. Its
|
||||
// amount is known when the step states it and the
|
||||
// router held no WETH before.
|
||||
if (isRouter(w.recipient) && w.amount > 0n) {
|
||||
const weth = WETH.get(chainId);
|
||||
heldByRouter.set(
|
||||
weth,
|
||||
w.amount === CONTRACT_BALANCE ||
|
||||
heldByRouter.has(weth)
|
||||
? null
|
||||
: w.amount,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
payeeUnread = true;
|
||||
}
|
||||
} else if (command === "V4_SWAP") {
|
||||
} else if (cmdId === 0x10) {
|
||||
const v4 = decodeV4Swap(inputs[i]);
|
||||
if (v4) {
|
||||
// Each SETTLE paid from the router spends the amount
|
||||
// decodeV4Swap() says it pays.
|
||||
for (const s of v4.settledFromRouter) {
|
||||
spendFromRouter(s.currency, s.amount);
|
||||
}
|
||||
for (const t of v4.takes) {
|
||||
if (!isHop(i, t.currency, t.recipient)) {
|
||||
addPayee(t.recipient, t.currency);
|
||||
@@ -1562,41 +1202,29 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
setInputOnce(v4.tokenIn, v4.amountIn);
|
||||
setOutput(v4.tokenOut, v4.amountOutMin);
|
||||
}
|
||||
} else if (command === "UNWRAP_WETH") {
|
||||
} else if (cmdId === 0x0c) {
|
||||
hasUnwrapWeth = true;
|
||||
const u = decodeUnwrapWeth(inputs[i]);
|
||||
if (u) {
|
||||
addPayee(u.recipient);
|
||||
// It pays out all the router's WETH, as ETH. Into the
|
||||
// router, the router then holds as ETH the WETH a
|
||||
// step paid it.
|
||||
const heldWeth = WETH_ADDRESSES.some((w) =>
|
||||
heldByRouter.has(w),
|
||||
);
|
||||
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w));
|
||||
if (isRouter(u.recipient) && heldWeth) {
|
||||
heldByRouter.set(
|
||||
"0x0000000000000000000000000000000000000000",
|
||||
null,
|
||||
if (!isRouter(u.recipient)) {
|
||||
WETH_ADDRESSES.forEach((w) =>
|
||||
heldByRouter.delete(w),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
payeeUnread = true;
|
||||
}
|
||||
} else {
|
||||
// Any other step may pay an address this decoder does not
|
||||
// read: V3_SWAP_EXACT_OUT, PERMIT2_TRANSFER_FROM_BATCH and
|
||||
// the NFT commands among them, and a type the release
|
||||
// names no command for.
|
||||
// Any other command may pay an address this decoder does
|
||||
// not read: V3_SWAP_EXACT_OUT and
|
||||
// PERMIT2_TRANSFER_FROM_BATCH among them.
|
||||
payeeUnread = true;
|
||||
}
|
||||
} catch {
|
||||
// Skip commands we can't decode. One may pay any address.
|
||||
payeeUnread = true;
|
||||
}
|
||||
if (allowedToFail) {
|
||||
commandNames[i] += " (allowed to fail)";
|
||||
}
|
||||
}
|
||||
|
||||
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
|
||||
@@ -1710,26 +1338,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
});
|
||||
}
|
||||
|
||||
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
|
||||
// where it goes, an address on a line of its own.
|
||||
for (const m of movedFromSender) {
|
||||
details.push({
|
||||
label: "Moved from the sender",
|
||||
value: amountText(m.amount, tokenInfo(m.token, sources))
|
||||
.display,
|
||||
});
|
||||
details.push(tokenMovedLine("Token moved", m.token, sources));
|
||||
details.push(
|
||||
present(m.to)
|
||||
? { label: "Moved to", value: m.to }
|
||||
: {
|
||||
label: "Moved to",
|
||||
value: m.recipient,
|
||||
address: m.recipient,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Keyed on the address, not the symbol: a token absent from the
|
||||
// bundled list has no symbol, and gating the line on one dropped it
|
||||
// entirely, leaving a Min. received figure with nothing saying what is
|
||||
@@ -1763,11 +1371,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
|
||||
// A Paid to line names each other address paid, and says when a
|
||||
// step pays the bridge, when one moves the sender's tokens into the
|
||||
// router, when a token stays in the router, and when a payout could
|
||||
// not be read. The output is received by the sender only when some
|
||||
// step pays the sender and there is no such line. When no step names
|
||||
// anyone paid, one line says so. `paidTo` holds these lines and the
|
||||
// token line after each line about a token in the router.
|
||||
// router, when the output stays in the router, and when a payout
|
||||
// could not be read. The output is received by the sender only when
|
||||
// some step pays the sender and there is no such line. When no step
|
||||
// names anyone paid, one line says so. `paidTo` holds these lines and
|
||||
// the token line after each move into the router.
|
||||
const outputHeld =
|
||||
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
|
||||
const paidTo = paidElsewhere.map((address) => ({
|
||||
@@ -1778,6 +1386,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
if (bridgeDeposit) {
|
||||
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
|
||||
}
|
||||
// Each move into the router is followed by a line giving the moved
|
||||
// token's address, in the shape of the Token In line, so the screens
|
||||
// show it as they show that one.
|
||||
for (const m of movedIntoRouter) {
|
||||
const info = tokenInfo(m.token, sources);
|
||||
paidTo.push({
|
||||
@@ -1787,30 +1398,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
amountText(m.amount, info).display +
|
||||
" from the sender (later steps may not spend all of it)",
|
||||
});
|
||||
paidTo.push(
|
||||
tokenMovedLine("Token moved into the router", m.token, sources),
|
||||
);
|
||||
paidTo.push({
|
||||
label: "Token moved into the router",
|
||||
value: info.symbol
|
||||
? info.symbol + " (" + m.token + ")"
|
||||
: m.token,
|
||||
address: m.token,
|
||||
isToken: true,
|
||||
});
|
||||
}
|
||||
if (outputHeld) {
|
||||
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
|
||||
}
|
||||
// Any other token the router still holds after the last step, such
|
||||
// as one a swap step bought on the way to the output, what a V4
|
||||
// SETTLE took beyond what the step's swap spent, or the WETH of a
|
||||
// WRAP_ETH, stays there too, and its line is followed by the token's.
|
||||
for (const token of heldByRouter.keys()) {
|
||||
if (present(outputToken) && token === outputToken.toLowerCase()) {
|
||||
continue;
|
||||
}
|
||||
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
|
||||
paidTo.push(
|
||||
tokenMovedLine(
|
||||
"Token moved into the router",
|
||||
getAddress(token),
|
||||
sources,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (payeeUnread) {
|
||||
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}`,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
-19
@@ -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(
|
||||
|
||||
+5
-1413
File diff suppressed because it is too large
Load Diff
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user