2 Commits
Author SHA1 Message Date
sneak 33b97f135f fix: a send the confirmation screen refuses moves nothing on it (closes #583)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Sign & Send wrote its refusals, a network changed since review and a gas limit or fee over its bound, into the errors box above the password field. That box reserves one empty line, so a refusal grew it and pushed the password field and Sign & Send down.

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

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

Model: opus-5-5
2026-10-19 14:03:52 +02:00
11 changed files with 1321 additions and 189 deletions
+83 -40
View File
@@ -430,6 +430,11 @@ 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 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
@@ -1793,6 +1798,9 @@ 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
- Two more reserved error boxes, made visible when "Sign & Send" refuses a
send because the active network changed or because of its gas limit or fee
(see Transitions), so the refusal moves nothing
- Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors, while the balance is being read,
@@ -1810,7 +1818,7 @@ view would leave a wallet one click from deletion.
- "Sign & Send" after the active network was switched in Settings since this
transaction was reviewed → nothing is signed; "The active network changed
after this transaction was reviewed, so it was not sent. Please go back
and review it again." in the errors box, no screen change. After a switch,
and review it again." in its own box, no screen change. After a switch,
"Back" from Settings draws this screen again from the saved transaction,
as a popup reopened onto it does; when Send is beneath it on the stack, as
when Send showed it in the same popup, "Back" lands below Send instead
@@ -1824,6 +1832,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
@@ -2955,45 +2970,73 @@ 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. 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` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's
sender. The router reads `address(1)` as the transaction's sender, and
`address(2)` as the router itself, which holds what it is paid until a later
step pays it out; a payment to the router, as `address(2)` or by its own
address, is not counted as paid out. When a swap step or `TAKE` pays the
router the swap's output token, the output stays in the router unless a later
`SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out.
A swap step or `TAKE` 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. 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), no step pays any other address, no step that
may pay an address went unread, and the output does not stay in the router.
Otherwise it reads `Min. paid out`, a `Paid to` line after `Token Out` names
each other address paid, one reads
`The router, where it stays (no later step pays it out)` when the output stays
in the router, and one more reads `Unknown (could not be decoded)` when such a
step went unread, or `Unknown (not named in the calldata)` when no step pays
anyone. A step goes unread when its recipient does not decode as an address,
or when it is a command or V4 action the decoder does not read: every command
other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and
`EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`.
(`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`
line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `PERMIT2_TRANSFER_FROM` (which
moves the user's own tokens) and V4 `TAKE` and `TAKE_PORTION` names the
address it pays, and `TAKE_ALL` pays the transaction's sender. The router
reads `address(1)` as the transaction's sender, and `address(2)` as the router
itself, which holds what it is paid until a later step pays it out; a payment
to the router, as `address(2)` or by its own address, is not counted as paid
out. `UniversalRouterV1` alone pays a `PERMIT2_TRANSFER_FROM` to `address(1)`
or `address(2)` as those addresses, and the decoder names them. A
`PERMIT2_TRANSFER_FROM` into the router is named with the amount it moves,
because the decoder does not check that later steps spend all of it.
`ACROSS_V4_DEPOSIT_V3` pays the bridge, which pays the deposit's recipient on
another network and refunds a deposit nobody fills to its depositor; the
router passes both on as written, so `address(1)` and `address(2)` there are
addresses, and each that is not the transaction's sender is named. When a swap
step, `TAKE` or `TAKE_PORTION` pays the router the swap's output token, the
output stays in the router unless a later step pays all the router holds of it
to anyone but the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is
WETH, a `TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a
`PAY_PORTION_FULL_PRECISION` of 1e18 of that token, or an
`ACROSS_V4_DEPOSIT_V3` of `CONTRACT_BALANCE` of that token, or of ETH when it
sets `useNative`. The router sends ETH as written, so a `TRANSFER` of
`CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
token to the V2 pair that the very next step spends, as a V2 exact-in of that
token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
the router enforces that swap's own minimum. The decoder checks that the
address paid is that pair, deriving it from the V2 factory and pair init code
hash the network's routers were deployed with and the first two tokens of that
step's path; a payment to any other address is named. (Sepolia's
`UniversalRouterV1_2_V2Support` was deployed with placeholders for both
values, so no V2 swap step can succeed on that router.) Every other payment
counts, in whichever step and of whatever token, a share of the output
included: an interface fee paid to another address by `PAY_PORTION`,
`TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
because the swap's minimum includes the fee. The minimum reads `Min. received`
only when some step pays the transaction's sender (the From address on
TxApproval, the sender the explorer reports on TransactionDetail) and no
`Paid to` line is shown. `Paid to` lines after `Token Out` name each other
address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
for each move into the router, followed by a `Token moved into the router`
line that gives the moved token's address as the `Token In` line does; one
reads `The router, where it stays (no later step pays it out)` 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
+19
View File
@@ -697,6 +697,25 @@
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, 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-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
+29 -30
View File
@@ -291,9 +291,12 @@ function show(txInfo) {
renderValidation(txInfo);
// Reset password field and error
// Reset password field and error, and any refusal of an earlier Sign &
// Send.
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
setVisible("confirm-network-changed-error", false);
setVisible("confirm-fee-bound-error", false);
// Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible";
@@ -556,16 +559,17 @@ async function estimateGas(txInfo) {
}
}
// Populate the transaction this send describes, enforce the fee bound against
// the fees that were actually filled in, then sign and broadcast it. Apart
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
// what the configured RPC node answers, with nothing otherwise bounding what a
// hostile node can set — the dApp path's ceilings never reached this one.
// Populating before the check is what makes assertWithinCeilings() see the
// same numbers that would be signed; it throws an ApprovalMismatchError when
// the product gasLimit × maxFeePerGas is over the bound, which the caller
// shows in the reserved error area rather than sending.
// Populate the transaction this send describes, enforce the bounds against the
// gas limit and fees that were actually filled in, then sign and broadcast it.
// Apart from a max ETH send, which passes its estimate's fee fields as `fees`,
// the send pins no fee fields, so ethers fills maxFeePerGas and the gas limit
// from what the configured RPC node answers, with nothing otherwise bounding
// what a hostile node can set — the dApp path's ceilings never reached this
// one. Populating before the check is what makes assertWithinCeilings() see
// the same numbers that would be signed; it throws an ApprovalMismatchError
// when the gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
// over its bound, and the caller then shows the one refusal reserved for all
// three rather than sending.
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request;
if (tx.token === "ETH") {
@@ -593,15 +597,6 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
return connectedSigner.sendTransaction(populated);
}
// Show a full-sentence send failure in the reserved errors box, the same
// element and markup renderValidation() uses for the invalid-amount message,
// so it never moves anything on the screen.
function showSendError(message) {
const el = $("confirm-errors");
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
el.style.visibility = "visible";
}
async function checkRecipientHistory(txInfo) {
try {
const provider = reviewedNetworkProvider(txInfo);
@@ -640,6 +635,10 @@ 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-fee-bound-error", false);
const wallet = state.wallets[state.selectedWallet];
// Every Send button refuses a defective wallet before this screen,
// but the popup also reopens onto it from a saved view.
@@ -654,11 +653,7 @@ function init(_ctx) {
// Settings after a switch draws this screen again from the saved
// transaction, as a popup reopened onto it does.
if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
setVisible("confirm-network-changed-error", true);
return;
}
@@ -722,12 +717,16 @@ function init(_ctx) {
txStatus.showWait(pendingTx, tx.hash);
} catch (e) {
decryptedSecret = null;
// A fee over the bound is refused before anything is broadcast, so
// there is no transaction that may have reached the network to warn
// about: the message stays on the confirmation screen where the
// user can go back, rather than routing to the sent/failed screen.
// A gas limit or fee over its bound is refused before anything is
// broadcast, so there is no transaction that may have reached the
// network to warn about: the refusal stays on the confirmation
// screen where the user can go back, rather than routing to the
// sent/failed screen. Its sentence is fixed in index.html, so that
// its box can hold its space from the first paint; the reason in
// full, naming the bound, is logged.
if (e && e.approvalMismatch) {
showSendError(e.message);
log.errorf("send refused:", e.message);
setVisible("confirm-fee-bound-error", true);
return;
}
const hash = tx ? tx.hash : null;
+274 -49
View File
@@ -123,7 +123,10 @@ function v2PairFor(chainId, tokenA, tokenB) {
}
// Universal Router command types, each read from its command byte with the
// router's mask (see ROUTER_ADDRESSES)
// 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 = {
0x00: "V3 Swap (Exact In)",
0x01: "V3 Swap (Exact Out)",
@@ -132,6 +135,7 @@ const COMMAND_NAMES = {
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",
@@ -145,6 +149,7 @@ const COMMAND_NAMES = {
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,
@@ -175,6 +180,11 @@ const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
// The Paid to line for an ACROSS_V4_DEPOSIT_V3 step (see
// decodeAcrossV4DepositV3()).
const PAID_TO_BRIDGE =
"The bridge, which pays the deposit's recipient on another network";
// Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
@@ -251,9 +261,11 @@ const WETH_ADDRESSES = [
// Two recipient values the router reads as placeholders, not as addresses:
// MSG_SENDER pays the caller of execute(), which is the transaction's sender,
// and ADDRESS_THIS pays the router itself, which holds what it is paid until a
// later step pays it out. The swap commands, SWEEP, WRAP_ETH and UNWRAP_WETH
// map their recipient through the dispatcher's map(), and a V4 TAKE through
// V4Router's _mapRecipient(); TAKE_ALL always pays the caller.
// later step pays it out. The swap commands, SWEEP, WRAP_ETH, UNWRAP_WETH,
// TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION and, on every router but
// UniversalRouterV1, PERMIT2_TRANSFER_FROM map their recipient through the
// dispatcher's map(), and a V4 TAKE and TAKE_PORTION through V4Router's
// _mapRecipient(); TAKE_ALL always pays the caller.
// - constants: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Constants.sol
// - map(), UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Recipient.sol
// - map(), UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol
@@ -426,6 +438,84 @@ function decodeSweep(input) {
}
}
// TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION and PERMIT2_TRANSFER_FROM
// read their parameters as each release's dispatcher reads them:
// - UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/base/Dispatcher.sol
// - 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: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/base/Dispatcher.sol
// - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/base/Dispatcher.sol
// 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.
// - 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)
function decodeTransfer(input) {
try {
const d = coder.decode(["address", "address", "uint256"], input);
return { token: d[0], recipient: d[1], amount: d[2] };
} catch {
return null;
}
}
// Decode PERMIT2_TRANSFER_FROM (command 0x02) input bytes. Permit2 moves
// `amount` of `token` from the transaction's sender to the recipient.
// UniversalRouterV1 hands Permit2 the recipient as written, without map(), so
// on that router address(1) and address(2) are paid as those addresses.
// ABI: (address token, address recipient, uint160 amount)
function decodePermit2TransferFrom(input) {
try {
const d = coder.decode(["address", "address", "uint160"], input);
return { token: d[0], recipient: d[1], amount: d[2] };
} catch {
return null;
}
}
// Decode ACROSS_V4_DEPOSIT_V3 (command 0x40) input bytes. The router hands
// the bridge `inputAmount` of `inputToken`, or all it holds of it when that is
// CONTRACT_BALANCE; with `useNative` set it hands over its ETH instead. It
// passes `depositor` and `recipient` on as written, without map(). The bridge
// pays the recipient on another network what the deposit states, and refunds
// a deposit nobody fills to the depositor, who may also change the deposit's
// recipient.
// - UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/modules/ChainedActions.sol
// - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/ChainedActions.sol
// - parameters, the same at both commits: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/interfaces/IUniversalRouter.sol
// - the bridge's depositV3() and its refund to the depositor: https://github.com/across-protocol/contracts/blob/950e9154f3d61e59590eb7d4403fae5d3cae84f8/contracts/spoke-pools/SpokePool.sol
// ABI: (tuple(address depositor, address recipient, address inputToken,
// address outputToken, uint256 inputAmount, uint256 outputAmount,
// uint256 destinationChainId, address exclusiveRelayer,
// uint32 quoteTimestamp, uint32 fillDeadline,
// uint32 exclusivityDeadline, bytes message, bool useNative))
function decodeAcrossV4DepositV3(input) {
try {
const d = coder.decode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
input,
);
return {
depositor: d[0][0],
recipient: d[0][1],
inputToken: d[0][2],
inputAmount: d[0][4],
useNative: d[0][12],
};
} catch {
return null;
}
}
// Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the
// recipient.
// ABI: (address recipient, uint256 amount)
@@ -615,9 +705,11 @@ function totalAmount(amounts) {
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name.
//
// `takes` is every TAKE and TAKE_ALL, as the currency it takes and its
// recipient as written, MSG_SENDER for a TAKE_ALL. `recipientUnread` is set
// when the step has a payout this decoder did not read.
// `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// a share of the currency, so it does not name the output side as TAKE does.
// `recipientUnread` is set when the step has a payout this decoder did not
// read.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -663,6 +755,13 @@ function decodeV4Swap(input) {
const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0];
takes.push({ currency: t[0], recipient: MSG_SENDER });
} else if (actionId === V4_TAKE_PORTION) {
// TAKE_PORTION: (address currency, address recipient, uint256 bips)
const t = coder.decode(
["address", "address", "uint256"],
params[i],
);
takes.push({ currency: t[0], recipient: t[1] });
} else if (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN ||
@@ -674,17 +773,18 @@ function decodeV4Swap(input) {
} catch {
swapUndecoded = true;
}
} else if (actionId === V4_TAKE_PORTION) {
// It pays a share of a currency to an address this decoder
// does not read
// (https://git.eeqj.de/sneak/AutistMask/issues/599).
// V4Router reverts on any action not named here.
recipientUnread = true;
}
// V4Router reverts on any action not named above, so none
// of them pays anyone.
} catch {
// Skip sub-actions we can't decode. A TAKE or TAKE_ALL among
// them is a payout this decoder did not read.
if (actionId === V4_TAKE || actionId === V4_TAKE_ALL) {
// Skip sub-actions we can't decode. A TAKE, TAKE_ALL or
// TAKE_PORTION among them is a payout this decoder did not
// read.
if (
actionId === V4_TAKE ||
actionId === V4_TAKE_ALL ||
actionId === V4_TAKE_PORTION
) {
recipientUnread = true;
}
}
@@ -824,31 +924,47 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const paidElsewhere = [];
// Set when a step may pay an address this decoder did not read.
let payeeUnread = false;
// The tokens, lowercased, that a swap step or a V4 TAKE paid to the
// router and no later SWEEP of that token, or UNWRAP_WETH of WETH,
// paid out.
// Set when an ACROSS_V4_DEPOSIT_V3 step pays the bridge.
let bridgeDeposit = false;
// What each PERMIT2_TRANSFER_FROM moved from the sender into the
// router, as { token, amount }. Later steps may leave part of it
// there: nothing here checks that they spend all of it.
const movedIntoRouter = [];
// The tokens, lowercased, that a swap step or a V4 TAKE or
// TAKE_PORTION paid to the router and no later step paid out of it in
// full: a SWEEP of that token, an UNWRAP_WETH of WETH, or a TRANSFER,
// PAY_PORTION, PAY_PORTION_FULL_PRECISION or ACROSS_V4_DEPOSIT_V3 of
// all the router holds of that token.
const heldByRouter = new Set();
const isRouter = (recipient) => {
const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
};
// A step pays `recipient`, an address other than the sender's or the
// router's, as written.
const addPaidElsewhere = (recipient) => {
const r = recipient.toLowerCase();
if (!paidElsewhere.some((a) => a.toLowerCase() === r)) {
paidElsewhere.push(recipient);
}
};
// A step pays `recipient`. `output` is the token a swap step or a V4
// TAKE pays; other steps pass none.
// TAKE or TAKE_PORTION pays; other steps pass none.
const addPayee = (recipient, output) => {
const r = recipient.toLowerCase();
if (isRouter(recipient)) {
if (present(output)) heldByRouter.add(output.toLowerCase());
} else if (r === MSG_SENDER || r === sender) {
paidToSender = true;
} else if (!paidElsewhere.some((a) => a.toLowerCase() === r)) {
paidElsewhere.push(recipient);
} else {
addPaidElsewhere(recipient);
}
};
// A swap step or V4 TAKE that pays `token` to `recipient` is a hop,
// not a payout, when the very next step is a V2 exact-in whose input
// is already paid, whose path starts at `token`, and whose pair (see
// v2PairFor()) is `recipient`: Uniswap's mixed routes pay the next V2
// pair directly, and the router enforces that next swap's own minimum.
// A step that pays `token` to `recipient` is a hop, not a payout,
// when the very next step is a V2 exact-in whose input is already
// paid, whose path starts at `token`, and whose pair (see v2PairFor())
// is `recipient`: Uniswap's mixed routes pay the next V2 pair
// directly, and the router enforces that next swap's own minimum.
// Its input is already paid when its amountIn is ALREADY_PAID, or
// CONTRACT_BALANCE, which Uniswap's routes send and with which the
// router adds only what it holds.
@@ -870,9 +986,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask;
// Only the routers with the 0x7f mask have command 0x07 (see
// COMMAND_NAMES).
const name =
cmdId === 0x07 && commandTypeMask !== 0x7f
? null
: COMMAND_NAMES[cmdId];
commandNames.push(
COMMAND_NAMES[cmdId] ||
"Command 0x" + cmdId.toString(16).padStart(2, "0"),
name || "Command 0x" + cmdId.toString(16).padStart(2, "0"),
);
try {
@@ -890,6 +1011,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// input side an earlier hop established without breaking
// the invariant.
if (p) setInput(p.token, p.amount);
} else if (cmdId === 0x02) {
const p = decodePermit2TransferFrom(inputs[i]);
if (!p) {
payeeUnread = true;
} else if (
commandTypeMask === 0x1f &&
[MSG_SENDER, ADDRESS_THIS].includes(
p.recipient.toLowerCase(),
)
) {
// UniversalRouterV1, the router with the 0x1f mask,
// pays address(1) and address(2) as written (see
// decodePermit2TransferFrom()).
addPaidElsewhere(p.recipient);
} else if (isRouter(p.recipient)) {
movedIntoRouter.push(p);
} else if (!isHop(i, p.token, p.recipient)) {
addPayee(p.recipient);
}
} else if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at
@@ -948,6 +1088,67 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
payeeUnread = true;
}
} else if (
cmdId === 0x05 ||
cmdId === 0x06 ||
(cmdId === 0x07 && commandTypeMask === 0x7f)
) {
const t = decodeTransfer(inputs[i]);
if (t) {
if (!isHop(i, t.token, t.recipient)) {
addPayee(t.recipient);
}
// The amount with which TRANSFER, PAY_PORTION or
// PAY_PORTION_FULL_PRECISION pays all the router
// holds of the token, as SWEEP does.
const all =
cmdId === 0x05
? CONTRACT_BALANCE
: 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 =
cmdId === 0x05 &&
t.token ===
"0x0000000000000000000000000000000000000000";
if (
t.amount === all &&
!ethTransfer &&
!isRouter(t.recipient)
) {
heldByRouter.delete(t.token.toLowerCase());
}
} else {
payeeUnread = true;
}
} else if (cmdId === 0x40) {
// Only the routers with the 0x7f mask read a command type
// of 0x40.
const a = decodeAcrossV4DepositV3(inputs[i]);
if (a) {
bridgeDeposit = true;
// The bridge pays or refunds these as written, so
// only the sender's own address is the sender.
for (const r of [a.recipient, a.depositor]) {
if (r.toLowerCase() !== sender) {
addPaidElsewhere(r);
}
}
// A deposit of CONTRACT_BALANCE pays all the router
// holds of its token, or of ETH with useNative set.
if (a.inputAmount === CONTRACT_BALANCE) {
heldByRouter.delete(
a.useNative
? "0x0000000000000000000000000000000000000000"
: a.inputToken.toLowerCase(),
);
}
} else {
payeeUnread = true;
}
} else if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]);
if (w) {
@@ -1016,9 +1217,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
// Any other command may pay an address this decoder does
// not read: V3_SWAP_EXACT_OUT does, and so do TRANSFER,
// PAY_PORTION and the others
// https://git.eeqj.de/sneak/AutistMask/issues/599 lists.
// not read: V3_SWAP_EXACT_OUT and
// PERMIT2_TRANSFER_FROM_BATCH among them.
payeeUnread = true;
}
} catch {
@@ -1169,30 +1369,55 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
}
// The output is received by the sender only when some step pays the
// sender, none pays another address, none may pay an address this
// decoder did not read, and the output token is not left in the
// router. Otherwise each other address paid is named, and further
// lines say when the output stays in the router, a payout could not
// be read, or no step names anyone paid.
// A Paid to line names each other address paid, and says when a
// step pays the bridge, when one moves the sender's tokens into the
// router, when the output stays in the router, and when a payout
// could not be read. The output is received by the sender only when
// some step pays the sender and there is no such line. When no step
// names anyone paid, one line says so. `paidTo` holds these lines and
// the token line after each move into the router.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const received =
paidToSender &&
paidElsewhere.length === 0 &&
!payeeUnread &&
!outputHeld;
for (const address of paidElsewhere) {
details.push({ label: "Paid to", value: address, address });
const paidTo = paidElsewhere.map((address) => ({
label: "Paid to",
value: address,
address,
}));
if (bridgeDeposit) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
// Each move into the router is followed by a line giving the moved
// token's address, in the shape of the Token In line, so the screens
// show it as they show that one.
for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources);
paidTo.push({
label: "Paid to",
value:
"The router, " +
amountText(m.amount, info).display +
" 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) {
details.push({ label: "Paid to", value: STAYS_IN_ROUTER });
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
}
if (payeeUnread) {
details.push({ label: "Paid to", value: UNDECODED_CURRENCY });
} else if (!paidToSender && paidElsewhere.length === 0 && !outputHeld) {
details.push({ label: "Paid to", value: UNNAMED_CURRENCY });
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
}
const received = paidToSender && paidTo.length === 0;
if (!paidToSender && paidTo.length === 0) {
paidTo.push({ label: "Paid to", value: UNNAMED_CURRENCY });
}
details.push(...paidTo);
if (present(minOutput)) {
// A zero floor is the one case the user most needs stated: the
+167 -6
View File
@@ -11,20 +11,115 @@
//
// The check is driven here with a fake connected signer rather than a real
// one: populateTransaction() returns the fees the node would have produced,
// and sendTransaction() records whether the send actually happened. The real
// DOM path around it — reading the fee error into the reserved errors box — is
// covered by the Chrome e2e suite.
// and sendTransaction() records whether the send actually happened.
//
// The last test drives the real confirmation screen instead, with only the
// node and the DOM stubbed: a gas limit over its own bound at an ordinary fee
// is refused in the same box as a fee over the bound
// (https://git.eeqj.de/sneak/AutistMask/issues/583). That the box moves
// nothing on the screen is checked by the Chrome e2e suite.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
// What the stub node quotes for that test: a gas limit of 150,000,000 at a
// maximum fee of 2 gwei per gas, 0.3 ETH in all. And the signed transactions
// it was handed.
const mockNode = {
gasLimit: 150000000n,
maxFeePerGas: 2000000000n,
broadcast: [],
};
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.network = network;
}
async getNetwork() {
return this.network;
}
async getBalance() {
return actual.parseEther("5");
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async getFeeData() {
return {
maxFeePerGas: mockNode.maxFeePerGas,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 1000000000n,
};
}
async estimateGas() {
return mockNode.gasLimit;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
// The screens a send that was not refused goes on to.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
querySelectorAll: () => [],
remove() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
};
const { Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const { populateVerifyAndSend } = confirmTx;
const {
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify");
@@ -98,3 +193,69 @@ describe("populateVerifyAndSend enforces the combined fee bound", () => {
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
});
});
describe("Sign & Send on the confirmation screen", () => {
const PRIVATE_KEY = "0x" + "11".repeat(32);
const PASSWORD = "correct horse battery staple";
let encryptedSecret;
beforeAll(async () => {
await loadState();
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
function el(id) {
return global.document.getElementById(id);
}
function shown(id) {
return el(id).style.visibility === "visible";
}
// show() reads the balance and estimates the fee without awaiting
// either; this lets both finish.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
test("refuses a gas limit over its bound at an ordinary fee in the box for a refused gas limit or fee, and sends nothing", async () => {
expect(mockNode.gasLimit).toBeGreaterThan(MAX_GAS_LIMIT);
expect(mockNode.gasLimit * mockNode.maxFeePerGas).toBeLessThan(
MAX_TOTAL_FEE,
);
const from = new Wallet(PRIVATE_KEY).address;
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: from }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.init({});
confirmTx.show({
from,
to: RECIPIENT,
ensName: null,
amount: "1",
token: "ETH",
tokenSymbol: null,
tokenDecimals: null,
max: false,
});
await settle();
expect(el("btn-confirm-send").disabled).toBe(false);
expect(shown("confirm-fee-bound-error")).toBe(false);
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
expect(shown("confirm-fee-bound-error")).toBe(true);
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
});
});
+5 -18
View File
@@ -440,12 +440,8 @@ describe.each([
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").style.visibility).toBe("visible");
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
expect(shown("confirm-network-changed-error")).toBe(true);
expect(shown("confirm-fee-bound-error")).toBe(false);
});
},
);
@@ -497,10 +493,7 @@ describe.each([
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
expect(shown("confirm-network-changed-error")).toBe(true);
});
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => {
@@ -579,10 +572,7 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(errors()).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
expect(shown("confirm-network-changed-error")).toBe(true);
});
const tokenSend = {
@@ -629,10 +619,7 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(errors()).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
expect(shown("confirm-network-changed-error")).toBe(true);
});
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => {
+7 -4
View File
@@ -226,8 +226,9 @@ const RPC_RESULTS = {
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the
// reserve and the estimate collapse to the same number, and the gate tests
// stop being able to distinguish them.
function latestBlock() {
// stop being able to distinguish them. opts.baseFeeWei replaces its base fee,
// read at request time like every other fixture switch.
function latestBlock(opts) {
return {
hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32),
@@ -239,7 +240,7 @@ function latestBlock() {
gasUsed: "0xf4240",
miner: STUB_COUNTERPARTY,
extraData: "0x",
baseFeePerGas: hex(BASE_FEE_WEI),
baseFeePerGas: opts.baseFeeWei || hex(BASE_FEE_WEI),
transactions: [],
};
}
@@ -481,7 +482,7 @@ function rpcReply(req, opts, report) {
});
}
if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() });
return Object.assign(envelope, { result: latestBlock(opts) });
}
// The end of the dApp transaction round trip: the raw signed transaction
// the background hands to the node. It is recorded verbatim so a test can
@@ -625,6 +626,8 @@ function traceEnabled(raw) {
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
* defaults to zero, which is what every test that predates the funded
* fixture expects.
* @param {string} [opts.baseFeeWei] hex wei answered as the latest block's
* baseFeePerGas; defaults to BASE_FEE_WEI. Read at request time.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal.
* @param {boolean} [opts.holdBalance] hold every batch containing an
+96 -1
View File
@@ -2232,7 +2232,8 @@ function feeEth(wei) {
// What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, how tall the whole view is, and where Sign & Send sits in it.
// fee block says, how tall the whole view is, and where the password field and
// Sign & Send sit in it.
async function confirmState(page) {
return page.evaluate(() => {
const el = (id) => document.getElementById(id);
@@ -2245,6 +2246,9 @@ async function confirmState(page) {
};
return {
height: el("view-confirm-tx").getBoundingClientRect().height,
passwordTop:
el("confirm-tx-password").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
sendTop:
el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
@@ -2262,6 +2266,12 @@ async function confirmState(page) {
amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"),
// Its sentence is wrapped across lines in index.html.
feeBoundError: shown("confirm-fee-bound-error")
? el("confirm-fee-bound-error")
.textContent.replace(/\s+/g, " ")
.trim()
: "",
sendDisabled: el("btn-confirm-send").disabled,
};
});
@@ -3057,6 +3067,91 @@ 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);
}
});
// ------------------------------ the wait for a receipt ending in error (#315)
//
// README.md (WaitTx) documents two ways the wait ends on the error screen: a
+3 -3
View File
@@ -313,9 +313,9 @@ describe("Back after a switch from the Settings gear", () => {
boot.node("confirm-tx-password").value = "any password";
await boot.click("btn-confirm-send");
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
expect(boot.node("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was reviewed",
);
expect(
boot.node("confirm-network-changed-error").style.visibility,
).toBe("visible");
// Refused before the password is even tried.
expect(boot.text("confirm-tx-password-error")).toBe("");
});
+22 -1
View File
@@ -954,7 +954,28 @@ describe("uniswap decoder", () => {
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x40",
steps: "Command 0x40",
steps: "Across Bridge Deposit",
},
// Only the routers with the 0x7f mask have 0x07, as
// PAY_PORTION_FULL_PRECISION; the others revert on it
// (https://git.eeqj.de/sneak/AutistMask/issues/599).
{
router: "UniversalRouterV1",
address: ROUTER_V1_ADDR,
command: "0x07",
steps: "Command 0x07",
},
{
router: "UniversalRouterV2",
address: ROUTER_ADDR,
command: "0x07",
steps: "Command 0x07",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x07",
steps: "Pay Portion (Full Precision)",
},
])(
"command $command sent to $router is listed as $steps",
+616 -37
View File
@@ -1,12 +1,14 @@
// The address a swap's output is paid to
// (https://git.eeqj.de/sneak/AutistMask/issues/594).
// (https://git.eeqj.de/sneak/AutistMask/issues/594 and
// https://git.eeqj.de/sneak/AutistMask/issues/599).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH and V4 TAKE names the
// address it pays, and TAKE_ALL pays the sender. The decoder read none of
// them: a swap whose output went to another address still read "Min.
// received", as if the user received it. The router reads address(1) as the
// sender and address(2) as the router itself, which holds what it is paid
// until a later step pays it out.
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
// V4 TAKE and TAKE_PORTION names the address it pays, and TAKE_ALL pays the
// sender. The decoder read none of them: a swap whose output went to another
// address still read "Min. received", as if the user received it. The router
// reads address(1) as the sender and address(2) as the router itself, which
// holds what it is paid until a later step pays it out.
//
// The screens are driven against a minimal DOM stub in the shape
// tests/uniswapRouterAddress.test.js uses.
@@ -41,6 +43,8 @@ const transactionDetail = require("../src/popup/views/transactionDetail");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
// UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40.
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
// UniversalRouterV1 on Ethereum Mainnet.
const ROUTER_V1 = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER = "0x7777777777777777777777777777777777777777";
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
@@ -50,15 +54,26 @@ 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;
// The router's whole-balance amount.
const CONTRACT_BALANCE = 2n ** 255n;
// The Paid to line's value when no address can be named.
const UNDECODED = "Unknown (could not be decoded)";
const UNNAMED = "Unknown (not named in the calldata)";
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
const PAID_TO_BRIDGE =
"The bridge, which pays the deposit's recipient on another network";
// The Paid to line for a PERMIT2_TRANSFER_FROM of `amount` into the router.
const movedIntoRouter = (amount) =>
"The router, " +
amount +
" from the sender (later steps may not spend all of it)";
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
@@ -89,7 +104,14 @@ function v2Swap(command, recipient, amount, limit, path, payerIsUser = true) {
];
}
function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) {
function v3SwapExactIn(
recipient,
amountIn,
amountOutMin,
tokenIn,
tokenOut,
payerIsUser = true,
) {
return [
0x00,
coder.encode(
@@ -102,7 +124,7 @@ function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) {
["address", "uint24", "address"],
[tokenIn, 3000, tokenOut],
),
true,
payerIsUser,
],
),
];
@@ -115,6 +137,64 @@ function sweep(token, recipient) {
];
}
// TRANSFER (0x05), PAY_PORTION (0x06) and PAY_PORTION_FULL_PRECISION (0x07)
// share one layout. `amount` is a quantity, bips, or 1e18ths respectively.
function transfer(command, token, recipient, amount) {
return [
command,
coder.encode(
["address", "address", "uint256"],
[token, recipient, amount],
),
];
}
function permit2TransferFrom(token, recipient, amount) {
return [
0x02,
coder.encode(
["address", "address", "uint160"],
[token, recipient, amount],
),
];
}
// ACROSS_V4_DEPOSIT_V3 (0x40) of `inputAmount` of `inputToken`, bridged to
// chain id 8453.
function acrossDeposit(
depositor,
recipient,
inputToken,
inputAmount,
useNative = false,
) {
return [
0x40,
coder.encode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
[
[
depositor,
recipient,
inputToken,
inputToken,
inputAmount,
1n,
8453n,
ZERO,
0,
0,
0,
"0x",
useNative,
],
],
),
];
}
function wrapEth(recipient, amount) {
return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])];
}
@@ -150,6 +230,15 @@ const SWAP_USDT_FOR_USDC = [
[[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]],
),
];
const SWAP_USDT_FOR_ETH = [
0x06, // SWAP_EXACT_IN_SINGLE
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "0x"]],
),
];
// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
const UNREADABLE_SWAP = [0x06, "0x"];
const SETTLE_USDT = [
@@ -168,6 +257,15 @@ function take(currency, recipient) {
function takeAll(currency) {
return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])];
}
function takePortion(currency, recipient, bips) {
return [
0x10,
coder.encode(
["address", "address", "uint256"],
[currency, recipient, bips],
),
];
}
// A V4_SWAP step of `actions`, each [action, params].
function v4Swap(...actions) {
@@ -422,6 +520,428 @@ describe("an output left in the router is not received", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/599: TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION and V4 TAKE_PORTION pay a token to an address,
// and PERMIT2_TRANSFER_FROM moves the user's own tokens to one.
describe("each transfer command's recipient", () => {
// Each pays out all of a swap's output. Paid to another address, this is
// the first shape the issue reports.
test.each([
{
kind: "TRANSFER of CONTRACT_BALANCE",
data: (r) =>
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, r, CONTRACT_BALANCE),
),
min: "1.0000 WETH",
},
{
kind: "PAY_PORTION of 10,000 bips",
data: (r) =>
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, r, 10000n),
),
min: "1.0000 WETH",
},
{
kind: "PAY_PORTION_FULL_PRECISION of 1e18",
data: (r) =>
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x07, WETH, r, 10n ** 18n),
),
router: ROUTER_V2_1_2,
min: "1.0000 WETH",
},
{
kind: "V4 TAKE_PORTION of 10,000 bips",
data: (r) =>
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
takePortion(USDC, r, 10000n),
),
),
min: "1990.0000 USDC",
},
])(
"$kind: paid to the user it is received, to the router it stays there, to another address that address is named",
({ data, router = ROUTER, min }) => {
const toUser = decode(data(MSG_SENDER), USER, router);
expect(labels(toUser)).toEqual(PAID_TO_USER);
expect(detail(toUser, "Min. received").value).toBe(min);
expect(decode(data(USER), USER, router).details).toEqual(
toUser.details,
);
const toRouter = decode(data(ADDRESS_THIS), USER, router);
expect(labels(toRouter)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toRouter)).toEqual([STAYS_IN_ROUTER]);
expect(detail(toRouter, "Min. paid out").value).toBe(min);
const toOther = decode(data(OTHER), USER, router);
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe(min);
},
);
// The user's USDT, moved to `r`, then a swap of it paid to the user. A
// move to another address is the issue's second shape.
const movedThenSwapped = (r) =>
execute(
permit2TransferFrom(USDT, r, USDT_2000),
v3SwapExactIn(MSG_SENDER, USDT_2000, ONE_WETH, USDT, WETH, false),
);
test("PERMIT2_TRANSFER_FROM: to the user it pays no one else, to another address that address is named", () => {
for (const r of [MSG_SENDER, USER]) {
const result = decode(movedThenSwapped(r));
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
}
const toOther = decode(movedThenSwapped(OTHER));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH");
const alone = decode(
execute(permit2TransferFrom(USDT, OTHER, USDT_2000)),
);
expect(detail(alone, "Steps").value).toBe("Permit2 Transfer");
expect(paidTo(alone)).toEqual([OTHER]);
});
// Later steps may leave part of what a move into the router moves there,
// where anyone can sweep it in a later transaction.
test.each([
{ kind: "address(2)", r: ADDRESS_THIS },
{ kind: "the router's own address", r: ROUTER },
])(
"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(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");
},
);
test.each([
{ kind: "from the router's balance", payerIsUser: false },
{ kind: "from the user's", payerIsUser: true },
])(
"a move of 2,000 USDT into the router, then a swap of 1 USDT $kind paid to the user",
({ payerIsUser }) => {
const result = decode(
execute(
permit2TransferFrom(USDT, ADDRESS_THIS, USDT_2000),
v3SwapExactIn(
MSG_SENDER,
1000000n,
1n,
USDT,
WETH,
payerIsUser,
),
),
);
expect(detail(result, "Amount").value).toBe("1.0000 USDT");
expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
},
);
test("a move into the router of a token the wallet does not know gives the token's address", () => {
const result = decode(
execute(
permit2TransferFrom(UNKNOWN_TOKEN, ADDRESS_THIS, 5n),
swapToWeth(MSG_SENDER),
),
);
expect(paidTo(result)).toEqual([
movedIntoRouter("5 base units (decimals unknown)"),
]);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: UNKNOWN_TOKEN,
address: UNKNOWN_TOKEN,
isToken: true,
});
});
test("UniversalRouterV1 pays a PERMIT2_TRANSFER_FROM to address(1) or address(2) as written, and maps a TRANSFER's", () => {
for (const r of [MSG_SENDER, ADDRESS_THIS]) {
const result = decode(movedThenSwapped(r), USER, ROUTER_V1);
expect(paidTo(result)).toEqual([r]);
expect(detail(result, "Min. received")).toBeUndefined();
}
const transferred = decode(
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, MSG_SENDER, CONTRACT_BALANCE),
),
USER,
ROUTER_V1,
);
expect(labels(transferred)).toEqual(PAID_TO_USER);
});
test("TAKE_PORTION does not set Token Out: a TAKE_PORTION of another currency after the TAKE_ALL of the bought one", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
takeAll(USDC),
takePortion(USDT, OTHER, 25n),
),
),
);
expect(detail(result, "Token Out").address).toBe(USDC);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("a router without command 0x07 does not read it as PAY_PORTION_FULL_PRECISION", () => {
const result = decode(
execute(
swapToWeth(MSG_SENDER),
transfer(0x07, WETH, MSG_SENDER, 10n ** 18n),
),
);
expect(detail(result, "Steps").value).toBe(
"V3 Swap (Exact In) → Command 0x07",
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
// A payout of only part of what the router holds leaves the rest there.
describe("a payout of part of the router's output", () => {
test.each([
{
kind: "a TRANSFER of 1 base unit",
step: transfer(0x05, WETH, MSG_SENDER, 1n),
},
{
kind: "a PAY_PORTION of 5,000 bips",
step: transfer(0x06, WETH, MSG_SENDER, 5000n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e18 less 1",
step: transfer(0x07, WETH, MSG_SENDER, 10n ** 18n - 1n),
},
])("$kind to the user leaves the rest in the router", ({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step),
USER,
ROUTER_V2_1_2,
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
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.
test.each([
{
kind: "PAY_PORTION of 25 bips, then SWEEP",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, OTHER, 25n),
sweep(WETH, MSG_SENDER),
),
min: "1.0000 WETH",
},
{
kind: "TRANSFER of a flat fee, then SWEEP",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, OTHER, ONE_WETH / 400n),
sweep(WETH, MSG_SENDER),
),
min: "1.0000 WETH",
},
{
kind: "PAY_PORTION of 25 bips, then UNWRAP_WETH",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, OTHER, 25n),
unwrapWeth(MSG_SENDER),
),
min: "1.0000 ETH",
},
{
kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL",
data: execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
takePortion(USDC, OTHER, 25n),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
])(
"a fee by $kind names the fee's recipient and calls nothing received",
({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe(min);
},
);
});
// ACROSS_V4_DEPOSIT_V3 hands the bridge what the router holds. The bridge
// pays the deposit's recipient on another network, and refunds a deposit
// nobody fills to its depositor; the router passes both on as written.
describe("a bridge deposit", () => {
// A swap of 2,000 USDT for at least 1 WETH paid to the router, then a
// deposit of all the router's WETH.
const bridged = (depositor, recipient) =>
execute(
swapToWeth(ADDRESS_THIS),
acrossDeposit(depositor, recipient, WETH, CONTRACT_BALANCE),
);
test.each([
{
kind: "the user is its recipient and its depositor",
recipient: USER,
depositor: USER,
named: [],
},
{
kind: "another address is its recipient",
recipient: OTHER,
depositor: USER,
named: [OTHER],
},
{
kind: "another address is its depositor",
recipient: USER,
depositor: OTHER,
named: [OTHER],
},
{
kind: "address(1) is its recipient and address(2) its depositor",
recipient: MSG_SENDER,
depositor: ADDRESS_THIS,
named: [MSG_SENDER, ADDRESS_THIS],
},
])(
"when $kind, each that is not the user is named, and nothing is received",
({ recipient, depositor, named }) => {
const result = decode(
bridged(depositor, recipient),
USER,
ROUTER_V2_1_2,
);
expect(detail(result, "Steps").value).toBe(
"V3 Swap (Exact In) → Across Bridge Deposit",
);
expect(paidTo(result)).toEqual([...named, PAID_TO_BRIDGE]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
},
);
test("a deposit of part of the router's WETH leaves the rest there", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
acrossDeposit(USER, USER, WETH, ONE_WETH / 2n),
),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(result)).toEqual([PAID_TO_BRIDGE, STAYS_IN_ROUTER]);
});
test("a deposit of all the router's ETH pays out the ETH it holds; one of all its WETH does not", () => {
const ethToRouter = v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
);
const native = decode(
execute(
ethToRouter,
acrossDeposit(USER, USER, WETH, CONTRACT_BALANCE, true),
),
USER,
ROUTER_V2_1_2,
);
expect(detail(native, "Token Out").value).toBe("ETH");
expect(paidTo(native)).toEqual([PAID_TO_BRIDGE]);
const weth = decode(
execute(
ethToRouter,
acrossDeposit(USER, USER, WETH, CONTRACT_BALANCE),
),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(weth)).toEqual([PAID_TO_BRIDGE, STAYS_IN_ROUTER]);
});
});
// Uniswap's mixed routes pay a V2 pair directly when the next step swaps
// through it. That V2 swap spends what the pair holds: its amountIn is
// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. The pairs below
@@ -430,7 +950,6 @@ describe("an output left in the router is not received", () => {
describe("a swap paid to the pair the next V2 swap spends", () => {
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const CONTRACT_BALANCE = 2n ** 255n;
const AMOUNTS_IN = [
{ kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE },
{ kind: "ALREADY_PAID", amountIn: 0n },
@@ -498,6 +1017,36 @@ describe("a swap paid to the pair the next V2 swap spends", () => {
},
);
test.each([
{
kind: "ETH wrapped into the router, then a TRANSFER of all of it",
pay: (r) => [
wrapEth(ADDRESS_THIS, ONE_WETH),
transfer(0x05, WETH, r, CONTRACT_BALANCE),
],
},
{
kind: "a PERMIT2_TRANSFER_FROM of the user's WETH",
pay: (r) => [permit2TransferFrom(WETH, r, ONE_WETH)],
},
])(
"$kind, paid to the pair, is a hop; paid to another address, it is named",
({ pay }) => {
const route = (r) =>
execute(...pay(r), pairSpends(0n, [WETH, USDC]));
const toPair = decode(route(PAIR));
expect(paidTo(toPair)).toEqual([]);
expect(detail(toPair, "Min. received").value).toBe(
"1990.0000 USDC",
);
const toOther = decode(route(OTHER));
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. received")).toBeUndefined();
},
);
describe("on Sepolia", () => {
const SEPOLIA = "0xaa36a7";
// UniversalRouterV2_1_2 on Sepolia.
@@ -661,13 +1210,17 @@ describe("every step's payee counts, whichever step sets the output", () => {
});
describe("a payout that cannot be read is never said to be received", () => {
test("a V4 TAKE whose recipient word has its top byte set", () => {
test.each([
{ kind: "TAKE", action: take(USDC, OTHER) },
{ kind: "TAKE_PORTION", action: takePortion(USDC, OTHER, 25n) },
])("a V4 $kind whose recipient word has its top byte set", ({ action }) => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
withDirtyWord(take(USDC, OTHER), 1),
withDirtyWord(action, 1),
takeAll(USDC),
),
),
);
@@ -689,11 +1242,38 @@ describe("a payout that cannot be read is never said to be received", () => {
{ kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 },
{ kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) },
{ kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) },
{
kind: "TRANSFER",
step: transfer(0x05, WETH, OTHER, 1n),
word: 1,
},
{
kind: "PAY_PORTION",
step: transfer(0x06, WETH, OTHER, 1n),
word: 1,
},
{
kind: "PAY_PORTION_FULL_PRECISION",
step: transfer(0x07, WETH, OTHER, 1n),
word: 1,
},
{
kind: "PERMIT2_TRANSFER_FROM",
step: permit2TransferFrom(WETH, OTHER, 1n),
word: 1,
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
step: acrossDeposit(USER, OTHER, WETH, 1n),
word: 2,
},
])(
"a $kind whose recipient word has its top byte set",
({ step, word = 0 }) => {
const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
@@ -719,17 +1299,8 @@ describe("a payout that cannot be read is never said to be received", () => {
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
// https://git.eeqj.de/sneak/AutistMask/issues/599 is to read these. Until
// then each is a payout this decoder does not read.
const transferShape = (command) => [
command,
coder.encode(["address", "address", "uint256"], [WETH, OTHER, 1n]),
];
// Each is a payout this decoder does not read.
test.each([
{ kind: "PERMIT2_TRANSFER_FROM", step: transferShape(0x02) },
{ kind: "TRANSFER", step: transferShape(0x05) },
{ kind: "PAY_PORTION", step: transferShape(0x06) },
{ kind: "PAY_PORTION_FULL_PRECISION", step: transferShape(0x07) },
{
kind: "PERMIT2_TRANSFER_FROM_BATCH",
step: [
@@ -750,24 +1321,10 @@ describe("a payout that cannot be read is never said to be received", () => {
0x21,
coder.encode(
["bytes", "bytes[]"],
["0x05", [transferShape(0x05)[1]]],
["0x05", [transfer(0x05, WETH, OTHER, 1n)[1]]],
),
],
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
step: [0x40, coder.encode(["address"], [OTHER])],
},
{
kind: "V4 TAKE_PORTION",
step: v4Swap([
0x10,
coder.encode(
["address", "address", "uint256"],
[WETH, OTHER, 10000n],
),
]),
},
])("a swap paid to the user, then $kind", ({ step }) => {
const result = decode(
execute(swapToWeth(MSG_SENDER), step),
@@ -951,3 +1508,25 @@ describe("the screens judge the output against the transaction's sender", () =>
expect(toOther).not.toContain("Min. received");
});
});
// The address of each token moved into the router, a known one and one the
// wallet does not know, is shown as every other address is: with an explorer
// link, and copied when clicked. Neither token is the swap's.
describe("the screens show each token moved into the router as an address", () => {
const twoMovesThenSwap = execute(
permit2TransferFrom(USDC, ADDRESS_THIS, USDC_1990),
permit2TransferFrom(UNKNOWN_TOKEN, ADDRESS_THIS, 5n),
swapToWeth(MSG_SENDER),
);
test.each([
{ screen: "approval", show: showApproval },
{ screen: "transaction detail", show: showDetail },
])("the $screen screen", async ({ show }) => {
const html = await show(twoMovesThenSwap);
for (const token of [USDC, UNKNOWN_TOKEN]) {
expect(html).toContain('data-copy="' + token + '"');
expect(html).toContain("/address/" + token + '"');
}
});
});