Compare commits
2
Commits
c13828000d
...
d5921f0639
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5921f0639 | ||
|
|
a3da448f0d |
No files matched your search
@@ -1260,17 +1260,29 @@ runs (Transaction Decoding below gives what the router holds). The router
|
|||||||
unwraps only the WETH it holds, so WETH that step paid to the user, or to anyone
|
unwraps only the WETH it holds, so WETH that step paid to the user, or to anyone
|
||||||
else, stays WETH: a swap that pays the router WETH, then a swap that pays the
|
else, stays WETH: a swap that pays the router WETH, then a swap that pays the
|
||||||
user WETH, then an unwrap, shows WETH with the second swap's minimum. The
|
user WETH, then an unwrap, shows WETH with the second swap's minimum. The
|
||||||
minimum reads in ETH when that step paid all its WETH to the router. When it
|
minimum reads in ETH only when that step paid all its WETH to the router and
|
||||||
paid only part of it to the router, as a V4 step does that `TAKE`s some of its
|
every step between it and the `UNWRAP_WETH` is one the decoder read of these: a
|
||||||
WETH to the user and the rest to the router, or paid the router WETH beside a
|
`PERMIT2_PERMIT`, a `BALANCE_CHECK_ERC20`, or a `SWEEP`, `TRANSFER`,
|
||||||
payout the decoder cannot read, the swap shows ETH with no minimum: the decoder
|
`PAY_PORTION` or `PAY_PORTION_FULL_PRECISION` of a token other than WETH, but
|
||||||
does not count how much of that step's minimum reaches the router, nor read the
|
not of ETH to a WETH contract, which pays the router WETH. None of these can
|
||||||
`UNWRAP_WETH`'s own minimum. A later step that sets the output side sets it
|
take WETH out of the router or pay anyone WETH; the decoder treats every other
|
||||||
anew. Otherwise `Token Out` and `Min. received` keep the output side's own token
|
step as one that may. Otherwise the swap shows ETH with no minimum, as it does
|
||||||
and figure, whether the swap was paid in ETH or in a token: a V2 exact-out swap
|
after a V4 step that `TAKE`s some of its WETH to the user and the rest to the
|
||||||
that buys USDC and then unwraps the WETH it did not spend shows USDC. The token
|
router, or that pays the router WETH beside a payout the decoder cannot read,
|
||||||
permission warning on the signature screen has its own amount wording, including
|
and with a WETH interface fee paid by `PAY_PORTION` before the `UNWRAP_WETH`:
|
||||||
`Unknown`; the SignApproval section below describes it.
|
the decoder does not count how much of that step's minimum reaches the
|
||||||
|
`UNWRAP_WETH`, nor read the `UNWRAP_WETH`'s own minimum. When an `UNWRAP_WETH`
|
||||||
|
of that WETH pays the router, or its recipient cannot be read, the router may
|
||||||
|
hold the ETH, and a later step may pay it out as WETH, as a `WRAP_ETH` of it to
|
||||||
|
the user does: after any later step but those listed, the swap shows WETH with
|
||||||
|
no minimum. Once the swap shows no minimum, or shows WETH this way, no later
|
||||||
|
step, a later `UNWRAP_WETH` included, brings back the minimum or ETH; only a
|
||||||
|
later step that sets the output side sets it anew. Otherwise `Token Out` and
|
||||||
|
`Min. received` keep the output side's own token and figure, whether the swap
|
||||||
|
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
|
||||||
|
unwraps the WETH it did not spend shows USDC. The token permission warning on
|
||||||
|
the signature screen has its own amount wording, including `Unknown`; the
|
||||||
|
SignApproval section below describes it.
|
||||||
|
|
||||||
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
|
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
|
||||||
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
|
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
|
||||||
@@ -2080,8 +2092,11 @@ view would leave a wallet one click from deletion.
|
|||||||
none, and the tokens tracked on that network, and sends `chainChanged`
|
none, and the tokens tracked on that network, and sends `chainChanged`
|
||||||
with the new chain id to every open tab. A token is tracked only on the
|
with the new chain id to every open tab. A token is tracked only on the
|
||||||
network it was added on, because a contract address names a token on one
|
network it was added on, because a contract address names a token on one
|
||||||
network only. Choosing the network already active changes nothing and
|
network only. Switching also clears every address's balances. A balance
|
||||||
sends nothing
|
refresh, by the popup or the background, stores what it fetched only if
|
||||||
|
the stored network is still the one it fetched on when it returns, so the
|
||||||
|
previous network's balances never reach the new one. Choosing the network
|
||||||
|
already active changes nothing and sends nothing
|
||||||
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
||||||
`eth_chainId` before being saved)
|
`eth_chainId` before being saved)
|
||||||
- Blockscout API: endpoint URL input + "Save" button (validated against
|
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||||||
|
|||||||
@@ -1379,6 +1379,13 @@ const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
|
|||||||
// balances that came back — by address, onto whatever storage holds NOW.
|
// balances that came back — by address, onto whatever storage holds NOW.
|
||||||
// Applying by address rather than writing the array back is what keeps a
|
// Applying by address rather than writing the array back is what keeps a
|
||||||
// wallet or address added, renamed or deleted during the round trip.
|
// wallet or address added, renamed or deleted during the round trip.
|
||||||
|
//
|
||||||
|
// Nothing is applied when the network stored NOW is not the one the refresh
|
||||||
|
// ran on. A network switch during the round trip cleared every address's
|
||||||
|
// balances (applyChainSwitchFields()), and the previous network's must not come
|
||||||
|
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577).
|
||||||
|
// lastBalanceRefresh is left as the switch set it, so the next tick refreshes
|
||||||
|
// the new network.
|
||||||
async function backgroundRefresh() {
|
async function backgroundRefresh() {
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -1402,6 +1409,7 @@ async function backgroundRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await updateState((fresh) => {
|
await updateState((fresh) => {
|
||||||
|
if (fresh.networkId !== s.networkId) return;
|
||||||
for (const wallet of fresh.wallets) {
|
for (const wallet of fresh.wallets) {
|
||||||
for (const addr of wallet.addresses || []) {
|
for (const addr of wallet.addresses || []) {
|
||||||
const got = refreshed.get(String(addr.address).toLowerCase());
|
const got = refreshed.get(String(addr.address).toLowerCase());
|
||||||
|
|||||||
+35
-4
@@ -68,23 +68,54 @@ let refreshInFlight = false;
|
|||||||
// the recovery screen: there is no profile left to refresh.
|
// the recovery screen: there is no profile left to refresh.
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
|
||||||
|
// The refresh writes into a copy of the wallets. Inside the save, under the
|
||||||
|
// lock, its results are copied onto the address records in `state` only if the
|
||||||
|
// network it ran on is still the active one, on this page and in the stored
|
||||||
|
// record; the background's switch, after the user approves a site's request,
|
||||||
|
// changes only the stored record. A switch while the refresh ran cleared every
|
||||||
|
// address's balances (applyChainSwitchFields() in
|
||||||
|
// src/shared/chainSwitchFields.js), and the previous network's must not come
|
||||||
|
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped
|
||||||
|
// refresh is not recorded as done. Results are copied by address, so an address
|
||||||
|
// added or removed meanwhile is left as it is.
|
||||||
async function doRefreshAndRender() {
|
async function doRefreshAndRender() {
|
||||||
if (refreshInFlight) return;
|
if (refreshInFlight) return;
|
||||||
refreshInFlight = true;
|
refreshInFlight = true;
|
||||||
try {
|
try {
|
||||||
|
const networkId = state.networkId;
|
||||||
|
const wallets = structuredClone(state.wallets);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshPrices(),
|
refreshPrices(),
|
||||||
refreshBalances(
|
refreshBalances(
|
||||||
state.wallets,
|
wallets,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.blockscoutUrl,
|
state.blockscoutUrl,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
state.networkId,
|
networkId,
|
||||||
pageClosed.signal,
|
pageClosed.signal,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
state.lastBalanceRefresh = Date.now();
|
const refreshed = new Map();
|
||||||
await saveState();
|
for (const wallet of wallets) {
|
||||||
|
for (const addr of wallet.addresses) {
|
||||||
|
refreshed.set(addr.address, addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveState((stored) => {
|
||||||
|
if (state.networkId !== networkId) return false;
|
||||||
|
if (stored.networkId !== networkId) return false;
|
||||||
|
for (const wallet of state.wallets) {
|
||||||
|
for (const addr of wallet.addresses) {
|
||||||
|
const got = refreshed.get(addr.address);
|
||||||
|
if (!got) continue;
|
||||||
|
for (const key of ["balance", "ensName", "tokenBalances"]) {
|
||||||
|
if (got[key] !== undefined) addr[key] = got[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.lastBalanceRefresh = Date.now();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
renderWalletList();
|
renderWalletList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Every call site fires this and walks away — the boot below, the ten
|
// Every call site fires this and walks away — the boot below, the ten
|
||||||
|
|||||||
+16
-9
@@ -467,11 +467,7 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// (caught by tests/txStatus.test.js). A page's live picture of a field it
|
// (caught by tests/txStatus.test.js). A page's live picture of a field it
|
||||||
// does not own goes on being whatever its last loadState() saw, same as
|
// does not own goes on being whatever its last loadState() saw, same as
|
||||||
// before this fix; only the persisted record is guaranteed current.
|
// before this fix; only the persisted record is guaranteed current.
|
||||||
async function saveStateOnce() {
|
async function saveStateOnce(beforeWrite) {
|
||||||
// A copy, so what this save compares and writes is the page's state as it
|
|
||||||
// stood when the save began. A change made while it waits on storage is
|
|
||||||
// left for the next save, which compares against this copy.
|
|
||||||
const current = structuredClone(snapshotPersisted());
|
|
||||||
const result = await storageGet("autistmask");
|
const result = await storageGet("autistmask");
|
||||||
// The record in storage right now is about to be merged into and written
|
// The record in storage right now is about to be merged into and written
|
||||||
// back, so it is validated exactly like a load validates it. Without this,
|
// back, so it is validated exactly like a load validates it. Without this,
|
||||||
@@ -485,6 +481,12 @@ async function saveStateOnce() {
|
|||||||
// come from storage in its loaded (self-healed) shape. See
|
// come from storage in its loaded (self-healed) shape. See
|
||||||
// normalizePersisted() in persistedState.js.
|
// normalizePersisted() in persistedState.js.
|
||||||
const fresh = normalizePersisted(result.autistmask);
|
const fresh = normalizePersisted(result.autistmask);
|
||||||
|
if (beforeWrite && beforeWrite(fresh) === false) return;
|
||||||
|
|
||||||
|
// A copy, so what this save compares and writes is the page's state as it
|
||||||
|
// stood once the stored record was read. A change made while it waits on
|
||||||
|
// the write is left for the next save, which compares against this copy.
|
||||||
|
const current = structuredClone(snapshotPersisted());
|
||||||
|
|
||||||
const merged = { ...fresh };
|
const merged = { ...fresh };
|
||||||
for (const key of PERSISTED_FIELDS) {
|
for (const key of PERSISTED_FIELDS) {
|
||||||
@@ -579,10 +581,15 @@ function reportSaveFailure(err) {
|
|||||||
// The same lock orders this page's own saves. showView() calls saveState() on
|
// The same lock orders this page's own saves. showView() calls saveState() on
|
||||||
// every navigation without awaiting it, so two saves from one page can be
|
// every navigation without awaiting it, so two saves from one page can be
|
||||||
// asked for at once; they take the lock in the order they were asked, and each
|
// asked for at once; they take the lock in the order they were asked, and each
|
||||||
// compares the page's state as it stands when its turn begins against the
|
// compares the page's state, as it stands once the stored record has been read
|
||||||
// baseline the save before it left.
|
// and `beforeWrite` has run, against the baseline the save before it left.
|
||||||
function saveState() {
|
//
|
||||||
const turn = withLock("autistmask", saveStateOnce);
|
// `beforeWrite`, when given, is called inside the lock with the stored record
|
||||||
|
// as just read, before this page's state is compared against it. It may change
|
||||||
|
// `state`, and this save writes that change. When it returns false, nothing is
|
||||||
|
// written; any other return lets the save go ahead.
|
||||||
|
function saveState(beforeWrite) {
|
||||||
|
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
|
||||||
// Every failed save is reported, whether or not the caller awaited this
|
// Every failed save is reported, whether or not the caller awaited this
|
||||||
// one. The returned promise still rejects, so a caller that DOES await
|
// one. The returned promise still rejects, so a caller that DOES await
|
||||||
// keeps its own error handling.
|
// keeps its own error handling.
|
||||||
|
|||||||
+88
-15
@@ -1221,8 +1221,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
let hasUnwrapWeth = false;
|
let hasUnwrapWeth = false;
|
||||||
// Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH,
|
// Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH,
|
||||||
// which the router holds; cleared whenever the output side is set
|
// which the router holds; cleared whenever the output side is set
|
||||||
// again.
|
// again, or a later step may pay that ETH out as WETH (see after the
|
||||||
|
// loop). `unwrapIntoRouterStep` is the index of the first such
|
||||||
|
// UNWRAP_WETH that may have paid the ETH to the router, which then
|
||||||
|
// holds it: its recipient is the router or cannot be read. A later
|
||||||
|
// UNWRAP_WETH does not replace it. It is null when there is none, and
|
||||||
|
// is cleared with `outputUnwrapped` when the output side is set again.
|
||||||
let outputUnwrapped = false;
|
let outputUnwrapped = false;
|
||||||
|
let unwrapIntoRouterStep = null;
|
||||||
|
// The index of the step that set the output side.
|
||||||
|
let outputStep = -1;
|
||||||
|
// The index of the last step this decoder treats as one that may take
|
||||||
|
// WETH out of the router or pay anyone WETH: any step but one it read
|
||||||
|
// that is a PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP,
|
||||||
|
// TRANSFER, PAY_PORTION or PAY_PORTION_FULL_PRECISION of a token
|
||||||
|
// other than WETH, and not of ETH to a WETH contract, which pays the
|
||||||
|
// router WETH (see keepsRouterWeth in the loop). None of those can.
|
||||||
|
let lastWethStep = -1;
|
||||||
// Where the step that set the output side paid its output: the
|
// Where the step that set the output side paid its output: the
|
||||||
// recipient of a swap step, as written, or of each TAKE, TAKE_ALL and
|
// recipient of a swap step, as written, or of each TAKE, TAKE_ALL and
|
||||||
// TAKE_PORTION of it in a V4 step, a TAKE_ALL as paid to the sender,
|
// TAKE_PORTION of it in a V4 step, a TAKE_ALL as paid to the sender,
|
||||||
@@ -1265,13 +1280,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
if (!present(token) && !present(amount)) return;
|
if (!present(token) && !present(amount)) return;
|
||||||
setInput(token, amount);
|
setInput(token, amount);
|
||||||
};
|
};
|
||||||
const setOutput = (token, amount, recipients) => {
|
const setOutput = (step, token, amount, recipients) => {
|
||||||
if (!present(token) && !present(amount)) return;
|
if (!present(token) && !present(amount)) return;
|
||||||
outputToken = present(token) ? token : null;
|
outputToken = present(token) ? token : null;
|
||||||
minOutput = present(amount) ? amount : null;
|
minOutput = present(amount) ? amount : null;
|
||||||
outputEstablished = true;
|
outputEstablished = true;
|
||||||
outputUnwrapped = false;
|
outputUnwrapped = false;
|
||||||
|
unwrapIntoRouterStep = null;
|
||||||
outputRecipients = recipients;
|
outputRecipients = recipients;
|
||||||
|
outputStep = step;
|
||||||
};
|
};
|
||||||
|
|
||||||
// What the steps take from the sender beyond the amount of the step
|
// What the steps take from the sender beyond the amount of the step
|
||||||
@@ -1405,6 +1422,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
v2PairFor(chainId, next.path[0], next.path[1]).toLowerCase()
|
v2PairFor(chainId, next.path[0], next.path[1]).toLowerCase()
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
// Whether a SWEEP, TRANSFER, PAY_PORTION or PAY_PORTION_FULL_PRECISION
|
||||||
|
// of `token` to `recipient` pays anyone WETH (see lastWethStep): it
|
||||||
|
// does when `token` is WETH, and when it pays ETH to a WETH contract,
|
||||||
|
// which pays the router WETH for it.
|
||||||
|
const paysWeth = (token, recipient) =>
|
||||||
|
WETH_ADDRESSES.includes(token.toLowerCase()) ||
|
||||||
|
(token === "0x0000000000000000000000000000000000000000" &&
|
||||||
|
WETH_ADDRESSES.includes(recipient.toLowerCase()));
|
||||||
|
|
||||||
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;
|
||||||
@@ -1421,6 +1446,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
? COMMAND_NAMES[command]
|
? COMMAND_NAMES[command]
|
||||||
: "Command 0x" + cmdId.toString(16).padStart(2, "0"),
|
: "Command 0x" + cmdId.toString(16).padStart(2, "0"),
|
||||||
);
|
);
|
||||||
|
// Set by a branch below that reads a step that cannot take WETH
|
||||||
|
// out of the router or pay anyone WETH (see lastWethStep).
|
||||||
|
let keepsRouterWeth = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (command === "EXECUTE_SUB_PLAN") {
|
if (command === "EXECUTE_SUB_PLAN") {
|
||||||
@@ -1452,6 +1480,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
allowedToFail,
|
allowedToFail,
|
||||||
beforeEveryTake: !tookBefore,
|
beforeEveryTake: !tookBefore,
|
||||||
};
|
};
|
||||||
|
keepsRouterWeth = true;
|
||||||
} else {
|
} else {
|
||||||
stepsUnread.push(i);
|
stepsUnread.push(i);
|
||||||
}
|
}
|
||||||
@@ -1507,6 +1536,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// what the user receives: it sets no side, and the
|
// what the user receives: it sets no side, and the
|
||||||
// step says so.
|
// step says so.
|
||||||
commandNames[i] += " (of another address)";
|
commandNames[i] += " (of another address)";
|
||||||
|
keepsRouterWeth = true;
|
||||||
} else {
|
} else {
|
||||||
// The router passes this check whenever the owner
|
// The router passes this check whenever the owner
|
||||||
// holds at least minBalance, so a zero one guarantees
|
// holds at least minBalance, so a zero one guarantees
|
||||||
@@ -1517,8 +1547,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// does. It pays nobody.
|
// does. It pays nobody.
|
||||||
const minBalance = allowedToFail ? 0n : b.minBalance;
|
const minBalance = allowedToFail ? 0n : b.minBalance;
|
||||||
if (!(minBalance === 0n && present(minOutput))) {
|
if (!(minBalance === 0n && present(minOutput))) {
|
||||||
setOutput(b.token, minBalance, []);
|
setOutput(i, b.token, minBalance, []);
|
||||||
}
|
}
|
||||||
|
keepsRouterWeth = true;
|
||||||
}
|
}
|
||||||
} else if (command === "V3_SWAP_EXACT_IN") {
|
} else if (command === "V3_SWAP_EXACT_IN") {
|
||||||
const s = decodeV3SwapExactIn(inputs[i]);
|
const s = decodeV3SwapExactIn(inputs[i]);
|
||||||
@@ -1531,7 +1562,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// Always update output: in multi-step swaps (V3 → V4),
|
// Always update output: in multi-step swaps (V3 → V4),
|
||||||
// the last swap step determines the final output token
|
// the last swap step determines the final output token
|
||||||
// and minimum received amount.
|
// and minimum received amount.
|
||||||
setOutput(s.tokenOut, s.amountOutMin, [s.recipient]);
|
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
|
||||||
if (!s.payerIsUser) {
|
if (!s.payerIsUser) {
|
||||||
spendFromRouter(s.tokenIn, s.amountIn);
|
spendFromRouter(s.tokenIn, s.amountIn);
|
||||||
}
|
}
|
||||||
@@ -1551,7 +1582,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
} else if (s.payerIsUser) {
|
} else if (s.payerIsUser) {
|
||||||
addUserPaid(s.tokenIn, s.amountIn);
|
addUserPaid(s.tokenIn, s.amountIn);
|
||||||
}
|
}
|
||||||
setOutput(s.tokenOut, s.amountOutMin, [s.recipient]);
|
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
|
||||||
if (!s.payerIsUser) {
|
if (!s.payerIsUser) {
|
||||||
spendFromRouter(s.tokenIn, s.amountIn);
|
spendFromRouter(s.tokenIn, s.amountIn);
|
||||||
}
|
}
|
||||||
@@ -1582,7 +1613,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
} else if (s.payerIsUser) {
|
} else if (s.payerIsUser) {
|
||||||
addUserPaid(s.tokenIn, s.amountInMax);
|
addUserPaid(s.tokenIn, s.amountInMax);
|
||||||
}
|
}
|
||||||
setOutput(s.tokenOut, s.amountOut, [s.recipient]);
|
setOutput(i, s.tokenOut, s.amountOut, [s.recipient]);
|
||||||
if (!isHop(i, s.tokenOut, s.recipient)) {
|
if (!isHop(i, s.tokenOut, s.recipient)) {
|
||||||
addPayee(s.recipient, s.tokenOut);
|
addPayee(s.recipient, s.tokenOut);
|
||||||
}
|
}
|
||||||
@@ -1604,6 +1635,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
if (!isRouter(s.recipient)) {
|
if (!isRouter(s.recipient)) {
|
||||||
heldByRouter.delete(s.token.toLowerCase());
|
heldByRouter.delete(s.token.toLowerCase());
|
||||||
}
|
}
|
||||||
|
keepsRouterWeth = !paysWeth(s.token, s.recipient);
|
||||||
} else {
|
} else {
|
||||||
payeeUnread = true;
|
payeeUnread = true;
|
||||||
stepsUnread.push(i);
|
stepsUnread.push(i);
|
||||||
@@ -1641,6 +1673,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
) {
|
) {
|
||||||
heldByRouter.delete(t.token.toLowerCase());
|
heldByRouter.delete(t.token.toLowerCase());
|
||||||
}
|
}
|
||||||
|
keepsRouterWeth = !paysWeth(t.token, t.recipient);
|
||||||
} else {
|
} else {
|
||||||
payeeUnread = true;
|
payeeUnread = true;
|
||||||
stepsUnread.push(i);
|
stepsUnread.push(i);
|
||||||
@@ -1765,14 +1798,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
minOutput = v4.amountOutMin;
|
minOutput = v4.amountOutMin;
|
||||||
outputEstablished = true;
|
outputEstablished = true;
|
||||||
outputUnwrapped = false;
|
outputUnwrapped = false;
|
||||||
|
unwrapIntoRouterStep = null;
|
||||||
outputRecipients = takeRecipients;
|
outputRecipients = takeRecipients;
|
||||||
|
outputStep = i;
|
||||||
} else if (v4) {
|
} else if (v4) {
|
||||||
// A step with no swap action sets only the sides its
|
// A step with no swap action sets only the sides its
|
||||||
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
|
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
|
||||||
// setInputOnce() and setOutput() leave alone a side
|
// setInputOnce() and setOutput() leave alone a side
|
||||||
// whose two halves are both unstated.
|
// whose two halves are both unstated.
|
||||||
setInputOnce(v4.tokenIn, amountIn);
|
setInputOnce(v4.tokenIn, amountIn);
|
||||||
setOutput(v4.tokenOut, v4.amountOutMin, takeRecipients);
|
setOutput(
|
||||||
|
i,
|
||||||
|
v4.tokenOut,
|
||||||
|
v4.amountOutMin,
|
||||||
|
takeRecipients,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Every V4 step, with a swap action or without, takes
|
// Every V4 step, with a swap action or without, takes
|
||||||
// from the sender what the user pays it (an amount an
|
// from the sender what the user pays it (an amount an
|
||||||
@@ -1799,12 +1839,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// among it when the step that set the output side paid it
|
// among it when the step that set the output side paid it
|
||||||
// to the router and the router still holds WETH. WETH
|
// to the router and the router still holds WETH. WETH
|
||||||
// that step paid to the user, or to anyone else, stays
|
// that step paid to the user, or to anyone else, stays
|
||||||
// WETH, so when that step also paid some of its output
|
// WETH. When that step also paid some of its output
|
||||||
// elsewhere, or made a payout this decoder did not read,
|
// elsewhere or made a payout this decoder did not read,
|
||||||
// only part of it may be unwrapped. This decoder does not
|
// or this decoder treats a step between the two as one
|
||||||
// count how much of that step's minimum then reaches the
|
// that may take WETH out of the router or pay anyone WETH
|
||||||
// router, nor read the UNWRAP_WETH's own minimum, so the
|
// (see lastWethStep), the WETH unwrapped may be only part
|
||||||
// output side keeps no figure.
|
// of the output side's, or none of it.
|
||||||
|
// This decoder does not count how much of that step's
|
||||||
|
// minimum then reaches the UNWRAP_WETH, nor read the
|
||||||
|
// UNWRAP_WETH's own minimum, so the output side keeps no
|
||||||
|
// figure.
|
||||||
|
const u = decodeUnwrapWeth(inputs[i]);
|
||||||
const toRouter = (r) => present(r) && isRouter(r);
|
const toRouter = (r) => present(r) && isRouter(r);
|
||||||
if (
|
if (
|
||||||
present(outputToken) &&
|
present(outputToken) &&
|
||||||
@@ -1813,11 +1858,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
outputRecipients.some(toRouter)
|
outputRecipients.some(toRouter)
|
||||||
) {
|
) {
|
||||||
outputUnwrapped = true;
|
outputUnwrapped = true;
|
||||||
if (!outputRecipients.every(toRouter)) {
|
if (
|
||||||
|
unwrapIntoRouterStep === null &&
|
||||||
|
(u === null || isRouter(u.recipient))
|
||||||
|
) {
|
||||||
|
unwrapIntoRouterStep = i;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!outputRecipients.every(toRouter) ||
|
||||||
|
lastWethStep > outputStep
|
||||||
|
) {
|
||||||
minOutput = null;
|
minOutput = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const u = decodeUnwrapWeth(inputs[i]);
|
|
||||||
if (u) {
|
if (u) {
|
||||||
addPayee(u.recipient);
|
addPayee(u.recipient);
|
||||||
// It pays out all the router's WETH, as ETH. Into the
|
// It pays out all the router's WETH, as ETH. Into the
|
||||||
@@ -1854,6 +1907,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
if (allowedToFail) {
|
if (allowedToFail) {
|
||||||
commandNames[i] += " (allowed to fail)";
|
commandNames[i] += " (allowed to fail)";
|
||||||
}
|
}
|
||||||
|
if (!keepsRouterWeth) {
|
||||||
|
lastWethStep = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After an UNWRAP_WETH that may have paid the output side's WETH to
|
||||||
|
// the router as ETH, a later step may pay that ETH out as WETH, as a
|
||||||
|
// WRAP_ETH of it to the user does. This decoder treats every later
|
||||||
|
// step but those lastWethStep names as one that may, and does not
|
||||||
|
// count how much of that ETH each pays out: after one, the output
|
||||||
|
// side reads as WETH with no figure. Neither this nor the loss of the
|
||||||
|
// figure in the UNWRAP_WETH branch is undone by any later step, a
|
||||||
|
// later UNWRAP_WETH included, but one that sets the output side again.
|
||||||
|
if (
|
||||||
|
outputUnwrapped &&
|
||||||
|
unwrapIntoRouterStep !== null &&
|
||||||
|
lastWethStep > unwrapIntoRouterStep
|
||||||
|
) {
|
||||||
|
outputUnwrapped = false;
|
||||||
|
minOutput = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Amount line, and the lines for what the steps take beyond it.
|
// The Amount line, and the lines for what the steps take beyond it.
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
// that followed persisted the pre-refresh values — while still stamping
|
// that followed persisted the pre-refresh values — while still stamping
|
||||||
// lastBalanceRefresh, suppressing the redo.
|
// lastBalanceRefresh, suppressing the redo.
|
||||||
//
|
//
|
||||||
// Both use the real persistence path over a cloning storage stub. Nothing here
|
// A network switch landing inside a balance refresh's round trip is covered at
|
||||||
|
// the end (https://git.eeqj.de/sneak/AutistMask/issues/577).
|
||||||
|
//
|
||||||
|
// All use the real persistence path over a cloning storage stub. Nothing here
|
||||||
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
|
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
|
||||||
// holds against any implementation that gets the outcome right.
|
// holds against any implementation that gets the outcome right.
|
||||||
|
|
||||||
@@ -395,3 +398,48 @@ describe("a balance refresh under another handler's state read", () => {
|
|||||||
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
|
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a balance refresh under a network switch", () => {
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/577. The switch clears every
|
||||||
|
// address's balances, and a refresh that started on the previous network
|
||||||
|
// must not write that network's back over them when it returns. The same
|
||||||
|
// refresh in the popup is in tests/balanceRefreshNetworkSwitch.test.js.
|
||||||
|
test("a refresh that returns after a switch writes nothing, and the next one runs on the new network", async () => {
|
||||||
|
const roundTrip = deferred();
|
||||||
|
const reachedNetwork = deferred();
|
||||||
|
const ranOn = [];
|
||||||
|
|
||||||
|
const bg = loadWorker("sepolia", {
|
||||||
|
refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
|
||||||
|
ranOn.push(id);
|
||||||
|
reachedNetwork.resolve();
|
||||||
|
await roundTrip.promise;
|
||||||
|
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = bg.fireBalanceAlarm();
|
||||||
|
await reachedNetwork.promise;
|
||||||
|
|
||||||
|
// The user switches to Ethereum Mainnet in Settings while the round
|
||||||
|
// trip is out.
|
||||||
|
const during = bg.persisted();
|
||||||
|
applyChainSwitchFields(during, MAINNET.id);
|
||||||
|
global.chrome.storage.write("autistmask", during);
|
||||||
|
|
||||||
|
roundTrip.resolve();
|
||||||
|
await refresh;
|
||||||
|
|
||||||
|
const after = bg.persisted();
|
||||||
|
expect(after.networkId).toBe("mainnet");
|
||||||
|
expect(after.wallets[0].addresses[0].balance).toBe("0");
|
||||||
|
expect(after.lastBalanceRefresh).toBe(0);
|
||||||
|
|
||||||
|
await bg.fireBalanceAlarm();
|
||||||
|
|
||||||
|
expect(ranOn).toEqual(["sepolia", "mainnet"]);
|
||||||
|
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
|
||||||
|
REFRESHED_BALANCE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// A balance refresh the popup started on one network, and that returns after
|
||||||
|
// the network was switched to another, writes nothing: the switch cleared every
|
||||||
|
// address's balances, and the previous network's must not come back over them
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/577). The switch is made either
|
||||||
|
// by the user in this popup's Settings, or by the background after the user
|
||||||
|
// approved a site's request, which changes only the stored record. Driven
|
||||||
|
// through the real popup over a stored profile on Ethereum Mainnet. The same
|
||||||
|
// refresh in the background is in tests/backgroundStateIsolation.test.js.
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
TOKEN_ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Starts the ten-second refresh and holds it open. The balance stand-in writes
|
||||||
|
// Ethereum Mainnet's balances in place, on the records handed in, as the real
|
||||||
|
// refreshBalances() does, once the returned function is called. Resolves with
|
||||||
|
// that function and the refresh still running.
|
||||||
|
async function holdRefresh(page) {
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
let answer;
|
||||||
|
refreshBalances.mockImplementationOnce(async (wallets) => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
});
|
||||||
|
wallets[0].addresses[0].balance = "7.0";
|
||||||
|
wallets[0].addresses[0].tokenBalances = [
|
||||||
|
{
|
||||||
|
address: TOKEN_ADDRESS,
|
||||||
|
name: "Test Token",
|
||||||
|
symbol: "TST",
|
||||||
|
decimals: 18,
|
||||||
|
balance: "5.0",
|
||||||
|
holders: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const ticking = page.tick();
|
||||||
|
await page.settle();
|
||||||
|
return {
|
||||||
|
finish: async () => {
|
||||||
|
answer();
|
||||||
|
await ticking;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picks `networkId` in the Settings network selector, as the user does.
|
||||||
|
async function chooseNetwork(page, networkId) {
|
||||||
|
const select = page.node("settings-network");
|
||||||
|
select.value = networkId;
|
||||||
|
for (const fn of select.listeners.change) await fn();
|
||||||
|
await page.settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored record holds none of Ethereum Mainnet's balances, and no refresh
|
||||||
|
// recorded as done.
|
||||||
|
function expectNothingStored(page) {
|
||||||
|
const stored = page.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.wallets[0].addresses[0].balance).toBe("0");
|
||||||
|
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
||||||
|
expect(stored.lastBalanceRefresh).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a refresh that returns after a switch in Settings writes nothing, and the next one runs on the new network", async () => {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
await page.click("btn-settings");
|
||||||
|
await chooseNetwork(page, "sepolia");
|
||||||
|
await refresh.finish();
|
||||||
|
|
||||||
|
expect(state.wallets[0].addresses[0].balance).toBe("0");
|
||||||
|
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
||||||
|
expectNothingStored(page);
|
||||||
|
|
||||||
|
await page.click("btn-settings-back");
|
||||||
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
|
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
|
||||||
|
|
||||||
|
await page.tick();
|
||||||
|
|
||||||
|
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
|
||||||
|
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The stored record still holds Ethereum Mainnet while the refresh's save reads
|
||||||
|
// it, so only this page's own network shows the switch.
|
||||||
|
test("a refresh whose save is reading the stored record when the user switches in Settings writes nothing", async () => {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
await page.click("btn-settings");
|
||||||
|
|
||||||
|
// The next read of the stored record, the refresh's save's, is held.
|
||||||
|
const get = page.storage.local.get;
|
||||||
|
const read = get.getMockImplementation();
|
||||||
|
let releaseRead;
|
||||||
|
get.mockImplementationOnce(async (key) => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
releaseRead = resolve;
|
||||||
|
});
|
||||||
|
return read(key);
|
||||||
|
});
|
||||||
|
const finishing = refresh.finish();
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
// The user picks Sepolia meanwhile; the switch's own save waits for the
|
||||||
|
// refresh's save to let the lock go.
|
||||||
|
const select = page.node("settings-network");
|
||||||
|
select.value = "sepolia";
|
||||||
|
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
releaseRead();
|
||||||
|
await finishing;
|
||||||
|
await switching;
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(state.networkId).toBe("sepolia");
|
||||||
|
expect(state.wallets[0].addresses[0].balance).toBe("0");
|
||||||
|
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
||||||
|
expectNothingStored(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a refresh that returns after the background switched the network writes nothing", async () => {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
const { saveState } = require("../src/shared/state");
|
||||||
|
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
// The background writes the switch to Sepolia, as its updateState() does
|
||||||
|
// once the user approves a site's request. This page is not told.
|
||||||
|
const record = page.storage.read("autistmask");
|
||||||
|
applyChainSwitchFields(record, "sepolia");
|
||||||
|
page.storage.write("autistmask", record);
|
||||||
|
await refresh.finish();
|
||||||
|
|
||||||
|
expectNothingStored(page);
|
||||||
|
|
||||||
|
// Nor does a later save by this page, such as every navigation makes.
|
||||||
|
await saveState();
|
||||||
|
|
||||||
|
expectNothingStored(page);
|
||||||
|
});
|
||||||
+361
-10
@@ -11,7 +11,8 @@
|
|||||||
// what each PERMIT2_TRANSFER_FROM moves
|
// what each PERMIT2_TRANSFER_FROM moves
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/611), and when an
|
// (https://git.eeqj.de/sneak/AutistMask/issues/611), and when an
|
||||||
// UNWRAP_WETH makes the output ETH
|
// UNWRAP_WETH makes the output ETH
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/607).
|
// (https://git.eeqj.de/sneak/AutistMask/issues/607 and
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/642).
|
||||||
//
|
//
|
||||||
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
|
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
|
||||||
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
|
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
|
||||||
@@ -745,6 +746,365 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/642: a step between the swap and
|
||||||
|
// the UNWRAP_WETH may take WETH out of the router or pay it WETH, and a step
|
||||||
|
// after an UNWRAP_WETH into the router may pay its ETH out as WETH, yet each
|
||||||
|
// route read as ETH with the swap's minimum. A step the decoder read that is a
|
||||||
|
// PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or
|
||||||
|
// PAY_PORTION_FULL_PRECISION of a token other than WETH, and not of ETH to a
|
||||||
|
// WETH contract, cannot take WETH out of the router or pay anyone WETH; the
|
||||||
|
// decoder treats every other step as one that may.
|
||||||
|
describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
|
||||||
|
const NO_MINIMUM_TO_USER = PAID_TO_USER.filter(
|
||||||
|
(l) => l !== "Min. received",
|
||||||
|
);
|
||||||
|
const NO_MINIMUM_ELSEWHERE = PAID_ELSEWHERE.filter(
|
||||||
|
(l) => l !== "Min. paid out",
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
kind: "a TRANSFER of 0.0025 WETH",
|
||||||
|
step: transfer(0x05, WETH, OTHER, ONE_WETH / 400n),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a PAY_PORTION of 25 bips",
|
||||||
|
step: transfer(0x06, WETH, OTHER, 25n),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a PAY_PORTION_FULL_PRECISION of 1e15",
|
||||||
|
step: transfer(0x07, WETH, OTHER, 10n ** 15n),
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"$kind to another address between them reads as ETH with no minimum",
|
||||||
|
({ step }) => {
|
||||||
|
const result = decode(
|
||||||
|
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
|
||||||
|
USER,
|
||||||
|
ROUTER_V2_1_2,
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → ETH");
|
||||||
|
expect(labels(result)).toEqual(NO_MINIMUM_ELSEWHERE);
|
||||||
|
expect(paidTo(result)).toEqual([OTHER]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
kind: "a SWEEP whose recipient cannot be read",
|
||||||
|
step: withDirtyWord(sweep(WETH, OTHER), 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a V4 SETTLE of 0.5 WETH from the router",
|
||||||
|
step: v4Swap([
|
||||||
|
0x0b,
|
||||||
|
coder.encode(
|
||||||
|
["address", "uint256", "bool"],
|
||||||
|
[WETH, ONE_WETH / 2n, false],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a bridge deposit of 0.5 WETH",
|
||||||
|
step: acrossDeposit(USER, USER, WETH, ONE_WETH / 2n),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a PERMIT2_TRANSFER_FROM of 0.5 WETH into the router",
|
||||||
|
step: permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH / 2n),
|
||||||
|
},
|
||||||
|
])("$kind between them reads as ETH with no minimum", ({ step }) => {
|
||||||
|
const result = decode(
|
||||||
|
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → ETH");
|
||||||
|
expect(detail(result, "Min. received")).toBeUndefined();
|
||||||
|
expect(detail(result, "Min. paid out")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
kind: "a PERMIT2_PERMIT between them",
|
||||||
|
steps: [permit2Permit(USDT, USDT_2000), unwrapWeth(MSG_SENDER)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a BALANCE_CHECK_ERC20 of another address between them",
|
||||||
|
steps: [balanceCheck(USDC, 1n, OTHER), unwrapWeth(MSG_SENDER)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a BALANCE_CHECK_ERC20 of the sender with a zero minimum between them",
|
||||||
|
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a SWEEP of USDC between them",
|
||||||
|
steps: [sweep(USDC, MSG_SENDER), unwrapWeth(MSG_SENDER)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "an UNWRAP_WETH into the router, then a SWEEP of its ETH,",
|
||||||
|
steps: [unwrapWeth(ADDRESS_THIS), sweep(ZERO, MSG_SENDER)],
|
||||||
|
},
|
||||||
|
])("$kind reads as ETH with the swap's minimum", ({ steps }) => {
|
||||||
|
const result = decode(execute(swapToWeth(ADDRESS_THIS), ...steps));
|
||||||
|
expect(result.name).toBe("Swap USDT → ETH");
|
||||||
|
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a V4 step that TAKEs its WETH to the router, then an UNWRAP_WETH to the user, reads as ETH with the swap's minimum", () => {
|
||||||
|
const result = decode(
|
||||||
|
execute(
|
||||||
|
v4Swap(
|
||||||
|
SWAP_USDT_FOR_WETH,
|
||||||
|
SETTLE_USDT,
|
||||||
|
take(WETH, ADDRESS_THIS),
|
||||||
|
),
|
||||||
|
unwrapWeth(MSG_SENDER),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → ETH");
|
||||||
|
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
kind: "a TRANSFER of 100 USDC",
|
||||||
|
step: transfer(0x05, USDC, OTHER, 100000000n),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a PAY_PORTION of 25 bips of USDC",
|
||||||
|
step: transfer(0x06, USDC, OTHER, 25n),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a PAY_PORTION_FULL_PRECISION of 1e15 of USDC",
|
||||||
|
step: transfer(0x07, USDC, OTHER, 10n ** 15n),
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"$kind to another address between them reads as ETH with the swap's minimum",
|
||||||
|
({ step }) => {
|
||||||
|
const result = decode(
|
||||||
|
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
|
||||||
|
USER,
|
||||||
|
ROUTER_V2_1_2,
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → ETH");
|
||||||
|
expect(paidTo(result)).toEqual([OTHER]);
|
||||||
|
expect(detail(result, "Min. paid out").value).toBe("1.0000 ETH");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
kind: "a TRANSFER of 0.0025 ETH",
|
||||||
|
step: transfer(0x05, ZERO, OTHER, ONE_WETH / 400n),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a PAY_PORTION of 25 bips of its ETH",
|
||||||
|
step: transfer(0x06, ZERO, OTHER, 25n),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a PAY_PORTION_FULL_PRECISION of 1e15 of its ETH",
|
||||||
|
step: transfer(0x07, ZERO, OTHER, 10n ** 15n),
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"an UNWRAP_WETH into the router, then $kind to another address, then a SWEEP of its ETH to the user, reads as ETH with the swap's minimum",
|
||||||
|
({ step }) => {
|
||||||
|
const result = decode(
|
||||||
|
execute(
|
||||||
|
swapToWeth(ADDRESS_THIS),
|
||||||
|
unwrapWeth(ADDRESS_THIS),
|
||||||
|
step,
|
||||||
|
sweep(ZERO, MSG_SENDER),
|
||||||
|
),
|
||||||
|
USER,
|
||||||
|
ROUTER_V2_1_2,
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → ETH");
|
||||||
|
expect(paidTo(result)).toEqual([OTHER]);
|
||||||
|
expect(detail(result, "Min. paid out").value).toBe("1.0000 ETH");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the
|
||||||
|
// WRAP_ETH's.
|
||||||
|
test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => {
|
||||||
|
const result = decode(
|
||||||
|
execute(
|
||||||
|
swapToWeth(ADDRESS_THIS),
|
||||||
|
sweep(WETH, MSG_SENDER),
|
||||||
|
wrapEth(ADDRESS_THIS, ONE_WETH),
|
||||||
|
unwrapWeth(MSG_SENDER),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → ETH");
|
||||||
|
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => {
|
||||||
|
const result = decode(
|
||||||
|
execute(
|
||||||
|
swapToWeth(ADDRESS_THIS),
|
||||||
|
unwrapWeth(ADDRESS_THIS),
|
||||||
|
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → WETH");
|
||||||
|
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The router reads the recipient from the last 20 bytes of its word,
|
||||||
|
// here address(2), the router itself.
|
||||||
|
test("an UNWRAP_WETH whose recipient cannot be read, then a WRAP_ETH of all the router's ETH to the user, reads as WETH with no minimum", () => {
|
||||||
|
const result = decode(
|
||||||
|
execute(
|
||||||
|
swapToWeth(ADDRESS_THIS),
|
||||||
|
withDirtyWord(unwrapWeth(ADDRESS_THIS), 0),
|
||||||
|
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → WETH");
|
||||||
|
expect(detail(result, "Min. received")).toBeUndefined();
|
||||||
|
expect(detail(result, "Min. paid out")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The first UNWRAP_WETH may pay the router the swap's output as ETH, and
|
||||||
|
// the WRAP_ETH to the user pays all of it out as WETH, so the last
|
||||||
|
// UNWRAP_WETH finds none of it.
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
kind: "an UNWRAP_WETH whose recipient cannot be read, then a WRAP_ETH of all the router's ETH to the user",
|
||||||
|
steps: [
|
||||||
|
withDirtyWord(unwrapWeth(ADDRESS_THIS), 0),
|
||||||
|
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "an UNWRAP_WETH into the router, a WRAP_ETH of all its ETH to the user, then a WRAP_ETH of all its ETH into the router",
|
||||||
|
steps: [
|
||||||
|
unwrapWeth(ADDRESS_THIS),
|
||||||
|
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
|
||||||
|
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"$kind, then an UNWRAP_WETH to the user, reads as WETH with no minimum",
|
||||||
|
({ steps }) => {
|
||||||
|
const result = decode(
|
||||||
|
execute(
|
||||||
|
swapToWeth(ADDRESS_THIS),
|
||||||
|
...steps,
|
||||||
|
unwrapWeth(MSG_SENDER),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → WETH");
|
||||||
|
expect(detail(result, "Min. received")).toBeUndefined();
|
||||||
|
expect(detail(result, "Min. paid out")).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// A SWEEP of WETH pays out WETH, and ETH paid to a WETH contract pays the
|
||||||
|
// router WETH for it. The decoder treats each as a step that may pay out
|
||||||
|
// as WETH the swap's output, which the router holds as ETH.
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
kind: "a SWEEP of WETH to the user",
|
||||||
|
step: sweep(WETH, MSG_SENDER),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a SWEEP of its ETH to the WETH contract",
|
||||||
|
step: sweep(ZERO, WETH),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a TRANSFER of 0.5 ETH to the WETH contract",
|
||||||
|
step: transfer(0x05, ZERO, WETH, ONE_WETH / 2n),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "a PAY_PORTION of all its ETH to the WETH contract",
|
||||||
|
step: transfer(0x06, ZERO, WETH, 10000n),
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"an UNWRAP_WETH into the router, then $kind, reads as WETH with no minimum",
|
||||||
|
({ step }) => {
|
||||||
|
const result = decode(
|
||||||
|
execute(
|
||||||
|
swapToWeth(ADDRESS_THIS),
|
||||||
|
unwrapWeth(ADDRESS_THIS),
|
||||||
|
step,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → WETH");
|
||||||
|
expect(detail(result, "Min. received")).toBeUndefined();
|
||||||
|
expect(detail(result, "Min. paid out")).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The user receives 0.5 WETH and the rest of the swap's output as ETH.
|
||||||
|
test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => {
|
||||||
|
const result = decode(
|
||||||
|
execute(
|
||||||
|
swapToWeth(ADDRESS_THIS),
|
||||||
|
unwrapWeth(ADDRESS_THIS),
|
||||||
|
wrapEth(MSG_SENDER, ONE_WETH / 2n),
|
||||||
|
sweep(ZERO, MSG_SENDER),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → WETH");
|
||||||
|
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps
|
||||||
|
// the ETH an earlier V4 step paid the router.
|
||||||
|
test("an UNWRAP_WETH to the user, then a WRAP_ETH of all the router's ETH to the user, reads as ETH", () => {
|
||||||
|
const result = decode(
|
||||||
|
execute(
|
||||||
|
v4Swap(
|
||||||
|
SWAP_USDT_FOR_ETH,
|
||||||
|
SETTLE_USDT,
|
||||||
|
take(ZERO, ADDRESS_THIS),
|
||||||
|
),
|
||||||
|
swapToWeth(ADDRESS_THIS),
|
||||||
|
unwrapWeth(MSG_SENDER),
|
||||||
|
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(detail(result, "Token Out").value).toBe("ETH");
|
||||||
|
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The first swap is for at least 0.5 WETH and the second for at least
|
||||||
|
// 1 WETH. The second sets the output side again, so the first
|
||||||
|
// UNWRAP_WETH into the router no longer bears on it.
|
||||||
|
test.each([
|
||||||
|
{ kind: "a V3 swap step", step: swapToWeth(ADDRESS_THIS) },
|
||||||
|
{
|
||||||
|
kind: "a V4 step that TAKEs its WETH to the router",
|
||||||
|
step: v4Swap(
|
||||||
|
SWAP_USDT_FOR_WETH,
|
||||||
|
SETTLE_USDT,
|
||||||
|
take(WETH, ADDRESS_THIS),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"a swap paying WETH to the router, an UNWRAP_WETH into the router, a SWEEP of its ETH to the user, then $kind and an UNWRAP_WETH to the user, reads as ETH with the second swap's minimum",
|
||||||
|
({ step }) => {
|
||||||
|
const result = decode(
|
||||||
|
execute(
|
||||||
|
v3SwapExactIn(
|
||||||
|
ADDRESS_THIS,
|
||||||
|
USDT_2000,
|
||||||
|
ONE_WETH / 2n,
|
||||||
|
USDT,
|
||||||
|
WETH,
|
||||||
|
),
|
||||||
|
unwrapWeth(ADDRESS_THIS),
|
||||||
|
sweep(ZERO, MSG_SENDER),
|
||||||
|
step,
|
||||||
|
unwrapWeth(MSG_SENDER),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(result.name).toBe("Swap USDT → ETH");
|
||||||
|
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the
|
// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the
|
||||||
// router stays there unless a later step pays out or spends all of it, not
|
// router stays there unless a later step pays out or spends all of it, not
|
||||||
// only the swap's output. A V4 SETTLE can take more of the user's tokens than
|
// only the swap's output. A V4 SETTLE can take more of the user's tokens than
|
||||||
@@ -1711,15 +2071,6 @@ describe("a payout of part of the router's output", () => {
|
|||||||
),
|
),
|
||||||
min: "1.0000 WETH",
|
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",
|
kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL",
|
||||||
data: execute(
|
data: execute(
|
||||||
|
|||||||
Reference in new issue
Block a user