Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aa65ccdc6 |
@@ -430,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
|
||||
@@ -1086,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`.
|
||||
@@ -1208,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
|
||||
@@ -1773,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
|
||||
@@ -1818,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,
|
||||
@@ -1839,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
|
||||
@@ -1853,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**
|
||||
@@ -2366,11 +2329,9 @@ view would leave a wallet one click from deletion.
|
||||
- From: color dot + full address + etherscan link
|
||||
- Contract: color dot + full address + etherscan link, token symbol label if
|
||||
known; for a contract creation, the same sentence as on WaitTx
|
||||
- Value: amount in ETH (4 decimal places 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
|
||||
@@ -2989,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`
|
||||
@@ -3044,59 +2979,46 @@ 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 the swap's output token, the output stays in
|
||||
the router unless a later step pays all the router holds of it to anyone but
|
||||
the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is WETH, a
|
||||
`TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a
|
||||
addresses, and each that is not the transaction's sender is named. 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
|
||||
sets `useNative`. A step paying a token to the V2 pair that the very next step
|
||||
spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID`
|
||||
(zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes
|
||||
pay the next V2 pair directly, and the router enforces that swap's own
|
||||
minimum. The decoder checks that the address paid is that pair, deriving it
|
||||
from the V2 factory and pair init code hash the network's routers were
|
||||
deployed with and the first two tokens of that step's path; a payment to any
|
||||
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was
|
||||
deployed with placeholders for both values, so no V2 swap step can succeed on
|
||||
that router.) Every other payment counts, in whichever step and of whatever
|
||||
token, a share of the output included: an interface fee paid to another
|
||||
address by `PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the
|
||||
minimum reads `Min. paid out`, because the swap's minimum includes the fee.
|
||||
The minimum reads `Min. received` only when some step pays the transaction's
|
||||
sender (the From address on TxApproval, the sender the explorer reports on
|
||||
TransactionDetail) and no `Paid to` line is shown. `Paid to` lines after
|
||||
`Token Out` name each other address paid; one reads
|
||||
`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)` when the output
|
||||
stays in the router; and one more reads `Unknown (could not be decoded)` when
|
||||
a step that may pay an address went unread. When no step pays anyone, a single
|
||||
line reads `Unknown (not named in the calldata)`. Wherever there is a
|
||||
`Paid to` line, the minimum reads `Min. paid out`. A step goes unread when its
|
||||
recipient does not decode as an address, or when it is a command the decoder
|
||||
does not read: every command other than `PERMIT2_PERMIT`,
|
||||
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
|
||||
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
|
||||
`PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
|
||||
`UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`,
|
||||
`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among
|
||||
them), or a type its release names no command for.
|
||||
for each move into the router; one reads
|
||||
`The router, where it stays (no later step pays it out)` when the output stays
|
||||
in the router; and one more reads `Unknown (could not be decoded)` when a step
|
||||
that may pay an address went unread. When no step pays anyone, a single line
|
||||
reads `Unknown (not named in the calldata)`. Wherever there is a `Paid to`
|
||||
line, the minimum reads `Min. paid out`. A step goes unread when its recipient
|
||||
does not decode as an address, or when it is a command the decoder does not
|
||||
read: every command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
|
||||
`V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
|
||||
`SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
|
||||
`ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
|
||||
(`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH` and `EXECUTE_SUB_PLAN`
|
||||
among them).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -127,18 +127,16 @@ 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))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,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,
|
||||
@@ -100,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
|
||||
@@ -134,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,
|
||||
);
|
||||
}
|
||||
@@ -315,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";
|
||||
@@ -584,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") {
|
||||
@@ -622,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);
|
||||
@@ -660,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.
|
||||
@@ -679,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;
|
||||
}
|
||||
|
||||
@@ -688,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;
|
||||
}
|
||||
|
||||
@@ -720,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);
|
||||
@@ -740,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");
|
||||
|
||||
+105
-322
@@ -24,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
|
||||
@@ -266,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,
|
||||
@@ -501,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 {
|
||||
@@ -616,12 +448,10 @@ function decodeSweep(input) {
|
||||
|
||||
// Decode TRANSFER (command 0x05) input bytes, and those of PAY_PORTION (0x06)
|
||||
// and PAY_PORTION_FULL_PRECISION (0x07), which share its layout. Each pays
|
||||
// `token` out of the router to the recipient. TRANSFER pays `amount`, or, of a
|
||||
// token other than ETH, all the router holds when `amount` is
|
||||
// CONTRACT_BALANCE; it sends ETH as written, so a TRANSFER of
|
||||
// CONTRACT_BALANCE of ETH (`token` the zero address) always reverts.
|
||||
// PAY_PORTION pays `amount` ten-thousandths of what the router holds, and
|
||||
// PAY_PORTION_FULL_PRECISION `amount` 1e18ths of it, of ETH too.
|
||||
// `token` out of the router to the recipient. TRANSFER pays `amount`, or all
|
||||
// the router holds when `amount` is CONTRACT_BALANCE. PAY_PORTION pays
|
||||
// `amount` ten-thousandths of what the router holds, and
|
||||
// PAY_PORTION_FULL_PRECISION `amount` 1e18ths of it.
|
||||
// - UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/modules/Payments.sol
|
||||
// - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/Payments.sol
|
||||
// ABI: (address token, address recipient, uint256 amount)
|
||||
@@ -1024,9 +854,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;
|
||||
@@ -1139,9 +968,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 &&
|
||||
@@ -1157,22 +984,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.
|
||||
@@ -1180,13 +1003,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;
|
||||
@@ -1205,37 +1028,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
} else if (!isHop(i, p.token, p.recipient)) {
|
||||
addPayee(p.recipient);
|
||||
}
|
||||
} 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);
|
||||
@@ -1249,7 +1052,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
} 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);
|
||||
@@ -1260,7 +1063,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
} 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]);
|
||||
@@ -1273,7 +1076,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);
|
||||
@@ -1284,9 +1087,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) {
|
||||
@@ -1297,29 +1100,20 @@ 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" &&
|
||||
t.token ===
|
||||
"0x0000000000000000000000000000000000000000";
|
||||
if (
|
||||
t.amount === all &&
|
||||
!ethTransfer &&
|
||||
!isRouter(t.recipient)
|
||||
) {
|
||||
if (t.amount === all && !isRouter(t.recipient)) {
|
||||
heldByRouter.delete(t.token.toLowerCase());
|
||||
}
|
||||
} 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;
|
||||
@@ -1342,7 +1136,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(
|
||||
@@ -1353,7 +1147,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
} else {
|
||||
payeeUnread = true;
|
||||
}
|
||||
} else if (command === "V4_SWAP") {
|
||||
} else if (cmdId === 0x10) {
|
||||
const v4 = decodeV4Swap(inputs[i]);
|
||||
if (v4) {
|
||||
for (const t of v4.takes) {
|
||||
@@ -1395,7 +1189,7 @@ 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) {
|
||||
@@ -1409,19 +1203,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
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
|
||||
@@ -1571,8 +1361,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
// router, when the output stays in the router, and when a payout
|
||||
// could not be read. The output is received by the sender only when
|
||||
// some step pays the sender and there is no such line. When no step
|
||||
// names anyone paid, one line says so. `paidTo` holds these lines and
|
||||
// the token line after each move into the router.
|
||||
// names anyone paid, one line says so.
|
||||
const outputHeld =
|
||||
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
|
||||
const paidTo = paidElsewhere.map((address) => ({
|
||||
@@ -1583,26 +1372,20 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
if (bridgeDeposit) {
|
||||
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
|
||||
}
|
||||
// Each move into the router is followed by a line giving the moved
|
||||
// token's address, in the shape of the Token In line, so the screens
|
||||
// show it as they show that one.
|
||||
for (const m of movedIntoRouter) {
|
||||
const info = tokenInfo(m.token, sources);
|
||||
// amountText() names the token only when it knows both its
|
||||
// symbol and its scale.
|
||||
const ofToken =
|
||||
info.symbol && info.decimals !== null ? "" : " of " + m.token;
|
||||
paidTo.push({
|
||||
label: "Paid to",
|
||||
value:
|
||||
"The router, " +
|
||||
amountText(m.amount, info).display +
|
||||
ofToken +
|
||||
" from the sender (later steps may not spend all of it)",
|
||||
});
|
||||
paidTo.push({
|
||||
label: "Token moved into the router",
|
||||
value: info.symbol
|
||||
? info.symbol + " (" + m.token + ")"
|
||||
: m.token,
|
||||
address: m.token,
|
||||
isToken: true,
|
||||
});
|
||||
}
|
||||
if (outputHeld) {
|
||||
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
-217
@@ -2232,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);
|
||||
@@ -2246,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,
|
||||
@@ -2266,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,
|
||||
};
|
||||
});
|
||||
@@ -2915,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),
|
||||
);
|
||||
@@ -3068,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 () => {
|
||||
|
||||
@@ -477,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: {
|
||||
|
||||
+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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
+7
-15
@@ -987,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,
|
||||
@@ -1023,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(
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
// The address a swap's output is paid to
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/594 and
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/599), the allow-revert flag
|
||||
// on the steps that pay it
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router
|
||||
// release runs for a command type
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/592), and the address whose
|
||||
// balance a BALANCE_CHECK_ERC20 checks
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/600).
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/599).
|
||||
//
|
||||
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
|
||||
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
|
||||
@@ -51,8 +45,6 @@ const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
||||
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
|
||||
// UniversalRouterV1 on Ethereum Mainnet.
|
||||
const ROUTER_V1 = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B";
|
||||
// UniversalRouterV1_2_V2Support on Ethereum Mainnet.
|
||||
const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
|
||||
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const OTHER = "0x7777777777777777777777777777777777777777";
|
||||
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
|
||||
@@ -62,8 +54,6 @@ const ZERO = "0x0000000000000000000000000000000000000000";
|
||||
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||
// A token the wallet does not know.
|
||||
const UNKNOWN_TOKEN = "0x5555555555555555555555555555555555555555";
|
||||
const ONE_WETH = 1000000000000000000n;
|
||||
const USDT_2000 = 2000000000n;
|
||||
const USDC_1990 = 1990000000n;
|
||||
@@ -157,20 +147,6 @@ function transfer(command, token, recipient, amount) {
|
||||
];
|
||||
}
|
||||
|
||||
// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend.
|
||||
function permit2Permit(token, amount) {
|
||||
return [
|
||||
0x0a,
|
||||
coder.encode(
|
||||
[
|
||||
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
|
||||
"bytes",
|
||||
],
|
||||
[[[token, amount, 0, 0], ROUTER, 0n], "0x"],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function permit2TransferFrom(token, recipient, amount) {
|
||||
return [
|
||||
0x02,
|
||||
@@ -225,20 +201,16 @@ function unwrapWeth(recipient) {
|
||||
return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])];
|
||||
}
|
||||
|
||||
// BALANCE_CHECK_ERC20 (0x0e) of `owner`'s balance of `token`.
|
||||
function balanceCheck(token, minBalance, owner = USER) {
|
||||
function balanceCheck(token, minBalance) {
|
||||
return [
|
||||
0x0e,
|
||||
coder.encode(
|
||||
["address", "address", "uint256"],
|
||||
[owner, token, minBalance],
|
||||
[USER, token, minBalance],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// `step` with the allow-revert flag set on its command byte.
|
||||
const flagged = ([command, input]) => [command | 0x80, input];
|
||||
|
||||
// `step`, a [command or action, input] pair, with the top byte of the input's
|
||||
// word `index` set. The decoder cannot read that word as an address.
|
||||
function withDirtyWord([id, input], index) {
|
||||
@@ -651,24 +623,8 @@ describe("each transfer command's recipient", () => {
|
||||
"PERMIT2_TRANSFER_FROM into the router as $kind names what it moves, and nothing is received",
|
||||
({ r }) => {
|
||||
const result = decode(movedThenSwapped(r));
|
||||
expect(labels(result)).toEqual([
|
||||
"Protocol",
|
||||
"Token In",
|
||||
"Amount",
|
||||
"Token Out",
|
||||
"Paid to",
|
||||
"Token moved into the router",
|
||||
"Min. paid out",
|
||||
"Steps",
|
||||
"Deadline",
|
||||
]);
|
||||
expect(labels(result)).toEqual(PAID_ELSEWHERE);
|
||||
expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]);
|
||||
expect(detail(result, "Token moved into the router")).toEqual({
|
||||
label: "Token moved into the router",
|
||||
value: "USDT (" + USDT + ")",
|
||||
address: USDT,
|
||||
isToken: true,
|
||||
});
|
||||
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
|
||||
},
|
||||
);
|
||||
@@ -699,22 +655,17 @@ describe("each transfer command's recipient", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("a move into the router of a token the wallet does not know gives the token's address", () => {
|
||||
test("a move into the router of a token the wallet does not know names the token", () => {
|
||||
const unknown = "0x5555555555555555555555555555555555555555";
|
||||
const result = decode(
|
||||
execute(
|
||||
permit2TransferFrom(UNKNOWN_TOKEN, ADDRESS_THIS, 5n),
|
||||
permit2TransferFrom(unknown, ADDRESS_THIS, 5n),
|
||||
swapToWeth(MSG_SENDER),
|
||||
),
|
||||
);
|
||||
expect(paidTo(result)).toEqual([
|
||||
movedIntoRouter("5 base units (decimals unknown)"),
|
||||
movedIntoRouter("5 base units (decimals unknown) of " + unknown),
|
||||
]);
|
||||
expect(detail(result, "Token moved into the router")).toEqual({
|
||||
label: "Token moved into the router",
|
||||
value: UNKNOWN_TOKEN,
|
||||
address: UNKNOWN_TOKEN,
|
||||
isToken: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("UniversalRouterV1 pays a PERMIT2_TRANSFER_FROM to address(1) or address(2) as written, and maps a TRANSFER's", () => {
|
||||
@@ -792,32 +743,6 @@ describe("a payout of part of the router's output", () => {
|
||||
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
|
||||
});
|
||||
|
||||
// The router sends ETH as written, so a TRANSFER of CONTRACT_BALANCE of
|
||||
// ETH always reverts; PAY_PORTION reads the router's ETH balance.
|
||||
test("a TRANSFER of CONTRACT_BALANCE of ETH pays out nothing; a PAY_PORTION of 10,000 bips of ETH pays out all of it", () => {
|
||||
const ethToRouter = v4Swap(
|
||||
SWAP_USDT_FOR_ETH,
|
||||
SETTLE_USDT,
|
||||
take(ZERO, ADDRESS_THIS),
|
||||
);
|
||||
|
||||
const transferred = decode(
|
||||
execute(
|
||||
ethToRouter,
|
||||
transfer(0x05, ZERO, MSG_SENDER, CONTRACT_BALANCE),
|
||||
),
|
||||
);
|
||||
expect(detail(transferred, "Token Out").value).toBe("ETH");
|
||||
expect(paidTo(transferred)).toEqual([STAYS_IN_ROUTER]);
|
||||
expect(detail(transferred, "Min. received")).toBeUndefined();
|
||||
|
||||
const portion = decode(
|
||||
execute(ethToRouter, transfer(0x06, ZERO, MSG_SENDER, 10000n)),
|
||||
);
|
||||
expect(labels(portion)).toEqual(PAID_TO_USER);
|
||||
expect(detail(portion, "Min. received").value).toBe("1.0000 ETH");
|
||||
});
|
||||
|
||||
// An interface fee: a share of the output to another address, the rest
|
||||
// to the user. The swap's minimum includes the fee, so it is not what the
|
||||
// user receives.
|
||||
@@ -1388,709 +1313,6 @@ describe("paths whose recipients were untested", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/602: the top bit of a command
|
||||
// byte (0x80) is the allow-revert flag. The router lets a step with it fail
|
||||
// and goes on to the next step only for some commands, none of them a swap or
|
||||
// a payment; on every other command the flag has no effect.
|
||||
describe("the allow-revert flag", () => {
|
||||
// UniversalRouterV2_1_1 on Ethereum Mainnet.
|
||||
const ROUTER_V2_1_1 = "0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA";
|
||||
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
|
||||
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
|
||||
|
||||
// `steps` with the flag set on the one at `index`.
|
||||
const flagStep = (steps, index) =>
|
||||
steps.map((s, i) => (i === index ? flagged(s) : s));
|
||||
|
||||
const usdcToRouter = v3SwapExactIn(
|
||||
ADDRESS_THIS,
|
||||
USDT_2000,
|
||||
USDC_1990,
|
||||
USDT,
|
||||
USDC,
|
||||
);
|
||||
const wethToPair = v3SwapExactIn(PAIR, USDT_2000, 0n, USDT, WETH);
|
||||
const pairSpends = v2Swap(
|
||||
0x08,
|
||||
MSG_SENDER,
|
||||
0n,
|
||||
USDC_1990,
|
||||
[WETH, USDC],
|
||||
false,
|
||||
);
|
||||
|
||||
// The flag is set on the step at `index`: a swap, a payment, or the V2
|
||||
// swap that a payment to its pair hops into.
|
||||
test.each([
|
||||
{
|
||||
kind: "V2_SWAP_EXACT_IN",
|
||||
steps: [
|
||||
v2Swap(0x08, MSG_SENDER, USDT_2000, ONE_WETH, [USDT, WETH]),
|
||||
],
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
kind: "V2_SWAP_EXACT_OUT",
|
||||
steps: [
|
||||
v2Swap(0x09, MSG_SENDER, ONE_WETH, USDT_2000, [USDT, WETH]),
|
||||
],
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
kind: "V3_SWAP_EXACT_IN",
|
||||
steps: [swapToWeth(MSG_SENDER)],
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
kind: "V4_SWAP",
|
||||
steps: [v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))],
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
kind: "SWEEP",
|
||||
steps: [usdcToRouter, sweep(USDC, MSG_SENDER)],
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
kind: "TRANSFER",
|
||||
steps: [
|
||||
usdcToRouter,
|
||||
transfer(0x05, USDC, MSG_SENDER, CONTRACT_BALANCE),
|
||||
],
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
kind: "PAY_PORTION",
|
||||
steps: [usdcToRouter, transfer(0x06, USDC, MSG_SENDER, 10000n)],
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
kind: "PAY_PORTION_FULL_PRECISION",
|
||||
steps: [usdcToRouter, transfer(0x07, USDC, MSG_SENDER, 10n ** 18n)],
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
kind: "UNWRAP_WETH",
|
||||
steps: [swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)],
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
kind: "WRAP_ETH",
|
||||
steps: [
|
||||
wrapEth(ADDRESS_THIS, ONE_WETH),
|
||||
v3SwapExactIn(
|
||||
MSG_SENDER,
|
||||
ONE_WETH,
|
||||
USDC_1990,
|
||||
WETH,
|
||||
USDC,
|
||||
false,
|
||||
),
|
||||
],
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
kind: "ACROSS_V4_DEPOSIT_V3",
|
||||
steps: [
|
||||
usdcToRouter,
|
||||
acrossDeposit(USER, USER, USDC, CONTRACT_BALANCE),
|
||||
],
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
kind: "PERMIT2_TRANSFER_FROM to the pair the next V2 swap spends",
|
||||
steps: [permit2TransferFrom(WETH, PAIR, ONE_WETH), pairSpends],
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
kind: "a swap paid to the pair the next V2 swap spends",
|
||||
steps: [wethToPair, pairSpends],
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
kind: "the V2 swap after a swap paid to its pair",
|
||||
steps: [wethToPair, pairSpends],
|
||||
index: 1,
|
||||
},
|
||||
])(
|
||||
"$kind reads the same with the flag as without it",
|
||||
({ steps, index }) => {
|
||||
const plain = decode(execute(...steps), USER, ROUTER_V2_1_2);
|
||||
const withFlag = decode(
|
||||
execute(...flagStep(steps, index)),
|
||||
USER,
|
||||
ROUTER_V2_1_2,
|
||||
);
|
||||
expect(withFlag).toEqual(plain);
|
||||
expect(detail(withFlag, "Steps").value).not.toContain(
|
||||
"allowed to fail",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
|
||||
{ router: "UniversalRouterV2", address: ROUTER },
|
||||
{ router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 },
|
||||
])(
|
||||
"on $router, a BALANCE_CHECK_ERC20 with the flag is allowed to fail and does not replace the swap's minimum",
|
||||
({ address }) => {
|
||||
const check = balanceCheck(WETH, 2n * ONE_WETH);
|
||||
|
||||
const plain = decode(
|
||||
execute(swapToWeth(MSG_SENDER), check),
|
||||
USER,
|
||||
address,
|
||||
);
|
||||
expect(detail(plain, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check",
|
||||
);
|
||||
expect(detail(plain, "Min. received").value).toBe("2.0000 WETH");
|
||||
|
||||
const withFlag = decode(
|
||||
execute(swapToWeth(MSG_SENDER), flagged(check)),
|
||||
USER,
|
||||
address,
|
||||
);
|
||||
expect(detail(withFlag, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check (allowed to fail)",
|
||||
);
|
||||
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
|
||||
},
|
||||
);
|
||||
|
||||
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
|
||||
const check = balanceCheck(WETH, ONE_WETH);
|
||||
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
|
||||
"1.0000 WETH",
|
||||
);
|
||||
|
||||
const withFlag = decode(execute(flagged(check)));
|
||||
expect(detail(withFlag, "Token Out").address).toBe(WETH);
|
||||
expect(detail(withFlag, "Min. paid out").value).toBe(
|
||||
"None (no minimum guaranteed)",
|
||||
);
|
||||
});
|
||||
|
||||
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
|
||||
test.each([
|
||||
{ router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
mark: "",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV2",
|
||||
address: ROUTER,
|
||||
mark: " (allowed to fail)",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV2_1_2",
|
||||
address: ROUTER_V2_1_2,
|
||||
mark: " (allowed to fail)",
|
||||
},
|
||||
])(
|
||||
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag",
|
||||
({ address, mark }) => {
|
||||
const steps = [
|
||||
permit2Permit(USDT, USDT_2000),
|
||||
swapToWeth(MSG_SENDER),
|
||||
];
|
||||
const plain = decode(execute(...steps), USER, address);
|
||||
const withFlag = decode(
|
||||
execute(...flagStep(steps, 0)),
|
||||
USER,
|
||||
address,
|
||||
);
|
||||
expect(detail(withFlag, "Steps").value).toBe(
|
||||
"Permit2 Permit" + mark + " → V3 Swap (Exact In)",
|
||||
);
|
||||
const withoutSteps = (r) =>
|
||||
r.details.filter((d) => d.label !== "Steps");
|
||||
expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain));
|
||||
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
|
||||
},
|
||||
);
|
||||
|
||||
test("a step the decoder does not read is marked too", () => {
|
||||
const positionManagerCall = [0x12, "0x"];
|
||||
const plain = decode(
|
||||
execute(swapToWeth(MSG_SENDER), positionManagerCall),
|
||||
);
|
||||
expect(detail(plain, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → V3 Position Mgr Call",
|
||||
);
|
||||
|
||||
const withFlag = decode(
|
||||
execute(swapToWeth(MSG_SENDER), flagged(positionManagerCall)),
|
||||
);
|
||||
expect(detail(withFlag, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → V3 Position Mgr Call (allowed to fail)",
|
||||
);
|
||||
expect(paidTo(withFlag)).toEqual([UNDECODED]);
|
||||
});
|
||||
|
||||
// A command from each router's list, by its name in that release's
|
||||
// Commands.sol.
|
||||
test.each([
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
command: "FOUNDATION",
|
||||
id: 0x1c,
|
||||
name: "Foundation",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
command: "ELEMENT_MARKET",
|
||||
id: 0x1e,
|
||||
name: "Element Market",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV2",
|
||||
address: ROUTER,
|
||||
command: "PERMIT2_PERMIT_BATCH",
|
||||
id: 0x03,
|
||||
name: "Permit2 Permit Batch",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV2_1_1",
|
||||
address: ROUTER_V2_1_1,
|
||||
command: "V4_INITIALIZE_POOL",
|
||||
id: 0x13,
|
||||
name: "V4 Initialize Pool",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV2_1_2",
|
||||
address: ROUTER_V2_1_2,
|
||||
command: "V4_POSITION_MANAGER_CALL",
|
||||
id: 0x14,
|
||||
name: "V4 Position Mgr Call",
|
||||
},
|
||||
])(
|
||||
"on $router, $command with the flag is listed as '$name (allowed to fail)'",
|
||||
({ address, id, name }) => {
|
||||
const step = [id, "0x"];
|
||||
const plain = decode(
|
||||
execute(swapToWeth(MSG_SENDER), step),
|
||||
USER,
|
||||
address,
|
||||
);
|
||||
expect(detail(plain, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → " + name,
|
||||
);
|
||||
|
||||
const withFlag = decode(
|
||||
execute(swapToWeth(MSG_SENDER), flagged(step)),
|
||||
USER,
|
||||
address,
|
||||
);
|
||||
expect(detail(withFlag, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → " + name + " (allowed to fail)",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/592: each release runs its own
|
||||
// command for a command type, as its Commands.sol names it. The decoder named
|
||||
// and read every type as UniversalRouterV2 does, so on the two older routers
|
||||
// an NFT command read as a V4 swap, and on UniversalRouterV1, which reverts on
|
||||
// 0x0e, that type read as a balance check that set the minimum.
|
||||
describe("the command each release runs for a command type", () => {
|
||||
// Inputs UniversalRouterV2 reads as a V4 swap of 2,000 USDT for at least
|
||||
// 1,990 USDC paid to the user, and as a check that the user holds at
|
||||
// least 1 WETH.
|
||||
const swapInput = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))[1];
|
||||
const checkInput = balanceCheck(WETH, ONE_WETH)[1];
|
||||
// The lines of a transaction whose one step the decoder does not read.
|
||||
const NOT_READ = [
|
||||
"Protocol",
|
||||
"Token In",
|
||||
"Token Out",
|
||||
"Paid to",
|
||||
"Steps",
|
||||
"Deadline",
|
||||
];
|
||||
|
||||
test.each([
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x0e,
|
||||
input: checkInput,
|
||||
name: "Command 0x0e",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x10,
|
||||
input: swapInput,
|
||||
name: "Seaport",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x11,
|
||||
input: swapInput,
|
||||
name: "LooksRare 721",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x12,
|
||||
input: swapInput,
|
||||
name: "NFTX",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x13,
|
||||
input: swapInput,
|
||||
name: "CryptoPunks",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x14,
|
||||
input: swapInput,
|
||||
name: "LooksRare 1155",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x15,
|
||||
input: swapInput,
|
||||
name: "Owner Check 721",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x16,
|
||||
input: swapInput,
|
||||
name: "Owner Check 1155",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x17,
|
||||
input: swapInput,
|
||||
name: "Sweep ERC-721",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x18,
|
||||
input: swapInput,
|
||||
name: "X2Y2 721",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x19,
|
||||
input: swapInput,
|
||||
name: "Sudoswap",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x1a,
|
||||
input: swapInput,
|
||||
name: "NFT20",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x1b,
|
||||
input: swapInput,
|
||||
name: "X2Y2 1155",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x1c,
|
||||
input: swapInput,
|
||||
name: "Foundation",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1",
|
||||
address: ROUTER_V1,
|
||||
id: 0x1d,
|
||||
input: swapInput,
|
||||
name: "Sweep ERC-1155",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x10,
|
||||
input: swapInput,
|
||||
name: "Seaport V1.5",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x11,
|
||||
input: swapInput,
|
||||
name: "LooksRare V2",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x12,
|
||||
input: swapInput,
|
||||
name: "NFTX",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x13,
|
||||
input: swapInput,
|
||||
name: "CryptoPunks",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x14,
|
||||
input: swapInput,
|
||||
name: "Command 0x14",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x15,
|
||||
input: swapInput,
|
||||
name: "Owner Check 721",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x16,
|
||||
input: swapInput,
|
||||
name: "Owner Check 1155",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x17,
|
||||
input: swapInput,
|
||||
name: "Sweep ERC-721",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x18,
|
||||
input: swapInput,
|
||||
name: "X2Y2 721",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x19,
|
||||
input: swapInput,
|
||||
name: "Sudoswap",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x1a,
|
||||
input: swapInput,
|
||||
name: "NFT20",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x1b,
|
||||
input: swapInput,
|
||||
name: "X2Y2 1155",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x1c,
|
||||
input: swapInput,
|
||||
name: "Foundation",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x1d,
|
||||
input: swapInput,
|
||||
name: "Sweep ERC-1155",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x1e,
|
||||
input: swapInput,
|
||||
name: "Element Market",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x20,
|
||||
input: swapInput,
|
||||
name: "Seaport V1.4",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
id: 0x22,
|
||||
input: swapInput,
|
||||
name: "Approve ERC-20",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV2",
|
||||
address: ROUTER,
|
||||
id: 0x11,
|
||||
input: swapInput,
|
||||
name: "V3 Position Mgr Permit",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV2",
|
||||
address: ROUTER,
|
||||
id: 0x12,
|
||||
input: swapInput,
|
||||
name: "V3 Position Mgr Call",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV2",
|
||||
address: ROUTER,
|
||||
id: 0x13,
|
||||
input: swapInput,
|
||||
name: "V4 Initialize Pool",
|
||||
},
|
||||
])(
|
||||
"on $router, '$name' is a step the decoder does not read",
|
||||
({ address, id, input, name }) => {
|
||||
const result = decode(execute([id, input]), USER, address);
|
||||
expect(detail(result, "Steps").value).toBe(name);
|
||||
expect(result.name).toBe("Uniswap Swap");
|
||||
expect(labels(result)).toEqual(NOT_READ);
|
||||
expect(paidTo(result)).toEqual([UNDECODED]);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
|
||||
{ router: "UniversalRouterV2", address: ROUTER },
|
||||
])(
|
||||
"on $router, 0x0e is a balance check, which sets the minimum",
|
||||
({ address }) => {
|
||||
const result = decode(execute([0x0e, checkInput]), USER, address);
|
||||
expect(detail(result, "Steps").value).toBe("Balance Check");
|
||||
expect(detail(result, "Token Out").address).toBe(WETH);
|
||||
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
|
||||
},
|
||||
);
|
||||
|
||||
test("on UniversalRouterV2, 0x10 is a V4 swap", () => {
|
||||
const result = decode(execute([0x10, swapInput]), USER, ROUTER);
|
||||
expect(detail(result, "Steps").value).toBe("V4 Swap");
|
||||
expect(result.name).toBe("Swap USDT → USDC");
|
||||
expect(detail(result, "Min. received").value).toBe("1990.0000 USDC");
|
||||
});
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/600: on every release that has
|
||||
// BALANCE_CHECK_ERC20, the router checks the balance of the owner as written,
|
||||
// without map(), so address(1) and address(2) there are those addresses, not
|
||||
// the sender and the router. Only a check of the sender's own balance says
|
||||
// anything about what the user receives.
|
||||
describe.each([
|
||||
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
|
||||
{ router: "UniversalRouterV2", address: ROUTER },
|
||||
{ router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 },
|
||||
])("on $router, the owner of a BALANCE_CHECK_ERC20", ({ address }) => {
|
||||
// A swap of 2,000 USDT for WETH with no minimum, paid to the user.
|
||||
const swap = v3SwapExactIn(MSG_SENDER, USDT_2000, 0n, USDT, WETH);
|
||||
const NO_MINIMUM = "None (no minimum guaranteed)";
|
||||
|
||||
test("the sender: the check sets the minimum, unless the router lets it fail", () => {
|
||||
const check = balanceCheck(WETH, ONE_WETH, USER);
|
||||
|
||||
const plain = decode(execute(swap, check), USER, address);
|
||||
expect(detail(plain, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check",
|
||||
);
|
||||
expect(detail(plain, "Min. received").value).toBe("1.0000 WETH");
|
||||
// The sender as the From address gives it, in lower case.
|
||||
expect(
|
||||
decode(execute(swap, check), USER.toLowerCase(), address),
|
||||
).toEqual(plain);
|
||||
|
||||
const withFlag = decode(execute(swap, flagged(check)), USER, address);
|
||||
expect(detail(withFlag, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check (allowed to fail)",
|
||||
);
|
||||
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
|
||||
});
|
||||
|
||||
const notTheSender = [
|
||||
{ kind: "address(1)", owner: MSG_SENDER },
|
||||
{ kind: "address(2)", owner: ADDRESS_THIS },
|
||||
{ kind: "another address", owner: OTHER },
|
||||
];
|
||||
|
||||
test.each(notTheSender)(
|
||||
"$kind: after a swap paid to the user, the check sets no minimum and is said to be of another address",
|
||||
({ owner }) => {
|
||||
const check = balanceCheck(WETH, ONE_WETH, owner);
|
||||
|
||||
const plain = decode(execute(swap, check), USER, address);
|
||||
expect(detail(plain, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check (of another address)",
|
||||
);
|
||||
expect(paidTo(plain)).toEqual([]);
|
||||
expect(detail(plain, "Min. received").value).toBe(NO_MINIMUM);
|
||||
|
||||
const withFlag = decode(
|
||||
execute(swap, flagged(check)),
|
||||
USER,
|
||||
address,
|
||||
);
|
||||
expect(detail(withFlag, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check (of another address) (allowed to fail)",
|
||||
);
|
||||
expect(paidTo(withFlag)).toEqual([]);
|
||||
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(notTheSender)(
|
||||
"$kind: a check alone names no output token and states no minimum",
|
||||
({ owner }) => {
|
||||
const check = balanceCheck(WETH, ONE_WETH, owner);
|
||||
|
||||
for (const step of [check, flagged(check)]) {
|
||||
const result = decode(execute(step), USER, address);
|
||||
expect(detail(result, "Token Out").value).toBe(UNNAMED);
|
||||
expect(detail(result, "Min. received")).toBeUndefined();
|
||||
expect(detail(result, "Min. paid out")).toBeUndefined();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("an owner word with a nonzero top byte: the check is said not to decode and sets no minimum", () => {
|
||||
// The router checks the balance of the address in the word's last 20
|
||||
// bytes, here OTHER's.
|
||||
const check = withDirtyWord(balanceCheck(WETH, ONE_WETH, OTHER), 0);
|
||||
|
||||
const plain = decode(execute(swap, check), USER, address);
|
||||
expect(detail(plain, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check (could not be decoded)",
|
||||
);
|
||||
expect(detail(plain, "Min. received").value).toBe(NO_MINIMUM);
|
||||
|
||||
const withFlag = decode(execute(swap, flagged(check)), USER, address);
|
||||
expect(detail(withFlag, "Steps").value).toBe(
|
||||
"V3 Swap (Exact In) → Balance Check (could not be decoded) (allowed to fail)",
|
||||
);
|
||||
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
|
||||
});
|
||||
});
|
||||
|
||||
// The DOM stub both screens are drawn into.
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
@@ -2237,25 +1459,3 @@ describe("the screens judge the output against the transaction's sender", () =>
|
||||
expect(toOther).not.toContain("Min. received");
|
||||
});
|
||||
});
|
||||
|
||||
// The address of each token moved into the router, a known one and one the
|
||||
// wallet does not know, is shown as every other address is: with an explorer
|
||||
// link, and copied when clicked. Neither token is the swap's.
|
||||
describe("the screens show each token moved into the router as an address", () => {
|
||||
const twoMovesThenSwap = execute(
|
||||
permit2TransferFrom(USDC, ADDRESS_THIS, USDC_1990),
|
||||
permit2TransferFrom(UNKNOWN_TOKEN, ADDRESS_THIS, 5n),
|
||||
swapToWeth(MSG_SENDER),
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ screen: "approval", show: showApproval },
|
||||
{ screen: "transaction detail", show: showDetail },
|
||||
])("the $screen screen", async ({ show }) => {
|
||||
const html = await show(twoMovesThenSwap);
|
||||
for (const token of [USDC, UNKNOWN_TOKEN]) {
|
||||
expect(html).toContain('data-copy="' + token + '"');
|
||||
expect(html).toContain("/address/" + token + '"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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