2 Commits
Author SHA1 Message Date
sneak 56014a264b fix: any token a swap leaves in the router keeps it from reading as received (closes #606)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The decoder checked only the swap's output for being left in the router.
It now records every token a step pays the router, the WETH of a
WRAP_ETH and the rest of a V4 SETTLE taken to the router among them, and
any token still there after the last step gets a Paid to line naming it,
so the minimum reads Min. paid out. Steps that spend all the router holds
of a token, and swap steps from the router that together spend what a
WRAP_ETH stated, count as spending it. A SETTLE of OPEN_DELTA from the
router counts only in the plain shape of Uniswap's own routes.

Model: opus-5-5
2026-10-19 18:58:27 +00:00
clawbot 2c882c75d6 fix: refusing a From address the wallet does not hold moves nothing (closes #609)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Sign & Send wrote its refusal of a From address the selected wallet does not
hold onto the password error line, which reserves one line. The sentence runs
to about three, so it pushed Sign & Send down.

The refusal now has its own box above the password field, its sentence fixed
in index.html, holding its space from the first paint and only made visible,
as #583 did for the other
refusals. Drawing the screen and each press hide it again. A Chrome test
writes the other wallet into the stored selection, reopens the popup onto the
screen, presses Sign & Send and checks that nothing was broadcast and nothing
moved.

Model: opus-5-5
2026-10-19 20:50:02 +02:00
7 changed files with 341 additions and 82 deletions
+41 -32
View File
@@ -433,7 +433,11 @@ 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.
and leave the password field and Sign & Send where they were. It checks the same
for a send refused because the selected wallet does not hold its From address:
with the confirmation screen showing a send from the HD wallet, the key wallet
is selected by writing it straight into extension storage, as another popup
selecting it would save it, and the popup is reopened onto that screen.
It also covers both ways the wait for a sent transaction's receipt ends on the
error screen: lookups that still find no receipt 60 seconds after the broadcast,
@@ -1814,9 +1818,10 @@ 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
- Three more reserved error boxes, made visible when "Sign & Send" refuses a
send because the active network changed, because the selected wallet does
not hold the From address, or because of its gas limit or fee (see
Transitions), so the refusal moves nothing
- Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors, while the balance is being read,
@@ -1860,8 +1865,9 @@ view would leave a wallet one click from deletion.
- "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not
hold the From address → full-sentence error on the password error line,
nothing sent (no screen change)
hold the From address → nothing is signed; "The selected wallet does not
hold the From address above, so nothing was sent. Please go back and send
again." in its own box, no screen change
- "Sign & Send" (correct password, but the From address's key is refused,
see Data Model) → nothing is signed → **ErrorTx** saying why
- "Back" → **Send**
@@ -3060,32 +3066,35 @@ Currently supported:
or V3 exact-in swap step or V4 `SETTLE` that pays `CONTRACT_BALANCE` of that
token from the router (`payerIsUser` false). Such steps also spend all the
WETH a `WRAP_ETH` paid the router once together they spend the amount the
`WRAP_ETH` states, as a swap paid in ETH does; a `SETTLE` of `OPEN_DELTA`
(zero) from the router counts as spending what its step's swap spends only
when it is the step's only `SETTLE` or `SETTLE_ALL` of that token, comes after
every swap action in the step, and no swap action in the step buys that token,
because the router pays only what the step still owes of that token when the
`SETTLE` runs; otherwise it counts as spending nothing. So the rest of a V4
`SETTLE` of more than its swap spends, taken to the router, the WETH of a
`WRAP_ETH`, and a token a swap pays the router on the way to the output, each
stays in the router when later steps do not spend all of it, where anyone can
take it in a later transaction. The router sends ETH as written, so a
`TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A
step paying a token to the V2 pair that the very next step spends, as a V2
exact-in of that token whose `amountIn` is `ALREADY_PAID` (zero) or
`CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes pay the
next V2 pair directly, and the router enforces that swap's own minimum. The
decoder checks that the address paid is that pair, deriving it from the V2
factory and pair init code hash the network's routers were deployed with and
the first two tokens of that step's path; a payment to any other address is
named. (Sepolia's `UniversalRouterV1_2_V2Support` was deployed with
placeholders for both values, so no V2 swap step can succeed on that router.)
Every other payment counts, in whichever step and of whatever token, a share
of the output included: an interface fee paid to another address by
`PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the minimum reads
`Min. paid out`, because the swap's minimum includes the fee. The minimum
reads `Min. received` only when some step pays the transaction's sender (the
From address on TxApproval, the sender the explorer reports on
`WRAP_ETH` states, as a swap paid in ETH does. The router pays a `SETTLE` of
`OPEN_DELTA` (zero) only what its step still owes of that token when the
`SETTLE` runs, so one from the router counts as spending the amounts the
step's swap actions state they spend of that token only when every action in
the step could be read, every swap action names the token it spends and the
token it buys (a swap action with an empty path swaps nothing), the `SETTLE`
is the step's only `SETTLE` or `SETTLE_ALL` of that token and comes after
every swap action, no swap action buys that token, and the swap actions that
spend it state a quantity together; otherwise it counts as spending nothing.
So the rest of a V4 `SETTLE` of more than its swap spends, taken to the
router, the WETH of a `WRAP_ETH`, and a token a swap pays the router on the
way to the output, each stays in the router when later steps do not spend all
of it, where anyone can take it in a later transaction. The router sends ETH
as written, so a `TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and
pays out nothing. A step paying a token to the V2 pair that the very next step
spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID`
(zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes
pay the next V2 pair directly, and the router enforces that swap's own
minimum. The decoder checks that the address paid is that pair, deriving it
from the V2 factory and pair init code hash the network's routers were
deployed with and the first two tokens of that step's path; a payment to any
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was
deployed with placeholders for both values, so no V2 swap step can succeed on
that router.) Every other payment counts, in whichever step and of whatever
token, a share of the output included: an interface fee paid to another
address by `PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the
minimum reads `Min. paid out`, because the swap's minimum includes the fee.
The minimum reads `Min. received` only when some step pays the transaction's
sender (the From address on TxApproval, the sender the explorer reports on
TransactionDetail) and no `Paid to` line is shown. `Paid to` lines after
`Token Out` name each other address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a
+11 -3
View File
@@ -697,9 +697,10 @@
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. -->
<!-- Shown when Sign & Send refuses a send for its network, for
a From address the selected wallet does not hold, or for its
gas limit or fee. Each holds its space from the first paint, so
the refusal moves nothing. -->
<div
id="confirm-network-changed-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
@@ -708,6 +709,13 @@
reviewed, so it was not sent. Please go back and review it
again.
</div>
<div
id="confirm-from-address-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
>
The selected wallet does not hold the From address above, so
nothing was sent. Please go back and send again.
</div>
<div
id="confirm-fee-bound-error"
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
+3 -5
View File
@@ -320,6 +320,7 @@ function show(txInfo) {
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
// Gas estimate — show placeholder then fetch async
@@ -661,6 +662,7 @@ function init(_ctx) {
$("btn-confirm-send").addEventListener("click", async () => {
// A press shows only its own refusal, if any.
setVisible("confirm-network-changed-error", false);
setVisible("confirm-from-address-error", false);
setVisible("confirm-fee-bound-error", false);
const wallet = state.wallets[state.selectedWallet];
@@ -686,11 +688,7 @@ function init(_ctx) {
// address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) {
showError(
"confirm-tx-password-error",
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
setVisible("confirm-from-address-error", true);
return;
}
+56 -39
View File
@@ -887,9 +887,9 @@ function totalAmount(amounts) {
// a share of the currency, so it does not name the output side as TAKE does.
// `recipientUnread` is set when the step has a payout this decoder did not
// read. `settledFromRouter` is every SETTLE that the router pays from its own
// balance (`payerIsUser` false), as the currency, the amount as written and
// its index among the step's actions, except that a SETTLE of OPEN_DELTA
// whose payment this decoder cannot follow has no amount (null; see below).
// balance (`payerIsUser` false), as its currency and the amount it pays: a
// quantity, CONTRACT_BALANCE (all the router holds), or null when this
// decoder cannot tell (see below).
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -899,11 +899,15 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
const takes = [];
const settledFromRouter = [];
// The currency of every SETTLE and SETTLE_ALL, in order.
// Every SETTLE paid from the router, as its currency, its amount as
// written and its index among the step's actions.
const routerSettles = [];
// The currency of every SETTLE and SETTLE_ALL.
const settledCurrencies = [];
// The index of the step's last swap action, read or not, or -1.
let lastSwapAction = -1;
// Set when an action of the step could not be read.
let actionUnread = false;
let recipientUnread = false;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
@@ -925,7 +929,7 @@ function decodeV4Swap(input) {
settleToken = s[0];
settledCurrencies.push(s[0]);
if (!s[2]) {
settledFromRouter.push({
routerSettles.push({
currency: s[0],
amount: s[1],
index: i,
@@ -967,14 +971,18 @@ function decodeV4Swap(input) {
swaps.push(decodeV4SwapAction(actionId, params[i]));
} catch {
swapUndecoded = true;
actionUnread = true;
}
} else {
// V4Router reverts on any action not named above, so
// none of them pays anyone. This decoder reads none.
actionUnread = true;
}
// V4Router reverts on any action not named above, so none
// of them pays anyone.
} catch {
// Skip sub-actions we can't decode. A TAKE, TAKE_ALL or
// TAKE_PORTION among them is a payout this decoder did not
// read.
actionUnread = true;
if (
actionId === V4_TAKE ||
actionId === V4_TAKE_ALL ||
@@ -985,23 +993,40 @@ function decodeV4Swap(input) {
}
}
// A SETTLE of OPEN_DELTA (zero) paid from the router pays what the
// step still owes of its currency when it runs. That is what the
// step's swap actions spend of it only when the SETTLE is the step's
// one SETTLE or SETTLE_ALL of that currency, comes after every swap
// action, and no swap action buys that currency (an unread one may).
// Any other such SETTLE pays an amount this decoder cannot tell.
for (const s of settledFromRouter) {
// What each SETTLE paid from the router pays. A quantity or
// CONTRACT_BALANCE pays as written. A SETTLE of OPEN_DELTA (zero)
// pays whatever the step still owes of its currency when it runs.
// That is what the step's swap actions state they spend of it only
// in the plain shape of Uniswap's own routes, so only then is it
// given that amount: every action in the step was read; every swap
// action names the currency it spends and the one it buys (an
// exact-in action with an empty path swaps nothing); the SETTLE is
// the step's only SETTLE or SETTLE_ALL of its currency and comes
// after every swap action; no swap action buys that currency; and
// the swap actions that spend it state a quantity together (see
// totalAmount()). Otherwise it is given null.
const settledFromRouter = routerSettles.map((s) => {
if (s.amount !== 0n) {
return { currency: s.currency, amount: s.amount };
}
const stated = totalAmount(
swaps
.filter((w) => w.tokenIn === s.currency)
.map((w) => w.amountIn),
);
const paysWhatSwapsSpend =
!actionUnread &&
swaps.every((w) => present(w.tokenIn) && present(w.tokenOut)) &&
settledCurrencies.filter((c) => c === s.currency).length ===
1 &&
s.index > lastSwapAction &&
!swapUndecoded &&
!swaps.some((w) => w.tokenOut === s.currency);
if (s.amount === 0n && !paysWhatSwapsSpend) {
s.amount = null;
}
}
!swaps.some((w) => w.tokenOut === s.currency) &&
typeof stated === "bigint";
return {
currency: s.currency,
amount: paysWhatSwapsSpend ? stated : null,
};
});
// Rule 3.
if (swapUndecoded) {
@@ -1164,12 +1189,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
};
// A swap step or V4 SETTLE pays `amount` of `token` from the router's
// own balance. CONTRACT_BALANCE pays all of it. Another amount the
// step states counts against the amount a WRAP_ETH stated, and the
// steps pay all of it once their amounts reach that one: a swap paid
// in ETH wraps what its swap steps then spend. An amount the step
// does not state, or one of a token whose amount is not known, pays
// out nothing this decoder can see.
// own balance. CONTRACT_BALANCE pays all of it. Any other quantity
// counts against the amount a WRAP_ETH stated, and the steps pay all
// of it once their amounts reach that one: a swap paid in ETH wraps
// what its swap steps then spend. An amount that is not a quantity,
// such as the null decodeV4Swap() gives a SETTLE whose payment it
// cannot tell, or one of a token whose amount is not known, pays out
// nothing this decoder can see.
const spendFromRouter = (token, amount) => {
const t = token.toLowerCase();
const held = heldByRouter.get(t);
@@ -1457,19 +1483,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
// A SETTLE of OPEN_DELTA (zero) that decodeV4Swap()
// left at zero pays what the step's swap actions
// spend of its currency, which the step states as its
// amount when that currency is its Token In.
// Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays.
for (const s of v4.settledFromRouter) {
let amount = s.amount;
if (amount === 0n) {
amount =
s.currency === v4.tokenIn
? v4.amountIn
: null;
}
spendFromRouter(s.currency, amount);
spendFromRouter(s.currency, s.amount);
}
for (const t of v4.takes) {
if (!isHop(i, t.currency, t.recipient)) {
+17 -3
View File
@@ -442,11 +442,25 @@ describe("a confirmation screen whose From is not the selected address", () => {
expect(Transaction.from(signed).from).toBe(derived(1).address);
});
// The refusal's sentence is fixed in index.html, in a box of its own that
// holds its space from the first paint, so showing it moves nothing.
test("nothing is sent when the selected wallet does not hold it", async () => {
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
expect(node("confirm-tx-password-error").textContent).toBe(
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
expect(node("confirm-from-address-error").style.visibility).toBe(
"visible",
);
expect(node("confirm-tx-password-error").textContent).toBe("");
// Reviewing a send again draws the screen again, which hides it.
confirmTx.show({
from: derived(0).address,
to: RECIPIENT,
amount: "0.5",
token: "ETH",
});
await settle();
expect(node("confirm-from-address-error").style.visibility).toBe(
"hidden",
);
});
});
+119
View File
@@ -3153,6 +3153,125 @@ test("a send refused for its fee leaves the password field and Sign & Send where
}
});
// --------- a send from an address the selected wallet does not hold (#609)
//
// The selection is saved apart from the confirmation screen, so the popup can
// reopen onto the screen with a wallet selected that does not hold its From
// address, and Sign & Send then refuses the send. Another popup selecting the
// key wallet would save that selection; here it is written into storage, and
// the popup reopened.
test("a send from an address the selected wallet does not hold leaves the password field and Sign & Send where they were (#609)", async (env) => {
try {
// From the HD wallet's address.
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
// The screen's own save lands first, so it cannot write over the
// selection below.
await waitForPersisted(
env.page,
"currentView",
"confirm-tx",
"before selecting the key wallet",
);
await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
const record = {
...r.autistmask,
selectedWallet: 1,
selectedAddress: 0,
};
chrome.storage.local.set(
{ autistmask: record },
resolve,
);
});
}),
);
await reopenPopup(env, "confirm-tx");
await waitForEstimate(env.page);
// The refusal shown anywhere on the screen, not only in its own box:
// shown on the password error line instead, the test fails on the
// moves checked below rather than on the wait for it.
const refusal = env.page
.locator("#view-confirm-tx")
.getByText("does not hold the From address above")
.filter({ visible: true });
const before = await confirmState(env.page);
assert(
!before.sendDisabled && (await refusal.count()) === 0,
"the screen is not ready to send: " + JSON.stringify(before),
);
const sent = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await refusal.waitFor({ timeout: 60000 });
const after = await confirmState(env.page);
// Printed pass or fail, as the dust threshold test does.
console.log(
"# refused From address: password field top " +
before.passwordTop +
" -> " +
after.passwordTop +
"px, Sign & Send top " +
before.sendTop +
" -> " +
after.sendTop +
"px",
);
assert(
env.routeOpts.broadcastTransactions.length === sent,
"a send from an address the selected wallet does not hold" +
" reached the RPC",
);
assert(
after.passwordTop === before.passwordTop,
"the password field moved " +
(after.passwordTop - before.passwordTop) +
"px when the send was refused",
);
assert(
after.sendTop === before.sendTop,
"Sign & Send moved " +
(after.sendTop - before.sendTop) +
"px when the send was refused",
);
// Only now, where the refusal is shown. Its sentence is wrapped across
// lines in index.html.
const shownIn = await refusal.evaluate((el) => ({
id: el.id,
text: el.textContent.replace(/\s+/g, " ").trim(),
}));
assert(
shownIn.id === "confirm-from-address-error" &&
shownIn.text ===
"The selected wallet does not hold the From address above," +
" so nothing was sent. Please go back and send again.",
"wrong From address refusal: " + JSON.stringify(shownIn),
);
} finally {
// A reopened popup restores no Send screen beneath this one, so Back
// lands on the selected address. Then Home, and the HD wallet's
// address, which the later tests send from.
if (await env.page.isVisible("#view-confirm-tx")) {
await env.page.click("#btn-confirm-back");
}
if (await env.page.isVisible("#view-send")) {
await env.page.click("#btn-send-back");
}
await visible(env.page, "#view-address");
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await openAddressDetail(env.page);
}
});
// ------------------------------ the wait for a receipt ending in error (#315)
//
// README.md (WaitTx) documents two ways the wait ends on the error screen: a
+94
View File
@@ -594,6 +594,34 @@ describe("any token left in the router is not received", () => {
),
];
}
// V4 SWAP_EXACT_IN of `amountIn` of `currencyIn` through one pool for
// each currency of `path` in turn, for at least `amountOutMin` of the
// last. An empty path swaps nothing.
function swapExactIn(currencyIn, path, amountIn, amountOutMin) {
return [
0x07,
coder.encode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
[
[
currencyIn,
path.map((c) => [c, 500, 10, ZERO, "0x"]),
amountIn,
amountOutMin,
],
],
),
];
}
// V4 SETTLE_ALL of `currency`, paid by the user.
function settleAll(currency, maxAmount) {
return [
0x0c,
coder.encode(["address", "uint256"], [currency, maxAmount]),
];
}
// The line giving a token left in the router.
const tokenLine = (token, symbol) => ({
label: "Token moved into the router",
@@ -709,6 +737,60 @@ describe("any token left in the router is not received", () => {
),
left: tokenLine(WETH, "WETH"),
},
// The user pays for the swap by a SETTLE_ALL or SETTLE whose currency
// the decoder cannot read, so the SETTLE from the router pays nothing.
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE_ALL that cannot be read and a SETTLE of OPEN_DELTA from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
withDirtyWord(settleAll(WETH, ONE_WETH), 0),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE by the user that cannot be read and a SETTLE of OPEN_DELTA from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
withDirtyWord(settle(WETH, ONE_WETH, true), 0),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step with an exact-in of 1 WETH less 1,000 wei and an empty path, a swap of 1,000 wei, and a SETTLE of OPEN_DELTA from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactIn(WETH, [], ONE_WETH - 1000n, 0n),
swapExactInSingle(WETH, USDC, 1000n, USDC_1990),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of all of it settled from the router, with an action the decoder does not read",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
settle(WETH, 0n, false),
takeAll(USDC),
[0x14, coder.encode(["address"], [WETH])],
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a swap paying WETH to the router, a TRANSFER of 1 wei of it to the WETH/USDC pair, then a V2 swap of what the pair holds",
data: execute(
@@ -832,6 +914,18 @@ describe("any token left in the router is not received", () => {
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then a V4 swap of all of it through two pools, settled from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactIn(WETH, [USDT, USDC], ONE_WETH, USDC_1990),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH of all the router's ETH, then a swap of all its WETH",
data: execute(