2 Commits
Author SHA1 Message Date
sneak 33b97f135f fix: a send the confirmation screen refuses moves nothing on it (closes #583)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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 what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only. 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 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, 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 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 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 the token uses and that the transaction cannot be sent; for any other
failure it asks the user to go back and try again 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 - Password: an inline field on this screen, not a modal, with its own error
line line
- "Sign & Send" button (disabled if errors, while the balance is being read, - "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 - "Sign & Send" after the active network was switched in Settings since this
transaction was reviewed → nothing is signed; "The active network changed transaction was reviewed → nothing is signed; "The active network changed
after this transaction was reviewed, so it was not sent. Please go back 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, "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 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 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 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 network's RPC endpoint, and the Etherscan label check asks that network's
explorer 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 - "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is - "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 address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for `UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag `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 (`0x80`) therefore does not change a step's name. Only the routers with the
the commands inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`), listed as
step stands they may spend the user's tokens or pay the user. That step is `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`), listed as
listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other `Across Bridge Deposit`; the other routers revert on `0x07`, and it is listed
steps state, the summary names no swap direction, `Token In` and `Token Out` there as `Command 0x07`. The decoder does not read the commands inside an
read `Unknown (could not be decoded)`, and there is no `Amount` or `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they may spend
`Min. received` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and the user's tokens or pay the user. That step is listed as
V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's `Execute Sub-Plan (its steps are not shown)`, and whatever the other steps
sender. The router reads `address(1)` as the transaction's sender, and state, the summary names no swap direction, `Token In` and `Token Out` read
`address(2)` as the router itself, which holds what it is paid until a later `Unknown (could not be decoded)`, and there is no `Amount` or `Min. received`
step pays it out; a payment to the router, as `address(2)` or by its own line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH`, `TRANSFER`,
address, is not counted as paid out. When a swap step or `TAKE` pays the `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `PERMIT2_TRANSFER_FROM` (which
router the swap's output token, the output stays in the router unless a later moves the user's own tokens) and V4 `TAKE` and `TAKE_PORTION` names the
`SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out. address it pays, and `TAKE_ALL` pays the transaction's sender. The router
A swap step or `TAKE` paying a token to the V2 pair that the very next step reads `address(1)` as the transaction's sender, and `address(2)` as the router
spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID` itself, which holds what it is paid until a later step pays it out; a payment
(zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes to the router, as `address(2)` or by its own address, is not counted as paid
pay the next V2 pair directly, and the router enforces that swap's own out. `UniversalRouterV1` alone pays a `PERMIT2_TRANSFER_FROM` to `address(1)`
minimum. The decoder checks that the address paid is that pair, deriving it or `address(2)` as those addresses, and the decoder names them. A
from the V2 factory and pair init code hash the network's routers were `PERMIT2_TRANSFER_FROM` into the router is named with the amount it moves,
deployed with and the first two tokens of that step's path; a payment to any because the decoder does not check that later steps spend all of it.
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was `ACROSS_V4_DEPOSIT_V3` pays the bridge, which pays the deposit's recipient on
deployed with placeholders for both values, so no V2 swap step can succeed on another network and refunds a deposit nobody fills to its depositor; the
that router.) Every other payment counts, in whichever step and of whatever router passes both on as written, so `address(1)` and `address(2)` there are
token. The minimum reads `Min. received` only when some step pays the addresses, and each that is not the transaction's sender is named. When a swap
transaction's sender (the From address on TxApproval, the sender the explorer step, `TAKE` or `TAKE_PORTION` pays the router the swap's output token, the
reports on TransactionDetail), no step pays any other address, no step that output stays in the router unless a later step pays all the router holds of it
may pay an address went unread, and the output does not stay in the router. to anyone but the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is
Otherwise it reads `Min. paid out`, a `Paid to` line after `Token Out` names WETH, a `TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a
each other address paid, one reads `PAY_PORTION_FULL_PRECISION` of 1e18 of that token, or an
`The router, where it stays (no later step pays it out)` when the output stays `ACROSS_V4_DEPOSIT_V3` of `CONTRACT_BALANCE` of that token, or of ETH when it
in the router, and one more reads `Unknown (could not be decoded)` when such a sets `useNative`. The router sends ETH as written, so a `TRANSFER` of
step went unread, or `Unknown (not named in the calldata)` when no step pays `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
anyone. A step goes unread when its recipient does not decode as an address, token to the V2 pair that the very next step spends, as a V2 exact-in of that
or when it is a command or V4 action the decoder does not read: every command token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and the router enforces that swap's own minimum. The decoder checks that the
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and address paid is that pair, deriving it from the V2 factory and pair init code
`EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`. 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 ### Non-Goals Forever
+19
View File
@@ -697,6 +697,25 @@
id="confirm-fee-unknown-error" id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible" class="mb-2 border border-border border-dashed p-2 text-xs invisible"
></div> ></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"> <div class="mb-2">
<label class="block mb-1 text-xs">Password</label> <label class="block mb-1 text-xs">Password</label>
<input <input
+29 -30
View File
@@ -291,9 +291,12 @@ function show(txInfo) {
renderValidation(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 = ""; $("confirm-tx-password").value = "";
hideError("confirm-tx-password-error"); hideError("confirm-tx-password-error");
setVisible("confirm-network-changed-error", false);
setVisible("confirm-fee-bound-error", false);
// Gas estimate — show placeholder then fetch async // Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible"; $("confirm-fee").style.visibility = "visible";
@@ -556,16 +559,17 @@ async function estimateGas(txInfo) {
} }
} }
// Populate the transaction this send describes, enforce the fee bound against // Populate the transaction this send describes, enforce the bounds against the
// the fees that were actually filled in, then sign and broadcast it. Apart // gas limit and fees that were actually filled in, then sign and broadcast it.
// from a max ETH send, which passes its estimate's fee fields as `fees`, the // Apart from a max ETH send, which passes its estimate's fee fields as `fees`,
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from // the send pins no fee fields, so ethers fills maxFeePerGas and the gas limit
// what the configured RPC node answers, with nothing otherwise bounding what a // from what the configured RPC node answers, with nothing otherwise bounding
// hostile node can set — the dApp path's ceilings never reached this one. // what a hostile node can set — the dApp path's ceilings never reached this
// Populating before the check is what makes assertWithinCeilings() see the // one. Populating before the check is what makes assertWithinCeilings() see
// same numbers that would be signed; it throws an ApprovalMismatchError when // the same numbers that would be signed; it throws an ApprovalMismatchError
// the product gasLimit × maxFeePerGas is over the bound, which the caller // when the gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
// shows in the reserved error area rather than sending. // 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) { async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
let request; let request;
if (tx.token === "ETH") { if (tx.token === "ETH") {
@@ -593,15 +597,6 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
return connectedSigner.sendTransaction(populated); 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) { async function checkRecipientHistory(txInfo) {
try { try {
const provider = reviewedNetworkProvider(txInfo); const provider = reviewedNetworkProvider(txInfo);
@@ -640,6 +635,10 @@ function init(_ctx) {
onViewLeave("confirm-tx", clearPassword); onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => { $("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]; const wallet = state.wallets[state.selectedWallet];
// Every Send button refuses a defective wallet before this screen, // Every Send button refuses a defective wallet before this screen,
// but the popup also reopens onto it from a saved view. // 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 // Settings after a switch draws this screen again from the saved
// transaction, as a popup reopened onto it does. // transaction, as a popup reopened onto it does.
if (currentNetwork().chainId !== pendingTx.chainId) { if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError( setVisible("confirm-network-changed-error", true);
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
return; return;
} }
@@ -722,12 +717,16 @@ function init(_ctx) {
txStatus.showWait(pendingTx, tx.hash); txStatus.showWait(pendingTx, tx.hash);
} catch (e) { } catch (e) {
decryptedSecret = null; decryptedSecret = null;
// A fee over the bound is refused before anything is broadcast, so // A gas limit or fee over its bound is refused before anything is
// there is no transaction that may have reached the network to warn // broadcast, so there is no transaction that may have reached the
// about: the message stays on the confirmation screen where the // network to warn about: the refusal stays on the confirmation
// user can go back, rather than routing to the sent/failed screen. // 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) { if (e && e.approvalMismatch) {
showSendError(e.message); log.errorf("send refused:", e.message);
setVisible("confirm-fee-bound-error", true);
return; return;
} }
const hash = tx ? tx.hash : null; 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 // 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 = { const COMMAND_NAMES = {
0x00: "V3 Swap (Exact In)", 0x00: "V3 Swap (Exact In)",
0x01: "V3 Swap (Exact Out)", 0x01: "V3 Swap (Exact Out)",
@@ -132,6 +135,7 @@ const COMMAND_NAMES = {
0x04: "Sweep", 0x04: "Sweep",
0x05: "Transfer", 0x05: "Transfer",
0x06: "Pay Portion", 0x06: "Pay Portion",
0x07: "Pay Portion (Full Precision)",
0x08: "V2 Swap (Exact In)", 0x08: "V2 Swap (Exact In)",
0x09: "V2 Swap (Exact Out)", 0x09: "V2 Swap (Exact Out)",
0x0a: "Permit2 Permit", 0x0a: "Permit2 Permit",
@@ -145,6 +149,7 @@ const COMMAND_NAMES = {
0x13: "V4 Initialize Pool", 0x13: "V4 Initialize Pool",
0x14: "V4 Position Mgr Call", 0x14: "V4 Position Mgr Call",
0x21: "Execute Sub-Plan", 0x21: "Execute Sub-Plan",
0x40: "Across Bridge Deposit",
}; };
// The swap's Amount and Min. received lines land on the same approval screen, // 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 = const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)"; "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 // 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 // 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 // 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: // Two recipient values the router reads as placeholders, not as addresses:
// MSG_SENDER pays the caller of execute(), which is the transaction's sender, // 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 // 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 // later step pays it out. The swap commands, SWEEP, WRAP_ETH, UNWRAP_WETH,
// map their recipient through the dispatcher's map(), and a V4 TAKE through // TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION and, on every router but
// V4Router's _mapRecipient(); TAKE_ALL always pays the caller. // 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 // - 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(), 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 // - 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 // Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the
// recipient. // recipient.
// ABI: (address recipient, uint256 amount) // 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 // swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name. // name.
// //
// `takes` is every TAKE and TAKE_ALL, as the currency it takes and its // `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes
// recipient as written, MSG_SENDER for a TAKE_ALL. `recipientUnread` is set // and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// when the step has a payout this decoder did not read. // 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) { function decodeV4Swap(input) {
try { try {
const d = coder.decode(["bytes", "bytes[]"], input); const d = coder.decode(["bytes", "bytes[]"], input);
@@ -663,6 +755,13 @@ function decodeV4Swap(input) {
const t = coder.decode(["address", "uint256"], params[i]); const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0]; takeToken = t[0];
takes.push({ currency: t[0], recipient: MSG_SENDER }); 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 ( } else if (
actionId === V4_SWAP_EXACT_IN_SINGLE || actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN || actionId === V4_SWAP_EXACT_IN ||
@@ -674,17 +773,18 @@ function decodeV4Swap(input) {
} catch { } catch {
swapUndecoded = true; 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 { } catch {
// Skip sub-actions we can't decode. A TAKE or TAKE_ALL among // Skip sub-actions we can't decode. A TAKE, TAKE_ALL or
// them is a payout this decoder did not read. // TAKE_PORTION among them is a payout this decoder did not
if (actionId === V4_TAKE || actionId === V4_TAKE_ALL) { // read.
if (
actionId === V4_TAKE ||
actionId === V4_TAKE_ALL ||
actionId === V4_TAKE_PORTION
) {
recipientUnread = true; recipientUnread = true;
} }
} }
@@ -824,31 +924,47 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const paidElsewhere = []; const paidElsewhere = [];
// Set when a step may pay an address this decoder did not read. // Set when a step may pay an address this decoder did not read.
let payeeUnread = false; let payeeUnread = false;
// The tokens, lowercased, that a swap step or a V4 TAKE paid to the // Set when an ACROSS_V4_DEPOSIT_V3 step pays the bridge.
// router and no later SWEEP of that token, or UNWRAP_WETH of WETH, let bridgeDeposit = false;
// paid out. // 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 heldByRouter = new Set();
const isRouter = (recipient) => { const isRouter = (recipient) => {
const r = recipient.toLowerCase(); const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.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 // 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 addPayee = (recipient, output) => {
const r = recipient.toLowerCase(); const r = recipient.toLowerCase();
if (isRouter(recipient)) { if (isRouter(recipient)) {
if (present(output)) heldByRouter.add(output.toLowerCase()); if (present(output)) heldByRouter.add(output.toLowerCase());
} else if (r === MSG_SENDER || r === sender) { } else if (r === MSG_SENDER || r === sender) {
paidToSender = true; paidToSender = true;
} else if (!paidElsewhere.some((a) => a.toLowerCase() === r)) { } else {
paidElsewhere.push(recipient); addPaidElsewhere(recipient);
} }
}; };
// A swap step or V4 TAKE that pays `token` to `recipient` is a hop, // A step that pays `token` to `recipient` is a hop, not a payout,
// not a payout, when the very next step is a V2 exact-in whose input // when the very next step is a V2 exact-in whose input is already
// is already paid, whose path starts at `token`, and whose pair (see // paid, whose path starts at `token`, and whose pair (see v2PairFor())
// v2PairFor()) is `recipient`: Uniswap's mixed routes pay the next V2 // is `recipient`: Uniswap's mixed routes pay the next V2 pair
// pair directly, and the router enforces that next swap's own minimum. // directly, and the router enforces that next swap's own minimum.
// Its input is already paid when its amountIn is ALREADY_PAID, or // Its input is already paid when its amountIn is ALREADY_PAID, or
// CONTRACT_BALANCE, which Uniswap's routes send and with which the // CONTRACT_BALANCE, which Uniswap's routes send and with which the
// router adds only what it holds. // 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++) { for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask; 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( commandNames.push(
COMMAND_NAMES[cmdId] || name || "Command 0x" + cmdId.toString(16).padStart(2, "0"),
"Command 0x" + cmdId.toString(16).padStart(2, "0"),
); );
try { try {
@@ -890,6 +1011,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// input side an earlier hop established without breaking // input side an earlier hop established without breaking
// the invariant. // the invariant.
if (p) setInput(p.token, p.amount); 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) { } else if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at // The router passes this check whenever the owner holds at
@@ -948,6 +1088,67 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
payeeUnread = true; 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) { } else if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]); const w = decodeWrapEth(inputs[i]);
if (w) { if (w) {
@@ -1016,9 +1217,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
// Any other command may pay an address this decoder does // Any other command may pay an address this decoder does
// not read: V3_SWAP_EXACT_OUT does, and so do TRANSFER, // not read: V3_SWAP_EXACT_OUT and
// PAY_PORTION and the others // PERMIT2_TRANSFER_FROM_BATCH among them.
// https://git.eeqj.de/sneak/AutistMask/issues/599 lists.
payeeUnread = true; payeeUnread = true;
} }
} catch { } 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 // A Paid to line names each other address paid, and says when a
// sender, none pays another address, none may pay an address this // step pays the bridge, when one moves the sender's tokens into the
// decoder did not read, and the output token is not left in the // router, when the output stays in the router, and when a payout
// router. Otherwise each other address paid is named, and further // could not be read. The output is received by the sender only when
// lines say when the output stays in the router, a payout could not // some step pays the sender and there is no such line. When no step
// be read, or no step names anyone paid. // names anyone paid, one line says so. `paidTo` holds these lines and
// the token line after each move into the router.
const outputHeld = const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase()); present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const received = const paidTo = paidElsewhere.map((address) => ({
paidToSender && label: "Paid to",
paidElsewhere.length === 0 && value: address,
!payeeUnread && address,
!outputHeld; }));
for (const address of paidElsewhere) { if (bridgeDeposit) {
details.push({ label: "Paid to", value: address, address }); 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) { if (outputHeld) {
details.push({ label: "Paid to", value: STAYS_IN_ROUTER }); paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
} }
if (payeeUnread) { if (payeeUnread) {
details.push({ label: "Paid to", value: UNDECODED_CURRENCY }); paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
} else if (!paidToSender && paidElsewhere.length === 0 && !outputHeld) {
details.push({ label: "Paid to", value: UNNAMED_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)) { if (present(minOutput)) {
// A zero floor is the one case the user most needs stated: the // 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 // The check is driven here with a fake connected signer rather than a real
// one: populateTransaction() returns the fees the node would have produced, // one: populateTransaction() returns the fees the node would have produced,
// and sendTransaction() records whether the send actually happened. The real // and sendTransaction() records whether the send actually happened.
// DOM path around it — reading the fee error into the reserved errors box — is //
// covered by the Chrome e2e suite. // 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 = { // What the stub node quotes for that test: a gas limit of 150,000,000 at a
storage: { local: { get: async () => ({}), set: async () => {} } }, // 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(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); 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 { const {
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
MAX_TOTAL_FEE, MAX_TOTAL_FEE,
} = require("../src/shared/approvalVerify"); } = require("../src/shared/approvalVerify");
@@ -98,3 +193,69 @@ describe("populateVerifyAndSend enforces the combined fee bound", () => {
expect(signer.sent[0].gasLimit).toBe(under.gasLimit); 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(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled(); expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled(); expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").style.visibility).toBe("visible"); expect(shown("confirm-network-changed-error")).toBe(true);
expect(el("confirm-errors").innerHTML).toContain( expect(shown("confirm-fee-bound-error")).toBe(false);
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
}); });
}, },
); );
@@ -497,10 +493,7 @@ describe.each([
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled(); expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled(); expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").innerHTML).toContain( expect(shown("confirm-network-changed-error")).toBe(true);
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
}); });
test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => { 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(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(errors()).toContain( expect(shown("confirm-network-changed-error")).toBe(true);
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
}); });
const tokenSend = { const tokenSend = {
@@ -629,10 +619,7 @@ describe.each([
expect(signTransaction).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]); expect(mockNode.broadcast).toEqual([]);
expect(errors()).toContain( expect(shown("confirm-network-changed-error")).toBe(true);
"The active network changed after this transaction was" +
" reviewed, so it was not sent.",
);
}); });
test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => { 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 // The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the // 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 // reserve and the estimate collapse to the same number, and the gate tests
// stop being able to distinguish them. // stop being able to distinguish them. opts.baseFeeWei replaces its base fee,
function latestBlock() { // read at request time like every other fixture switch.
function latestBlock(opts) {
return { return {
hash: "0x" + "11".repeat(32), hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32), parentHash: "0x" + "22".repeat(32),
@@ -239,7 +240,7 @@ function latestBlock() {
gasUsed: "0xf4240", gasUsed: "0xf4240",
miner: STUB_COUNTERPARTY, miner: STUB_COUNTERPARTY,
extraData: "0x", extraData: "0x",
baseFeePerGas: hex(BASE_FEE_WEI), baseFeePerGas: opts.baseFeeWei || hex(BASE_FEE_WEI),
transactions: [], transactions: [],
}; };
} }
@@ -481,7 +482,7 @@ function rpcReply(req, opts, report) {
}); });
} }
if (req.method === "eth_getBlockByNumber") { 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 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 // 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; * @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
* defaults to zero, which is what every test that predates the funded * defaults to zero, which is what every test that predates the funded
* fixture expects. * 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 * @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal. * node-side refusal.
* @param {boolean} [opts.holdBalance] hold every batch containing an * @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 // 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 // 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) { async function confirmState(page) {
return page.evaluate(() => { return page.evaluate(() => {
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
@@ -2245,6 +2246,9 @@ async function confirmState(page) {
}; };
return { return {
height: el("view-confirm-tx").getBoundingClientRect().height, height: el("view-confirm-tx").getBoundingClientRect().height,
passwordTop:
el("confirm-tx-password").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top,
sendTop: sendTop:
el("btn-confirm-send").getBoundingClientRect().top - el("btn-confirm-send").getBoundingClientRect().top -
el("view-confirm-tx").getBoundingClientRect().top, el("view-confirm-tx").getBoundingClientRect().top,
@@ -2262,6 +2266,12 @@ async function confirmState(page) {
amountFeeError: shown("confirm-amount-fee-error"), amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"), gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-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, 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) // ------------------------------ the wait for a receipt ending in error (#315)
// //
// README.md (WaitTx) documents two ways the wait ends on the error screen: a // 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"; boot.node("confirm-tx-password").value = "any password";
await boot.click("btn-confirm-send"); await boot.click("btn-confirm-send");
expect(boot.visibleViews()).toEqual(["confirm-tx"]); expect(boot.visibleViews()).toEqual(["confirm-tx"]);
expect(boot.node("confirm-errors").innerHTML).toContain( expect(
"The active network changed after this transaction was reviewed", boot.node("confirm-network-changed-error").style.visibility,
); ).toBe("visible");
// Refused before the password is even tried. // Refused before the password is even tried.
expect(boot.text("confirm-tx-password-error")).toBe(""); expect(boot.text("confirm-tx-password-error")).toBe("");
}); });
+22 -1
View File
@@ -954,7 +954,28 @@ describe("uniswap decoder", () => {
router: "UniversalRouterV2_1_2", router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR, address: ROUTER_V2_1_2_ADDR,
command: "0x40", 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", "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 // 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 // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// address it pays, and TAKE_ALL pays the sender. The decoder read none of // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
// them: a swap whose output went to another address still read "Min. // V4 TAKE and TAKE_PORTION names the address it pays, and TAKE_ALL pays the
// received", as if the user received it. The router reads address(1) as the // sender. The decoder read none of them: a swap whose output went to another
// sender and address(2) as the router itself, which holds what it is paid // address still read "Min. received", as if the user received it. The router
// until a later step pays it out. // 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 // The screens are driven against a minimal DOM stub in the shape
// tests/uniswapRouterAddress.test.js uses. // tests/uniswapRouterAddress.test.js uses.
@@ -41,6 +43,8 @@ const transactionDetail = require("../src/popup/views/transactionDetail");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
// UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40. // UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40.
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85"; const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
// UniversalRouterV1 on Ethereum Mainnet.
const ROUTER_V1 = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER = "0x7777777777777777777777777777777777777777"; const OTHER = "0x7777777777777777777777777777777777777777";
const MSG_SENDER = "0x0000000000000000000000000000000000000001"; const MSG_SENDER = "0x0000000000000000000000000000000000000001";
@@ -50,15 +54,26 @@ const ZERO = "0x0000000000000000000000000000000000000000";
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// A token the wallet does not know.
const UNKNOWN_TOKEN = "0x5555555555555555555555555555555555555555";
const ONE_WETH = 1000000000000000000n; const ONE_WETH = 1000000000000000000n;
const USDT_2000 = 2000000000n; const USDT_2000 = 2000000000n;
const USDC_1990 = 1990000000n; 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. // The Paid to line's value when no address can be named.
const UNDECODED = "Unknown (could not be decoded)"; const UNDECODED = "Unknown (could not be decoded)";
const UNNAMED = "Unknown (not named in the calldata)"; const UNNAMED = "Unknown (not named in the calldata)";
const STAYS_IN_ROUTER = const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)"; "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 coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([ 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 [ return [
0x00, 0x00,
coder.encode( coder.encode(
@@ -102,7 +124,7 @@ function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) {
["address", "uint24", "address"], ["address", "uint24", "address"],
[tokenIn, 3000, tokenOut], [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) { function wrapEth(recipient, amount) {
return [0x0b, coder.encode(["address", "uint256"], [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"]], [[[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. // SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
const UNREADABLE_SWAP = [0x06, "0x"]; const UNREADABLE_SWAP = [0x06, "0x"];
const SETTLE_USDT = [ const SETTLE_USDT = [
@@ -168,6 +257,15 @@ function take(currency, recipient) {
function takeAll(currency) { function takeAll(currency) {
return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])]; 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]. // A V4_SWAP step of `actions`, each [action, params].
function v4Swap(...actions) { 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 // 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 // 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 // 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", () => { 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. // The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"; const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const CONTRACT_BALANCE = 2n ** 255n;
const AMOUNTS_IN = [ const AMOUNTS_IN = [
{ kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE }, { kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE },
{ kind: "ALREADY_PAID", amountIn: 0n }, { 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", () => { describe("on Sepolia", () => {
const SEPOLIA = "0xaa36a7"; const SEPOLIA = "0xaa36a7";
// UniversalRouterV2_1_2 on Sepolia. // 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", () => { 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( const result = decode(
execute( execute(
v4Swap( v4Swap(
SWAP_USDT_FOR_USDC, SWAP_USDT_FOR_USDC,
SETTLE_USDT, 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: "SWEEP", step: sweep(WETH, OTHER), word: 1 },
{ kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) }, { kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) },
{ kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) }, { 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", "a $kind whose recipient word has its top byte set",
({ step, word = 0 }) => { ({ step, word = 0 }) => {
const result = decode( const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)), execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)),
USER,
ROUTER_V2_1_2,
); );
expect(paidTo(result)).toEqual([UNDECODED]); expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined(); 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"); expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/599 is to read these. Until // Each is a payout this decoder does not read.
// then each is a payout this decoder does not read.
const transferShape = (command) => [
command,
coder.encode(["address", "address", "uint256"], [WETH, OTHER, 1n]),
];
test.each([ 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", kind: "PERMIT2_TRANSFER_FROM_BATCH",
step: [ step: [
@@ -750,24 +1321,10 @@ describe("a payout that cannot be read is never said to be received", () => {
0x21, 0x21,
coder.encode( coder.encode(
["bytes", "bytes[]"], ["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 }) => { ])("a swap paid to the user, then $kind", ({ step }) => {
const result = decode( const result = decode(
execute(swapToWeth(MSG_SENDER), step), 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"); 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 + '"');
}
});
});