Compare commits
2
Commits
3a1ee7e6da
...
e9dc1fdccd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9dc1fdccd | ||
|
|
c99772a520 |
@@ -1082,19 +1082,21 @@ The rule and its exception live in `src/shared/amountDisplay.js` as
|
||||
confirmation screens display goes through the floored one — the ERC-20 amount,
|
||||
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
|
||||
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
|
||||
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
|
||||
on the confirmation screen for the wallet's own send
|
||||
(`src/popup/views/confirmTx.js`). Both screens render a network fee through
|
||||
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
|
||||
rather than its truncated figure, so the same fee reads the same on both, USD
|
||||
value included. Balances are stored exactly (`src/shared/balances.js`), whatever
|
||||
decimals a token declares, so a balance below the floor reaches these screens as
|
||||
it is. The history list (`src/shared/transactions.js`) uses the unfloored one:
|
||||
the transaction detail view is the authoritative record and already shows exact
|
||||
precision. The balance lists use neither: they round to four places with
|
||||
`toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal
|
||||
rule is unchanged everywhere else, including for amounts at or above the floor
|
||||
on the approval screens.
|
||||
`Current balance` (`src/popup/views/send.js`), and the amount, balance and
|
||||
network fee on the confirmation screen for the wallet's own send
|
||||
(`src/popup/views/confirmTx.js`), which hands the wait, success and error
|
||||
screens the amount as it showed it. The transaction is still signed for the
|
||||
exact amount, and its USD value is priced from it. Both screens render a network
|
||||
fee through `formatFee()` in `src/popup/views/helpers.js`, which prices the
|
||||
exact fee in USD rather than its truncated figure, so the same fee reads the
|
||||
same on both, USD value included. Balances are stored exactly
|
||||
(`src/shared/balances.js`), whatever decimals a token declares, so a balance
|
||||
below the floor reaches these screens as it is. The history list
|
||||
(`src/shared/transactions.js`) uses the unfloored one: the transaction detail
|
||||
view is the authoritative record and already shows exact precision. The balance
|
||||
lists use neither: they round to four places with `toFixed(4)` (`balanceLine()`
|
||||
in `src/popup/views/helpers.js`). The 4-decimal rule is unchanged everywhere
|
||||
else, including for amounts at or above the floor on the approval screens.
|
||||
|
||||
The floor applies only where the token's scale is known. Where it is not, the
|
||||
approval screen states base units instead of a quantity — see Unknown token
|
||||
@@ -1198,7 +1200,11 @@ number. They can read:
|
||||
guarantees nothing: it sets `Token Out` and `Min. received` only when the
|
||||
output side holds no minimum, not even a zero one, at the point the check is
|
||||
reached, and otherwise leaves the current token and figure in place. A nonzero
|
||||
`minBalance` sets both lines, as a swap step does.
|
||||
`minBalance` sets both lines, as a swap step does. A `BALANCE_CHECK_ERC20`
|
||||
step that the router lets fail without reverting the transaction (see
|
||||
Transaction Decoding) guarantees nothing either, whatever its `minBalance`,
|
||||
and is read as one whose `minBalance` is zero. A minimum stated by a V4 swap
|
||||
step the router lets fail is likewise read as zero.
|
||||
|
||||
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
|
||||
reads `Unknown (not named in the calldata)` when the decoder found no token for
|
||||
@@ -1757,14 +1763,17 @@ view would leave a wallet one click from deletion.
|
||||
- Token contract: full address + etherscan link (ERC-20 only)
|
||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
|
||||
filled in is worked out again from this screen's own fee estimate when it
|
||||
arrives, as the balance minus the reserve, and the transaction is signed
|
||||
with that estimate's fee fields, so a fee fetched again at signing cannot
|
||||
exceed what the amount leaves behind. The address keeps whatever part of
|
||||
the reserve the transaction does not use. If the balance could not be read
|
||||
or no longer covers the fee, the amount is left as it was and one of the
|
||||
errors below blocks the send
|
||||
- Amount: value + symbol (USD in parentheses), the value to four decimal
|
||||
places with the nonzero floor, as on TxApproval (see Display Consistency),
|
||||
however Send's amount field wrote it (`.5` and `0.5` both read `0.5000`).
|
||||
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
|
||||
@@ -2970,13 +2979,33 @@ Currently supported:
|
||||
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
|
||||
(`0x80`) therefore does not change the command a step is read as. 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. On
|
||||
`UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` is an NFT
|
||||
market call the router lets fail, which the decoder still reads as a V4 swap
|
||||
([#592](https://git.eeqj.de/sneak/AutistMask/issues/592)); with the flag set,
|
||||
a minimum it states is read as zero: `None (no minimum guaranteed)`. 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`
|
||||
|
||||
@@ -127,16 +127,18 @@ function isRenderableDecoded(value) {
|
||||
}
|
||||
|
||||
// The pending transaction confirmTx.show() renders: `token` reaches
|
||||
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
|
||||
// addressTitle(), makeBlockie() and getLocalWarnings(). `chainId`, which
|
||||
// show() fills in when it is absent, picks the network whose explorer the
|
||||
// address links open, so it has to name one.
|
||||
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
|
||||
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
|
||||
// truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in
|
||||
// when it is absent, picks the network whose explorer the address links open,
|
||||
// so it has to name one.
|
||||
function isRenderablePendingTx(value) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isText(value.token) &&
|
||||
isAddressText(value.from) &&
|
||||
isAddressText(value.to) &&
|
||||
isText(value.amount) &&
|
||||
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ const {
|
||||
transferAmountUnits,
|
||||
} = require("../../shared/transferAmount");
|
||||
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
||||
// The balance line and the fee reserve go through it, as the approval screen's
|
||||
// amounts do.
|
||||
// The amount, the balance line and the fee reserve go through it, as the
|
||||
// approval screen's amounts do.
|
||||
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
||||
const {
|
||||
CODES,
|
||||
@@ -100,8 +100,31 @@ function valueWithUsd(text, usdAmount) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// The Amount line, with its USD value. A max ETH send's line is drawn again
|
||||
// once its amount is re-derived from the fee estimate.
|
||||
// The amount as this screen and the wait, success and error screens show it,
|
||||
// written as the approval screen writes a site's amount: the amount being
|
||||
// signed, parsed at the scale it is encoded with and written back as plain
|
||||
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
|
||||
// `00.5` read `0.5000`, as `0.5` does. txInfo.amount, which is what gets
|
||||
// signed, is left as it is.
|
||||
function shownAmount(txInfo) {
|
||||
let exact = txInfo.amount;
|
||||
try {
|
||||
if (txInfo.token === "ETH") {
|
||||
exact = formatEther(parseEther(txInfo.amount));
|
||||
} else {
|
||||
const decimals = displayedDecimals(txInfo.tokenDecimals);
|
||||
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
|
||||
}
|
||||
} catch {
|
||||
// A token whose scale is unknown, which is never signed, or an amount
|
||||
// the amount check refuses: cut as Send's amount field held it.
|
||||
}
|
||||
return truncateAmountNeverZero(exact);
|
||||
}
|
||||
|
||||
// The Amount line, with its USD value, which is priced from the exact amount.
|
||||
// A max ETH send's line is drawn again once its amount is re-derived from the
|
||||
// fee estimate.
|
||||
function renderAmount(txInfo) {
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
const rawSymbol = isErc20
|
||||
@@ -110,7 +133,7 @@ function renderAmount(txInfo) {
|
||||
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
||||
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||||
$("confirm-amount").textContent = valueWithUsd(
|
||||
txInfo.amount + " " + displaySymbol(rawSymbol),
|
||||
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
|
||||
amountUsd,
|
||||
);
|
||||
}
|
||||
@@ -698,6 +721,10 @@ function init(_ctx) {
|
||||
$("btn-confirm-send").disabled = true;
|
||||
$("btn-confirm-send").classList.add("text-muted");
|
||||
|
||||
// The wait, success and error screens show the amount as this screen
|
||||
// does, so they are handed a copy carrying that text. pendingTx keeps
|
||||
// the exact amount, which is the one signed.
|
||||
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
|
||||
let tx;
|
||||
try {
|
||||
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
|
||||
@@ -714,7 +741,7 @@ function init(_ctx) {
|
||||
// Note: JS strings are immutable; this nulls the reference but
|
||||
// the original string may persist in memory until GC.
|
||||
decryptedSecret = null;
|
||||
txStatus.showWait(pendingTx, tx.hash);
|
||||
txStatus.showWait(shownTx, tx.hash);
|
||||
} catch (e) {
|
||||
decryptedSecret = null;
|
||||
// A gas limit or fee over its bound is refused before anything is
|
||||
@@ -730,7 +757,7 @@ function init(_ctx) {
|
||||
return;
|
||||
}
|
||||
const hash = tx ? tx.hash : null;
|
||||
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
||||
txStatus.showError(shownTx, hash, e.shortMessage || e.message);
|
||||
} finally {
|
||||
$("btn-confirm-send").disabled = false;
|
||||
$("btn-confirm-send").classList.remove("text-muted");
|
||||
|
||||
+105
-36
@@ -24,55 +24,104 @@ const ROUTER_IFACE = new Interface([
|
||||
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
||||
]);
|
||||
|
||||
// The Universal Router deployments on each network, by chain id, copied from
|
||||
// the deployment files Uniswap publishes, each address named by its key there.
|
||||
// The files also list `UnsupportedProtocol`, which is not a router.
|
||||
// The Universal Router releases deployed at the addresses below, each with two
|
||||
// things read from its source at the commit linked for it:
|
||||
//
|
||||
// Beside each address is the mask that router reads a command's type with:
|
||||
// its dispatcher runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits
|
||||
// above the mask are the allow-revert flag (0x80) or ignored. The mask grew
|
||||
// between releases. Each is `COMMAND_TYPE_MASK` in the release's
|
||||
// `contracts/libraries/Commands.sol`, the same file as in the source verified
|
||||
// for that address:
|
||||
// - 0x1f, UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Commands.sol
|
||||
// - 0x3f, UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/libraries/Commands.sol
|
||||
// - 0x3f, UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/libraries/Commands.sol
|
||||
// - 0x7f, UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/libraries/Commands.sol
|
||||
// - 0x7f, UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/libraries/Commands.sol
|
||||
// - `commandTypeMask`, the mask it reads a command's type with: its dispatcher
|
||||
// runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits above the
|
||||
// mask are the allow-revert flag (0x80) or ignored. The mask grew between
|
||||
// releases. Each is `COMMAND_TYPE_MASK` in the release's
|
||||
// `contracts/libraries/Commands.sol`, the same file as in the source
|
||||
// verified for its addresses.
|
||||
// - `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,
|
||||
// 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,
|
||||
// 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,
|
||||
// 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,
|
||||
// The same as UniversalRouterV2's.
|
||||
allowRevert: ROUTER_V2.allowRevert,
|
||||
};
|
||||
|
||||
// The Universal Router deployments on each network, by chain id, copied from
|
||||
// the deployment files Uniswap publishes, each address named by its key there
|
||||
// and given with the release it runs. The files also list
|
||||
// `UnsupportedProtocol`, which is not a router.
|
||||
const ROUTER_ADDRESSES = new Map([
|
||||
[
|
||||
NETWORKS.mainnet.chainId,
|
||||
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json
|
||||
[
|
||||
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1
|
||||
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
|
||||
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2
|
||||
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1
|
||||
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2
|
||||
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", ROUTER_V1], // UniversalRouterV1
|
||||
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support
|
||||
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", ROUTER_V2], // UniversalRouterV2
|
||||
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", ROUTER_V2_1], // UniversalRouterV2_1_1
|
||||
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", ROUTER_V2_1], // UniversalRouterV2_1_2
|
||||
],
|
||||
],
|
||||
[
|
||||
NETWORKS.sepolia.chainId,
|
||||
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json
|
||||
[
|
||||
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
|
||||
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2
|
||||
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1
|
||||
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2
|
||||
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support
|
||||
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", ROUTER_V2], // UniversalRouterV2
|
||||
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", ROUTER_V2_1], // UniversalRouterV2_1_1
|
||||
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", ROUTER_V2_1], // UniversalRouterV2_1_2
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// The mask the Universal Router deployment at `address` on the network with
|
||||
// this chain id reads a command's type with, or null when `address` is not one
|
||||
// of them. Address case is ignored.
|
||||
function routerCommandTypeMask(address, chainId) {
|
||||
// The release that the Universal Router deployment at `address` on the
|
||||
// network with this chain id runs, or null when `address` is not one of them.
|
||||
// Address case is ignored.
|
||||
function routerRelease(address, chainId) {
|
||||
const routers = ROUTER_ADDRESSES.get(chainId) || [];
|
||||
const wanted = (address || "").toLowerCase();
|
||||
const router = routers.find(([a]) => a.toLowerCase() === wanted);
|
||||
return router ? router[1] : null;
|
||||
}
|
||||
|
||||
// The allow-revert flag on a command byte (see ROUTER_V1).
|
||||
const ALLOW_REVERT_FLAG = 0x80;
|
||||
|
||||
// The Uniswap V2 factory and pair init code hash the Universal Routers on each
|
||||
// network were deployed with. A V2 swap step swaps through the pair its path
|
||||
// starts at, which the router finds as `UniswapV2Library.pairFor()` does: the
|
||||
@@ -123,7 +172,7 @@ function v2PairFor(chainId, tokenA, tokenB) {
|
||||
}
|
||||
|
||||
// Universal Router command types, each read from its command byte with the
|
||||
// router's mask (see ROUTER_ADDRESSES). Only the routers with the 0x7f mask
|
||||
// router's mask (see ROUTER_V1). 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.
|
||||
@@ -856,8 +905,9 @@ function decodeV4Swap(input) {
|
||||
// sent to any other contract is not a swap through the router, whatever that
|
||||
// contract does with it.
|
||||
function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
|
||||
if (commandTypeMask === null) return null;
|
||||
const release = routerRelease(toAddress, chainId);
|
||||
if (release === null) return null;
|
||||
const { commandTypeMask, allowRevert } = release;
|
||||
try {
|
||||
const parsed = ROUTER_IFACE.parseTransaction({ data });
|
||||
if (!parsed) return null;
|
||||
@@ -986,6 +1036,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
|
||||
for (let i = 0; i < commandsBytes.length; i++) {
|
||||
const cmdId = commandsBytes[i] & commandTypeMask;
|
||||
// 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 =
|
||||
@@ -1034,11 +1089,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
const b = decodeBalanceCheck(inputs[i]);
|
||||
// The router passes this check whenever the owner holds at
|
||||
// least minBalance, so a zero one guarantees nothing and
|
||||
// does not replace a minimum an earlier step stated. Any
|
||||
// other minBalance sets the output side as a swap does.
|
||||
// It pays nobody.
|
||||
if (b && !(b.minBalance === 0n && present(minOutput))) {
|
||||
setOutput(b.token, b.minBalance);
|
||||
// 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 = b && !allowedToFail ? b.minBalance : 0n;
|
||||
if (b && !(minBalance === 0n && present(minOutput))) {
|
||||
setOutput(b.token, minBalance);
|
||||
}
|
||||
} else if (cmdId === 0x00) {
|
||||
const s = decodeV3SwapExactIn(inputs[i]);
|
||||
@@ -1161,7 +1218,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
payeeUnread = true;
|
||||
}
|
||||
} else if (cmdId === 0x10) {
|
||||
const v4 = decodeV4Swap(inputs[i]);
|
||||
let v4 = decodeV4Swap(inputs[i]);
|
||||
// A step the router lets fail guarantees nothing, so a
|
||||
// minimum it states is read as zero, as a
|
||||
// BALANCE_CHECK_ERC20's is. Only UniversalRouterV1 and
|
||||
// UniversalRouterV1_2_V2Support let this command fail; on
|
||||
// them it is an NFT market call, still read here as a V4
|
||||
// swap (https://git.eeqj.de/sneak/AutistMask/issues/592).
|
||||
if (v4 && allowedToFail && present(v4.amountOutMin)) {
|
||||
v4 = { ...v4, amountOutMin: 0n };
|
||||
}
|
||||
if (v4) {
|
||||
for (const t of v4.takes) {
|
||||
if (!isHop(i, t.currency, t.recipient)) {
|
||||
@@ -1225,6 +1291,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
// The amount of the wallet's own send on the confirmation, wait, success and
|
||||
// error screens (https://git.eeqj.de/sneak/AutistMask/issues/522).
|
||||
//
|
||||
// These screens printed the text from Send's amount field as it was typed or
|
||||
// filled in: `0.5 ETH`, or `1.234147890123456789 ETH` after Max, where the
|
||||
// approval screen shows a site's send as `0.5000 ETH`. They now show it to four
|
||||
// decimal places with the nonzero floor, as the approval screen does, and the
|
||||
// transaction is still signed for the exact amount.
|
||||
//
|
||||
// Driven through the real Send screen, confirmation screen, Sign & Send and
|
||||
// wait screen, with only the node, the explorer and the DOM stubbed.
|
||||
|
||||
"use strict";
|
||||
|
||||
// What the stub node answers, and the signed transactions it was handed. The
|
||||
// token's own decimals() answers 6, as the explorer does.
|
||||
const mockNode = {
|
||||
balanceWei: 0n,
|
||||
tokenUnits: 0n,
|
||||
tokenDecimals: 6n,
|
||||
broadcast: [],
|
||||
broadcastError: null,
|
||||
receipt: null,
|
||||
};
|
||||
|
||||
// The token rows the stub explorer reports for the address.
|
||||
const mockExplorer = { items: [] };
|
||||
|
||||
jest.mock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
// The selector of decimals(); every other contract call is balanceOf().
|
||||
const DECIMALS = "0x313ce567";
|
||||
class StubProvider {
|
||||
async getBalance() {
|
||||
return mockNode.balanceWei;
|
||||
}
|
||||
async call(tx) {
|
||||
return actual.AbiCoder.defaultAbiCoder().encode(
|
||||
["uint256"],
|
||||
[
|
||||
tx.data.startsWith(DECIMALS)
|
||||
? mockNode.tokenDecimals
|
||||
: mockNode.tokenUnits,
|
||||
],
|
||||
);
|
||||
}
|
||||
async lookupAddress() {
|
||||
return null;
|
||||
}
|
||||
// 20 gwei reserved per gas.
|
||||
async getFeeData() {
|
||||
return {
|
||||
maxFeePerGas: 20000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
gasPrice: 10000000000n,
|
||||
};
|
||||
}
|
||||
async estimateGas() {
|
||||
return 21000n;
|
||||
}
|
||||
async getCode() {
|
||||
return "0x";
|
||||
}
|
||||
async getTransactionCount() {
|
||||
return 1;
|
||||
}
|
||||
async getNetwork() {
|
||||
return { chainId: 1n };
|
||||
}
|
||||
async broadcastTransaction(signed) {
|
||||
if (mockNode.broadcastError) throw mockNode.broadcastError;
|
||||
mockNode.broadcast.push(signed);
|
||||
return { hash: "0x" + "ab".repeat(32) };
|
||||
}
|
||||
async getTransactionReceipt() {
|
||||
return mockNode.receipt;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
JsonRpcProvider: StubProvider,
|
||||
Network: { from: () => ({}) },
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
// The explorer's token list, which refreshBalances() also fetches.
|
||||
debugFetch: jest.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockExplorer.items,
|
||||
})),
|
||||
urlOrigin: () => "",
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
// The confirmation screen's Etherscan label lookup is the only fetch() these
|
||||
// screens make; it fails, as it does offline.
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
|
||||
|
||||
// A stub DOM: every id resolves to a recording element.
|
||||
const elements = new Map();
|
||||
|
||||
function makeEl(id) {
|
||||
const handlers = new Map();
|
||||
return {
|
||||
id,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
value: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: {
|
||||
add() {},
|
||||
remove() {},
|
||||
toggle() {},
|
||||
contains: () => false,
|
||||
},
|
||||
handlers,
|
||||
children: [],
|
||||
addEventListener(name, fn) {
|
||||
handlers.set(name, fn);
|
||||
},
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
remove() {},
|
||||
focus() {},
|
||||
// The success screen hides whole sections through .parentElement.
|
||||
get parentElement() {
|
||||
return global.document.getElementById(id + "-parent");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (!elements.has(id)) elements.set(id, makeEl(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
createElement: (tag) => makeEl(tag),
|
||||
body: { prepend() {}, appendChild() {} },
|
||||
addEventListener() {},
|
||||
};
|
||||
global.navigator = { clipboard: { writeText() {} } };
|
||||
|
||||
const {
|
||||
Interface,
|
||||
Transaction,
|
||||
Wallet,
|
||||
getAddress,
|
||||
parseEther,
|
||||
} = require("ethers");
|
||||
const { refreshBalances } = require("../src/shared/balances");
|
||||
const { encryptWithPassword } = require("../src/shared/vault");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { clearPrices } = require("../src/shared/prices");
|
||||
const send = require("../src/popup/views/send");
|
||||
const confirmTx = require("../src/popup/views/confirmTx");
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
|
||||
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
||||
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
|
||||
const GWEI = 1000000000n;
|
||||
// 1.234567890123456789 ETH.
|
||||
const BALANCE_WEI = 1234567890123456789n;
|
||||
|
||||
// A token the bundled list does not know, with enough holders to be listed,
|
||||
// at 6 decimal places.
|
||||
const TOKEN = "0x" + "d".repeat(40);
|
||||
|
||||
const transferIface = new Interface([
|
||||
"function transfer(address to, uint256 amount)",
|
||||
]);
|
||||
|
||||
let encryptedSecret;
|
||||
|
||||
// The confirmation screen Review leads to, once shown.
|
||||
let confirmed = null;
|
||||
|
||||
function el(id) {
|
||||
return global.document.getElementById(id);
|
||||
}
|
||||
|
||||
function text(id) {
|
||||
return el(id).textContent;
|
||||
}
|
||||
|
||||
// show() reads the balance and then estimates the fee without awaiting
|
||||
// either; this lets both settle.
|
||||
async function settle() {
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
// The address holds BALANCE_WEI and, when `tokenUnits` is given, that many
|
||||
// base units of TOKEN, fetched and stored where the popup stores them. Send
|
||||
// is then open for `token` ("ETH" or TOKEN) with the recipient entered.
|
||||
async function openSend(token, tokenUnits = null) {
|
||||
mockNode.balanceWei = BALANCE_WEI;
|
||||
mockNode.tokenUnits = tokenUnits ?? 0n;
|
||||
mockExplorer.items =
|
||||
tokenUnits === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: String(tokenUnits),
|
||||
token: {
|
||||
type: "ERC-20",
|
||||
address_hash: TOKEN,
|
||||
symbol: "TOK",
|
||||
name: "Token",
|
||||
decimals: "6",
|
||||
holders_count: "50000",
|
||||
},
|
||||
},
|
||||
];
|
||||
state.wallets = [
|
||||
{
|
||||
type: "key",
|
||||
name: "Wallet 1",
|
||||
encryptedSecret,
|
||||
addresses: [{ address: HOLDER }],
|
||||
},
|
||||
];
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
await refreshBalances(
|
||||
state.wallets,
|
||||
"https://rpc.example.invalid",
|
||||
"https://blockscout.example/api/v2",
|
||||
state.trackedTokens,
|
||||
"mainnet",
|
||||
);
|
||||
send.init({
|
||||
showConfirmTx: (info) => (confirmed = info),
|
||||
pageClosed: new AbortController().signal,
|
||||
});
|
||||
confirmTx.init({});
|
||||
send.resetSendValidation();
|
||||
state.currentView = "send";
|
||||
state.selectedToken = token;
|
||||
el("send-to").value = RECIPIENT;
|
||||
el("send-amount").value = "";
|
||||
}
|
||||
|
||||
// Press Review and show the confirmation screen with its fee estimate settled.
|
||||
async function review() {
|
||||
await el("btn-send-review").handlers.get("click")();
|
||||
confirmTx.show(confirmed);
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function signAndSend() {
|
||||
el("confirm-tx-password").value = PASSWORD;
|
||||
await el("btn-confirm-send").handlers.get("click")();
|
||||
}
|
||||
|
||||
// The one transaction the stub node was handed.
|
||||
function broadcastTx() {
|
||||
expect(mockNode.broadcast).toHaveLength(1);
|
||||
return Transaction.from(mockNode.broadcast[0]);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
elements.clear();
|
||||
clearPrices();
|
||||
confirmed = null;
|
||||
state.selectedToken = null;
|
||||
state.trackedTokens = [];
|
||||
state.fraudContracts = [];
|
||||
state.currentView = null;
|
||||
mockNode.broadcast = [];
|
||||
mockNode.broadcastError = null;
|
||||
mockNode.receipt = null;
|
||||
txStatus.init({
|
||||
doRefreshAndRender() {},
|
||||
pageClosed: new AbortController().signal,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
txStatus.endWait();
|
||||
});
|
||||
|
||||
describe("the wallet's own send shows its amount to four decimal places", () => {
|
||||
test("for a short amount", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.5";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
|
||||
expect(broadcastTx().value).toBe(parseEther("0.5"));
|
||||
});
|
||||
|
||||
// Send accepts both. The approval screen writes the same value `0.5000`.
|
||||
test.each([".5", "00.5"])("for an amount typed as %s", async (typed) => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = typed;
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("0.5000 ETH");
|
||||
expect(broadcastTx().value).toBe(parseEther("0.5"));
|
||||
});
|
||||
|
||||
test("for a token amount typed as .5", async () => {
|
||||
// 100 TOK.
|
||||
await openSend(TOKEN, 100000000n);
|
||||
el("send-amount").value = ".5";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.5000 TOK");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("0.5000 TOK");
|
||||
const [, amount] = transferIface.decodeFunctionData(
|
||||
"transfer",
|
||||
broadcastTx().data,
|
||||
);
|
||||
expect(amount).toBe(500000n);
|
||||
});
|
||||
|
||||
test("for a Max amount with many decimals", async () => {
|
||||
await openSend("ETH");
|
||||
await el("btn-send-max").handlers.get("click")();
|
||||
// The balance minus 21000 gas at 20 gwei.
|
||||
expect(el("send-amount").value).toBe("1.234147890123456789");
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("1.2341 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("1.2341 ETH");
|
||||
expect(broadcastTx().value).toBe(BALANCE_WEI - 21000n * 20n * GWEI);
|
||||
});
|
||||
|
||||
// Cut to four decimals it would read 0.0000, so it is shown to its first
|
||||
// significant digit instead, and signed in full.
|
||||
test("for a tiny nonzero amount", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.000012345";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.00001 ETH");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("0.00001 ETH");
|
||||
expect(broadcastTx().value).toBe(parseEther("0.000012345"));
|
||||
});
|
||||
|
||||
test("for a token amount", async () => {
|
||||
// 100 TOK.
|
||||
await openSend(TOKEN, 100000000n);
|
||||
el("send-amount").value = "12.345678";
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("12.3456 TOK");
|
||||
|
||||
await signAndSend();
|
||||
expect(text("wait-tx-summary")).toBe("12.3456 TOK");
|
||||
const tx = broadcastTx();
|
||||
expect(tx.to).toBe(getAddress(TOKEN));
|
||||
const [to, amount] = transferIface.decodeFunctionData(
|
||||
"transfer",
|
||||
tx.data,
|
||||
);
|
||||
expect(to).toBe(RECIPIENT);
|
||||
expect(amount).toBe(12345678n);
|
||||
});
|
||||
|
||||
test("on the success screen", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.5";
|
||||
await review();
|
||||
await signAndSend();
|
||||
// A popup opened later resumes the wait, and the receipt is there.
|
||||
mockNode.receipt = { blockNumber: 21000000 };
|
||||
txStatus.restoreWait();
|
||||
await settle();
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
expect(text("success-tx-summary")).toBe("0.5000 ETH");
|
||||
});
|
||||
|
||||
test("on the error screen", async () => {
|
||||
await openSend("ETH");
|
||||
el("send-amount").value = "0.5";
|
||||
await review();
|
||||
mockNode.broadcastError = new Error("the node refused it");
|
||||
await signAndSend();
|
||||
expect(state.currentView).toBe("error-tx");
|
||||
expect(text("error-tx-summary")).toBe("0.5000 ETH");
|
||||
});
|
||||
});
|
||||
@@ -333,7 +333,7 @@ function expectNativeTransferOn(networkId) {
|
||||
"Native " + symbol + " transfer",
|
||||
);
|
||||
expect(el("confirm-amount").textContent).toBe(
|
||||
"1 " + symbol + usdOn(networkId, "$2,000.00"),
|
||||
"1.0000 " + symbol + usdOn(networkId, "$2,000.00"),
|
||||
);
|
||||
expect(el("confirm-balance").textContent).toBe(
|
||||
"5.0000 " + symbol + usdOn(networkId, "$10,000.00"),
|
||||
@@ -649,7 +649,7 @@ describe.each([
|
||||
`href="${explorerUrl}/address/${TOKEN}"`,
|
||||
);
|
||||
expect(el("confirm-amount").textContent).toBe(
|
||||
"10 USDC" + usdOn(reviewedOn, "$10.00"),
|
||||
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
|
||||
);
|
||||
expect(el("confirm-balance").textContent).toBe(
|
||||
"50.0000 USDC" + usdOn(reviewedOn, "$50.00"),
|
||||
|
||||
+2
-1
@@ -2915,9 +2915,10 @@ async function goToTokenConfirm(env) {
|
||||
amount: TOKEN_AMOUNT,
|
||||
});
|
||||
await waitForEstimate(env.page);
|
||||
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
|
||||
const shown = await shownAmount(env.page);
|
||||
assert(
|
||||
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
|
||||
shown === "0.2500 " + STUB_TOKEN.symbol,
|
||||
"the confirmation screen is not showing the amount that was entered: " +
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
|
||||
@@ -245,7 +245,7 @@ describe.each([
|
||||
test("the value", async () => {
|
||||
await confirmSend("0.1");
|
||||
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
|
||||
expect(text("confirm-amount")).toBe("0.1 " + symbol);
|
||||
expect(text("confirm-amount")).toBe("0.1000 " + symbol);
|
||||
await approveTx(NETWORKS[networkId].chainId);
|
||||
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
|
||||
});
|
||||
@@ -429,10 +429,10 @@ describe.each([
|
||||
await global.document
|
||||
.getElementById("btn-confirm-send")
|
||||
.handlers.get("click")();
|
||||
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
|
||||
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
|
||||
state.networkId = activeNetworkId;
|
||||
txStatus.restoreWait();
|
||||
expect(text("wait-tx-summary")).toBe("0.1 " + symbol);
|
||||
expect(text("wait-tx-summary")).toBe("0.1000 " + symbol);
|
||||
});
|
||||
|
||||
test("the transaction detail screen's type line and fee", async () => {
|
||||
|
||||
@@ -477,6 +477,19 @@ const CONTRACT = [
|
||||
views: ["confirm-tx"],
|
||||
restored: false,
|
||||
},
|
||||
// The amount line splits the amount to cut it to four decimals.
|
||||
{
|
||||
value: {
|
||||
pendingTx: {
|
||||
token: "ETH",
|
||||
from: ADDRESS,
|
||||
to: ADDRESS,
|
||||
amount: 42,
|
||||
},
|
||||
},
|
||||
views: ["confirm-tx"],
|
||||
restored: false,
|
||||
},
|
||||
// A chain id naming no network has no explorer to link to.
|
||||
{
|
||||
value: {
|
||||
|
||||
+12
-6
@@ -306,7 +306,8 @@ describe("Max on an ETH send", () => {
|
||||
openSend();
|
||||
await pressMax();
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe(maxAfter(20n * GWEI) + " ETH");
|
||||
expect(confirmed.amount).toBe(maxAfter(20n * GWEI));
|
||||
expect(text("confirm-amount")).toBe("1.2341 ETH");
|
||||
expect(el("confirm-errors").innerHTML).toBe("");
|
||||
expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden");
|
||||
expect(canSend()).toBe(true);
|
||||
@@ -322,14 +323,16 @@ describe("Max on an ETH send", () => {
|
||||
// Kept, the Send screen's amount would be refused for want of funds.
|
||||
mockNode.feeData = fees(30n * GWEI);
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe(maxAfter(30n * GWEI) + " ETH");
|
||||
expect(confirmed.amount).toBe(maxAfter(30n * GWEI));
|
||||
expect(text("confirm-amount")).toBe("1.2339 ETH");
|
||||
expect(canSend()).toBe(true);
|
||||
|
||||
// And falls when the screen is shown again, as on reopening the popup.
|
||||
mockNode.feeData = fees(10n * GWEI);
|
||||
confirmTx.restore();
|
||||
await settle();
|
||||
expect(text("confirm-amount")).toBe(maxAfter(10n * GWEI) + " ETH");
|
||||
expect(confirmed.amount).toBe(maxAfter(10n * GWEI));
|
||||
expect(text("confirm-amount")).toBe("1.2343 ETH");
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -526,7 +529,8 @@ describe("Max on an ETH send", () => {
|
||||
el("send-amount").handlers.get("input")();
|
||||
mockNode.feeData = fees(30n * GWEI);
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("0.5 ETH");
|
||||
expect(confirmed.amount).toBe("0.5");
|
||||
expect(text("confirm-amount")).toBe("0.5000 ETH");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -540,7 +544,8 @@ describe("Max on a token send", () => {
|
||||
await pressMax();
|
||||
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
|
||||
expect(confirmed.amount).toBe("1234.567890123456789012");
|
||||
expect(text("confirm-amount")).toBe("1234.5678 TOK");
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -553,7 +558,8 @@ describe("Max on a token send", () => {
|
||||
await pressMax();
|
||||
expect(el("send-amount").value).toBe("1234.567890123456789012");
|
||||
await review();
|
||||
expect(text("confirm-amount")).toBe("1234.567890123456789012 TOK");
|
||||
expect(confirmed.amount).toBe("1234.567890123456789012");
|
||||
expect(text("confirm-amount")).toBe("1234.5678 TOK");
|
||||
expect(canSend()).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
+15
-7
@@ -987,13 +987,23 @@ describe("uniswap decoder", () => {
|
||||
);
|
||||
|
||||
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
|
||||
// not read. With the allow-revert flag (0x80) the command byte is 0xa1.
|
||||
// not read. With the allow-revert flag (0x80) the command byte is 0xa1,
|
||||
// and the router lets the step fail
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
|
||||
test.each([
|
||||
{ flag: "without", command: 0x21 },
|
||||
{ flag: "with", command: 0xa1 },
|
||||
{
|
||||
flag: "without",
|
||||
command: 0x21,
|
||||
steps: "Execute Sub-Plan (its steps are not shown)",
|
||||
},
|
||||
{
|
||||
flag: "with",
|
||||
command: 0xa1,
|
||||
steps: "Execute Sub-Plan (its steps are not shown) (allowed to fail)",
|
||||
},
|
||||
])(
|
||||
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
|
||||
({ command }) => {
|
||||
({ command, steps }) => {
|
||||
const subPlan = encodeSubPlan("0x00", [
|
||||
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
|
||||
USDT_ADDR,
|
||||
@@ -1013,9 +1023,7 @@ describe("uniswap decoder", () => {
|
||||
USER_ADDR,
|
||||
);
|
||||
expect(result.name).toBe("Uniswap Swap");
|
||||
expect(detail(result, "Steps").value).toBe(
|
||||
"Execute Sub-Plan (its steps are not shown)",
|
||||
);
|
||||
expect(detail(result, "Steps").value).toBe(steps);
|
||||
// The sub-plan names both currencies, so the lines must not say
|
||||
// the calldata did not.
|
||||
expect(detail(result, "Token In").value).toBe(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// The address a swap's output is paid to
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/594 and
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/599).
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/599), and the allow-revert flag
|
||||
// on the steps that pay it
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
|
||||
//
|
||||
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
|
||||
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
|
||||
@@ -149,6 +151,20 @@ function transfer(command, token, recipient, amount) {
|
||||
];
|
||||
}
|
||||
|
||||
// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend.
|
||||
function permit2Permit(token, amount) {
|
||||
return [
|
||||
0x0a,
|
||||
coder.encode(
|
||||
[
|
||||
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
|
||||
"bytes",
|
||||
],
|
||||
[[[token, amount, 0, 0], ROUTER, 0n], "0x"],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function permit2TransferFrom(token, recipient, amount) {
|
||||
return [
|
||||
0x02,
|
||||
@@ -1362,6 +1378,342 @@ 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", () => {
|
||||
// UniversalRouterV1_2_V2Support on Ethereum Mainnet.
|
||||
const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
|
||||
// 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";
|
||||
|
||||
// `step` with the allow-revert flag set on its command byte.
|
||||
const flagged = ([command, input]) => [command | 0x80, input];
|
||||
// `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)",
|
||||
);
|
||||
});
|
||||
|
||||
// On these two routers 0x10 is an NFT market call the router lets fail,
|
||||
// still read as a V4 swap
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/592).
|
||||
test.each([
|
||||
{ router: "UniversalRouterV1", address: ROUTER_V1 },
|
||||
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
|
||||
])(
|
||||
"on $router, a V4_SWAP with the flag is allowed to fail and states no minimum",
|
||||
({ address }) => {
|
||||
const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC));
|
||||
|
||||
const plain = decode(execute(step), USER, address);
|
||||
expect(detail(plain, "Steps").value).toBe("V4 Swap");
|
||||
expect(detail(plain, "Min. received").value).toBe("1990.0000 USDC");
|
||||
|
||||
const withFlag = decode(execute(flagged(step)), USER, address);
|
||||
expect(detail(withFlag, "Steps").value).toBe(
|
||||
"V4 Swap (allowed to fail)",
|
||||
);
|
||||
expect(detail(withFlag, "Token Out").address).toBe(USDC);
|
||||
expect(detail(withFlag, "Min. received").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: "Command 0x1c",
|
||||
},
|
||||
{
|
||||
router: "UniversalRouterV1_2_V2Support",
|
||||
address: ROUTER_V1_2,
|
||||
command: "ELEMENT_MARKET",
|
||||
id: 0x1e,
|
||||
name: "Command 0x1e",
|
||||
},
|
||||
{
|
||||
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)",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The DOM stub both screens are drawn into.
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
|
||||
Reference in New Issue
Block a user