Author SHA1 Message Date
sneak 30b089a3f5 fix: the address screen shows a token just added at its fetched balance, not 0.0000 (closes #516)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A balance refresh that returns now draws the balances on AddressDetail or
AddressToken again when that screen is up, not only the wallet list; their
transaction lists are left alone. Every add path on AddToken and
SettingsAddToken tracks a token through one step, trackToken(), which also
lists it in the new stored field tokensNotFetched: its line reads `quantity
unknown` instead of 0.0000 until a refresh that started with it tracked has
fetched every address's token balances, even if the popup closes first. To
tell an address whose token balances the explorer did not return, the refresh
takes them off its copy of the wallets before fetching.

Model: opus-5-5
2026-10-21 04:46:34 +00:00
clawbot 4be625dc06 fix: an allowance to another spender leaves no Amount figure for its token and no Min. received (closes #658)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Code that a step runs, such as a V4 pool's hook, can call a spender other
than the router, which can then take up to its allowance from the user's
wallet before the transaction ends. The Amount figure and Min. received took
no account of it.

In decode(), an allowance of Token In to a spender other than the router
now leaves no Amount line, as an unread permit already did, so the swap
steps' own takes get lines of their own. Any allowance to another spender,
or a permit or batch the decoder cannot read, makes the minimum read
Min. paid out. Permits to the router read as before.

Model: opus-5-5
2026-10-21 06:24:51 +02:00
clawbot e2965469ba fix: with no Amount line, the user's takes the total adds up get lines of their own (closes #659)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
When a step this decoder cannot read, an unreadable permit, or a take
of Token In with an unknown amount held back the Amount line, the
amount the step that sets the input side states, and each later take
of Token In added to it, appeared on no line, while other takes kept
their own Moved from the sender lines. Those takes now get the same
lines, first. The first step's amount gets them only when the user
pays that step (a V2 or V3 step with payerIsUser true, a V4 step the
user pays some of its input token, or a WRAP_ETH): one paid from the
router takes nothing from the wallet. Routes with an Amount line read
as before.

Model: opus-5-5
2026-10-21 05:15:21 +02:00
15 changed files with 915 additions and 170 deletions
+67 -30
View File
@@ -1173,8 +1173,8 @@ fetched, without that cross-address check, and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless
only the user is paid, see Transaction Decoding), do not always read as a
number. They can read:
only the user is paid and no allowance goes to a spender other than the router,
see Transaction Decoding), do not always read as a number. They can read:
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
truncated to four decimals, with the floor and the zero cases described above.
@@ -1396,7 +1396,9 @@ lists, the send-screen token selector, the address total and the remove-address
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on.
zero balance" is on; a token just added on AddToken or SettingsAddToken whose
balance no refresh has fetched yet is listed there as `quantity unknown` (see
AddToken).
#### Stored state and its version
@@ -1567,6 +1569,13 @@ such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it.
The popup refreshes balances every ten seconds while it is open
(`doRefreshAndRender()` in `src/popup/index.js`). Each refresh that returns
draws the wallet list again and, when AddressDetail or AddressToken is up, that
screen's USD total and balance lines; its transaction list is left alone. No
other screen is drawn again by a refresh, DeleteAddress and its balance lines
included.
A network switch leaves every screen on the page showing the previous network.
The user makes it in Settings, or approves a site's request and the background
saves it. Any other popup open meanwhile takes the switch from the stored
@@ -2078,7 +2087,14 @@ view would leave a wallet one click from deletion.
- "Add" button
- **Transitions**:
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
**AddressDetail**
**AddressDetail**. The token's balance is not known until a balance
refresh that started with it tracked has fetched every address's token
balances; until then its balance line reads `quantity unknown`, never
`0.0000`, unless the explorer had already listed a holding of it. That
holds after the popup is closed and opened again, as the tokens still
waiting for a fetch are stored with the profile. A refresh that returns
draws AddressDetail's balances again (see Navigation), so the fetched
balance shows without the screen being reopened
- "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change
- "Add", then a network switch in Settings before the contract lookup
@@ -2355,7 +2371,9 @@ view would leave a wallet one click from deletion.
add path
- **Transitions**:
- Any of the add paths shown, on success → adds the token and shows an
"Added SYMBOL" flash message (no screen change)
"Added SYMBOL" flash message (no screen change). Until its balance is
fetched, its balance line reads `quantity unknown`, as after "Add" on
AddToken
- A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change
- "Add" by contract address, then a network switch in Settings before the
@@ -3197,8 +3215,9 @@ Currently supported:
`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
TransactionDetail), no `Paid to` line is shown, and no permit or batch grants
an allowance to a spender other than the router (see below). `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)`
@@ -3252,7 +3271,8 @@ Currently supported:
(listed at the end of this section), a step whose input it cannot read, in
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before
the step that sets the input side or after it, leaves `Token In` and the
`Amount` line as they read with no permit: a permit of 5,000 DAI to another
`Amount` line as they read with no permit, unless it lets a spender other than
the router take `Token In` (see below): a permit of 5,000 DAI to another
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI
to the router. When a permit's amount stands and the step that sets the input
@@ -3263,15 +3283,25 @@ Currently supported:
of a token other than `Token In`; one of `Token In` after an amount that is
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one
whose amount the decoder does not know. When the step that sets the input side
states no amount, the user pays it some of its input token, and there is no
`Amount` line, that step gets them too whenever another take's lines give a
figure, or a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines
below or, into the router, on its `Paid to` line, so that figure cannot read
as all that leaves. When it states no amount and the user pays it none of its
input token, as when the router pays it, that step takes none of that token
from the user's wallet and never gets them, with a permit or without. They
come in step order, the step that sets the input side first when it has them:
whose amount the decoder does not know. When there is no `Amount` line, each
take the total counts gets them too, so that every amount the decoder reads is
shown: the amount the step that sets the input side states, when it is a
quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is
true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose
ETH comes from the transaction's value), and each later take of `Token In`
added to it. A step that sets the input side and is paid from the router takes
nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder
cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give
lines for both amounts. When the step that sets the input side states no
amount, the user pays it some of its input token, and there is no `Amount`
line, that step gets them too whenever another take's lines give a figure, or
a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into
the router, on its `Paid to` line, so that figure cannot read as all that
leaves. When it states no amount and the user pays it none of its input token,
as when the router pays it, that step takes none of that token from the user's
wallet and never gets them, with a permit or without. They come in step order,
except that the step that sets the input side comes first when it has them,
followed, when there is no `Amount` line, by the other takes the total counts:
`Moved from the sender`, its amount, worded as the `Amount` line words one, or
`Unknown (could not be decoded)` when the decoder does not know it;
`Token moved`, giving the token's address as the `Token In` line does; and
@@ -3309,19 +3339,26 @@ Currently supported:
`PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` whose input the decoder cannot
read, such as one whose token or spender word has a nonzero top byte, may let
any spender take any amount of any token: it gives one `Permit2 allowance`
line alone, reading `Unknown (could not be decoded)`, and leaves no `Amount`
line, whose figure might be too low. These lines change neither the `Paid to`
lines nor the minimum's label: a permit or batch pays nobody. A step goes
unread when its recipient does not decode as an address, when it is a V3 swap
step whose path is shorter than one token address or has bytes after its last
hop, or when it is a command the decoder does not read: every command other
than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`, `PERMIT2_TRANSFER_FROM`,
`V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`,
`V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
`PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
`UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`PERMIT2_TRANSFER_FROM_BATCH`,
`EXECUTE_SUB_PLAN` and the NFT commands among them), or a type its release
names no command for.
line alone, reading `Unknown (could not be decoded)`. These lines change no
`Paid to` line: a permit or batch pays nobody. But code that a step runs, such
as a V4 pool's hook or a token's own code, can call a spender other than the
router, which can then take up to its allowance from the user's wallet before
the transaction ends. So an allowance to any spender but the router, or a
permit or batch the decoder cannot read, makes the minimum read
`Min. paid out`, and when its token is `Token In`, or the decoder does not
know its token or `Token In`, it leaves no `Amount` line, whose figure might
be too low: a permit of 5,000 USDT to another address and then a V3 exact-in
of 600 USDT read with no `Amount` line, and the 600 USDT gets lines of its
own. A step goes unread when its recipient does not decode as an address, when
it is a V3 swap step whose path is shorter than one token address or has bytes
after its last hop, or when it is a command the decoder does not read: every
command other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`,
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`,
`WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
(`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among
them), or a type its release names no command for.
### Non-Goals Forever
+27
View File
@@ -82,12 +82,26 @@ let refreshTimer = null;
// (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.
//
// Once the save is done, the wallet list is drawn again, and so are the
// balances on AddressDetail or AddressToken when that is the screen up. No
// other screen is drawn again, DeleteAddress and its balance lines included.
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
try {
const networkId = state.networkId;
// The tracked tokens this refresh asks the explorer about. A token
// added on AddToken after this point is not among them, so this
// refresh does not count as having fetched it.
const tokens = state.trackedTokens.map((t) => t.address.toLowerCase());
const wallets = structuredClone(state.wallets);
// Taken off the copy, so that an address whose token balances the
// explorer did not return comes back without any and keeps its
// stored ones.
for (const wallet of wallets) {
for (const addr of wallet.addresses) delete addr.tokenBalances;
}
await Promise.all([
refreshPrices(),
refreshBalances(
@@ -100,9 +114,13 @@ async function doRefreshAndRender() {
),
]);
const refreshed = new Map();
let allTokenBalancesFetched = true;
for (const wallet of wallets) {
for (const addr of wallet.addresses) {
refreshed.set(addr.address, addr);
if (addr.tokenBalances === undefined) {
allTokenBalancesFetched = false;
}
}
}
await saveState((stored) => {
@@ -117,10 +135,19 @@ async function doRefreshAndRender() {
}
}
}
// Each token this refresh asked about is now fetched for every
// address.
if (allTokenBalancesFetched) {
state.tokensNotFetched = state.tokensNotFetched.filter(
(token) => !tokens.includes(token),
);
}
state.lastBalanceRefresh = Date.now();
return true;
});
renderWalletList();
if (state.currentView === "address") addressDetail.renderBalances();
if (state.currentView === "address-token") addressToken.renderBalance();
} catch (e) {
// Every call site fires this and walks away — the boot below, the ten
// second interval, and eight views through ctx — so it must never
+9 -2
View File
@@ -1,4 +1,11 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
@@ -72,7 +79,7 @@ function init(ctx) {
return;
}
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
trackToken({
address: contractAddr,
symbol: info.symbol,
decimals: info.decimals,
+17 -8
View File
@@ -65,11 +65,25 @@ function show() {
});
$("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden");
renderBalances();
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
// The USD total and the balance list. Drawn again by each balance refresh
// that returns while this screen is up (doRefreshAndRender() in
// src/popup/index.js), which leaves the rest of the screen as it is.
function renderBalances() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
$("address-balances").innerHTML = balanceLinesForAddress(
addr,
state.trackedTokens,
@@ -83,11 +97,6 @@ function show() {
ctx.showAddressToken();
});
});
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
let loadedTxs = [];
@@ -280,4 +289,4 @@ function init(_ctx) {
exportPrivkey.init();
}
module.exports = { init, show };
module.exports = { init, show, renderBalances };
+45 -32
View File
@@ -48,37 +48,18 @@ function show() {
const addr = wallet.addresses[state.selectedAddress];
const ai = state.selectedAddress;
const tokenId = state.selectedToken;
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = nativeCurrency();
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
);
// null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
amount = tb ? unknownableAmount(tb.balance) : 0;
price = getPrice(symbol);
}
currentSymbol = symbol;
currentSymbol =
tokenId === "ETH"
? nativeCurrency()
: resolveSymbol(tokenId, addr.tokenBalances, state.trackedTokens);
$("address-token-title").textContent =
wallet.name +
" \u2014 Address " +
(ai + 1) +
" \u2014 " +
displaySymbol(symbol);
displaySymbol(currentSymbol);
// Blockie
const blockieEl = $("address-token-jazzicon");
@@ -100,13 +81,7 @@ function show() {
$("address-token-line").dataset.full = addr.address;
attachCopyHandlers($("address-token-line"));
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
renderBalance();
// Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info");
@@ -168,6 +143,44 @@ function show() {
loadTransactions(addr.address, tokenId);
}
// The USD total and the balance line of the selected token. Drawn again by
// each balance refresh that returns while this screen is up
// (doRefreshAndRender() in src/popup/index.js), which leaves the rest of the
// screen as it is.
function renderBalance() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const tokenId = state.selectedToken;
let amount, price;
if (tokenId === "ETH") {
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const id = tokenId.toLowerCase();
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === id,
);
// null when the scale is unknown, or when no refresh has fetched a
// token just added: no quantity to show, and none to price.
// balanceLine() states that rather than printing 0.0000.
if (tb) amount = unknownableAmount(tb.balance);
else amount = state.tokensNotFetched.includes(id) ? null : 0;
price = getPrice(currentSymbol);
}
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(
currentSymbol,
amount,
price,
);
}
async function loadTransactions(address, tokenId) {
try {
const rawTxs = await fetchRecentTransactions(
@@ -343,4 +356,4 @@ function init(_ctx) {
});
}
module.exports = { init, show };
module.exports = { init, show, renderBalance };
+17 -3
View File
@@ -380,6 +380,18 @@ function balanceLine(symbol, amount, price, tokenId) {
);
}
// Tracks `token` on the active network, as every add path on AddToken and
// SettingsAddToken does, and lists it in `state.tokensNotFetched`: no refresh
// has fetched its balance yet. An address with no balance row for it has no
// known balance of it, so its line says the quantity is unknown rather than
// 0.0000. doRefreshAndRender() in src/popup/index.js takes it off once a
// refresh that started with it tracked has fetched every address's token
// balances (https://git.eeqj.de/sneak/AutistMask/issues/516).
function trackToken(token) {
state.trackedTokens.push(token);
state.tokensNotFetched.push(token.address.toLowerCase());
}
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
nativeCurrency(),
@@ -409,12 +421,13 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
}
if (showZero && trackedTokens) {
for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue;
const id = t.address.toLowerCase();
if (seen.has(id)) continue;
html += balanceLine(
t.symbol,
0,
state.tokensNotFetched.includes(id) ? null : 0,
getPrice(t.symbol),
t.address.toLowerCase(),
id,
);
}
}
@@ -786,6 +799,7 @@ module.exports = {
flashCopyFeedback,
balanceLine,
balanceLinesForAddress,
trackToken,
addressHoldsFunds,
keyMaterialName,
unknownableAmount,
+11 -4
View File
@@ -1,4 +1,11 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
@@ -43,7 +50,7 @@ function renderTop10() {
decimals: parseInt(btn.dataset.decimals, 10),
name: btn.dataset.name || btn.dataset.symbol,
};
state.trackedTokens.push(token);
trackToken(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
@@ -110,7 +117,7 @@ function init(_ctx) {
decimals: parseInt(opt.dataset.decimals, 10),
name: opt.dataset.name || opt.dataset.symbol,
};
state.trackedTokens.push(token);
trackToken(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
@@ -156,7 +163,7 @@ function init(_ctx) {
return;
}
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
trackToken({
address: addr,
symbol: info.symbol,
decimals: info.decimals,
+9
View File
@@ -28,6 +28,13 @@ const DEFAULT_STATE = {
// others are restored from when the active network changes. See
// applyChainSwitchFields().
networkTrackedTokens: {},
// Contract addresses, lowercased, of the tokens the user added that no
// balance refresh has fetched yet. trackToken() in
// src/popup/views/helpers.js adds one, and doRefreshAndRender() in
// src/popup/index.js takes it off. Stored, so a token added just before
// the popup closed still reads `quantity unknown` when it opens again
// (https://git.eeqj.de/sneak/AutistMask/issues/516).
tokensNotFetched: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
@@ -412,6 +419,8 @@ function normalizePersisted(saved) {
// so a stored string walks through the `|| []` and a stored number walks
// through an Array.isArray().
out.fraudContracts = textList(saved.fraudContracts);
// Contract addresses too, read with `includes()` and `filter()`.
out.tokensNotFetched = textList(saved.tokensNotFetched);
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
+60 -19
View File
@@ -1234,9 +1234,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputToken = null;
let inputAmount = null;
let inputEstablished = false;
// Whether the user pays the V4 step that set the input side any of
// its input currency (see decodeV4Swap()). Only a V4 step can set the
// input side with no amount, the one case in which this is read.
// Whether the user pays the step that set the input side: a V2 or V3
// swap step with `payerIsUser` true, a V4 step the user pays some of
// its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH
// comes from the transaction's value. A step paid from the router
// takes nothing from the sender.
let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
@@ -1353,8 +1355,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
};
// A PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH at `step` whose input this
// decoder cannot read may let any spender take any amount of any
// token: one line says so, and there is no Amount line, whose figure
// may then be too low.
// token: one line says so, there is no Amount line, whose figure may
// then be too low, and the minimum reads Min. paid out (see
// toAnotherSpender).
const permitUnread = (step) => {
permits.push({ token: null, amount: null, spender: null, step });
stepsUnread.push(step);
@@ -1622,6 +1625,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
@@ -1645,6 +1649,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
@@ -1676,6 +1681,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountInMax);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
@@ -1771,10 +1777,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "WRAP_ETH") {
const w = decodeWrapEth(inputs[i]);
if (w) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
if (!inputEstablished) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
inputPaidByUser = true;
}
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
if (w.amount === CONTRACT_BALANCE) {
@@ -2021,8 +2030,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as
// they read with no permit, and gets lines of its own below. A step
// that may take from the sender what this decoder did not read leaves
// no figure, permit or not (see spentUnread()), and so does a permit
// or batch whose input it cannot read (see permitUnread()). An
// no figure, permit or not (see spentUnread()), and so do a permit
// or batch whose input it cannot read (see permitUnread()) and an
// allowance of Token In to another spender (see toAnotherSpender). An
// EXECUTE_SUB_PLAN step leaves both sides unknown (below).
const stepCommands = Array.from(
commandsBytes,
@@ -2041,6 +2051,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// (see spentUnread()), or a token it does not know.
const spentFromSender = [];
let total = null;
// The takes from the sender that `total` adds up, each a quantity, as
// { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of a step that set the input side
// and is paid from the router counts in `total` but is not among them.
const inTotal = [];
// Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts
// whoever pays it. Stating none, it counts only when the user pays it
@@ -2053,6 +2068,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let firstTakeUnstated = null;
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount });
}
if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
}
@@ -2071,6 +2089,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
typeof t.amount === "bigint"
) {
total += t.amount;
inTotal.push(t);
continue;
}
spentFromSender.push(t);
@@ -2199,14 +2218,30 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
return amountText(amount, info);
};
// Each allowance to a spender other than the router. Code that a step
// runs, such as a V4 pool's hook or a token's own code, can call that
// spender, which can then take up to the allowance from the sender
// before the transaction ends. A permit or batch this decoder could
// not read may be to any spender, of any token.
const toAnotherSpender = permits.filter(
(p) => !sameAddress(p.spender, toAddress),
);
// A null token in `spentFromSender`, a step this decoder did not read
// or a token it does not know, and one in `permits`, a permit or
// batch it could not read, leave no Amount figure, which may then be
// too low.
// or a token it does not know, and an allowance to another spender
// that may be of Token In, leave no Amount figure, which may then be
// too low. With no Amount line, each take the total adds up gets
// lines of its own, first, so that every amount this decoder read is
// shown.
if (
present(inputAmount) &&
spentFromSender.every((s) => present(s.token)) &&
permits.every((p) => present(p.token))
toAnotherSpender.every(
(p) =>
present(p.token) &&
present(inputToken) &&
!sameAddress(p.token, inputToken),
)
) {
const amount = spentText(inputAmount, inInfo);
details.push({
@@ -2214,6 +2249,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
value: amount.display,
rawValue: amount.raw,
});
} else {
spentFromSender.unshift(...inTotal);
}
// What steps take from the sender beyond the Amount line, in the shape
@@ -2325,9 +2362,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// step pays the bridge, when one moves the sender's tokens into the
// router, when a token stays in the router, and when a payout could
// not be read. The output is received by the sender only when some
// step pays the sender and there is no such line. When no step names
// anyone paid, one line says so. `paidTo` holds these lines and the
// token line after each line about a token in the router.
// step pays the sender, there is no such line, and no allowance goes
// to another spender (see toAnotherSpender). When no step names anyone
// paid, one line says so. `paidTo` holds these lines and the token
// line after each line about a token in the router.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({
@@ -2374,7 +2412,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
}
const received = paidToSender && paidTo.length === 0;
const received =
paidToSender &&
paidTo.length === 0 &&
toAnotherSpender.length === 0;
if (!paidToSender && paidTo.length === 0) {
paidTo.push({ label: "Paid to", value: UNNAMED_CURRENCY });
}
+273
View File
@@ -0,0 +1,273 @@
// After "Add" on AddToken, AddressDetail lists the new token, and its line reads
// `quantity unknown`, not 0.0000, until a balance refresh that asked the
// explorer about the token has returned for every address, the popup closing
// and opening again meanwhile included; so does a token added on
// SettingsAddToken, wherever it is listed. A refresh that returns draws the
// balances on AddressDetail or AddressToken again, so the fetched balance shows
// without the screen being reopened
// (https://git.eeqj.de/sneak/AutistMask/issues/516). Driven through the real
// popup over a stored profile on Ethereum Mainnet, with "Show tracked tokens
// with zero balance" on, as it is by default.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
// Tracked by nobody until a test adds it.
const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
// The explorer's row for a holding of a million NEW_TOKEN, in the shape
// fetchTokenBalances() stores it.
const MILLION = {
address: NEW_TOKEN,
name: "New Token",
symbol: "NEW",
decimals: 18,
balance: "1000000.0",
holders: 3,
};
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail for the one address.
function bootOnAddress() {
return bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
}
// Holds the next balance refresh open. The returned function lets it finish
// with the address's ETH balance at 2 and `tokenBalances` as the explorer's
// rows for it, or with no token balances at all when given null: an explorer
// that did not answer, which the real refreshBalances() leaves unwritten.
function holdNextRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let release;
refreshBalances.mockImplementationOnce(async (wallets) => {
const tokenBalances = await new Promise((resolve) => {
release = resolve;
});
wallets[0].addresses[0].balance = "2.0";
if (tokenBalances !== null) {
wallets[0].addresses[0].tokenBalances = tokenBalances;
}
});
return async (tokenBalances) => {
release(tokenBalances);
await page.settle();
};
}
// "+ Token", NEW_TOKEN typed in, and "Add", with the contract lookup
// answering at once.
async function addNewToken(page) {
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-add-token");
page.node("add-token-address").value = NEW_TOKEN;
await page.click("btn-add-token-confirm");
}
// NEW_TOKEN's row in the balance list in element `id`, AddressDetail's unless
// another is named, as markup.
function newTokenRow(page, id = "address-balances") {
return page
.node(id)
.innerHTML.split("<div ")
.find((row) =>
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'),
);
}
// AddressDetail for the one address opened from Home, as its `[info]` button
// does.
async function openAddress(page) {
const { state } = require("../src/shared/state");
state.selectedWallet = 0;
state.selectedAddress = 0;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressDetail").show();
await page.settle();
}
// AddressToken for `token` opened from AddressDetail, as tapping the token's
// row there does.
async function openToken(page, token) {
const { state } = require("../src/shared/state");
state.selectedToken = token;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
}
test("until the refresh Add started returns, the new token's line reads quantity unknown", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
const finish = holdNextRefresh(page);
await addNewToken(page);
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
await finish([MILLION]);
});
test("once that refresh returns, AddressDetail shows the fetched balance without being reopened", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
// Stands for the transactions on the screen, which only show() replaces.
page.node("tx-list").innerHTML = "transactions on the screen";
await finish([MILLION]);
expect(page.visibleViews()).toEqual(["address"]);
expect(newTokenRow(page)).toContain("1000000.0000");
// Only the balances are drawn again, not the transaction list.
expect(page.node("tx-list").innerHTML).toBe("transactions on the screen");
});
test("a refresh already running when the token is added does not count as fetching it", async () => {
const page = await bootOnAddress();
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await addNewToken(page);
await finishRunning([]);
await ticking;
expect(newTokenRow(page)).toContain("quantity unknown");
// The next one asks the explorer about the token, which lists no holding
// of it: a fetched zero.
const finishNext = holdNextRefresh(page);
const next = page.tick();
await page.settle();
await finishNext([]);
await next;
expect(newTokenRow(page)).toContain("0.0000");
});
test("closed and opened again before any refresh has fetched it, the new token's line still reads quantity unknown", async () => {
const closed = await bootOnAddress();
holdNextRefresh(closed);
await addNewToken(closed);
cleanupPopup();
// The refresh this boot starts gets no token balances back: the boot's
// stub of refreshBalances() writes none.
const page = await bootPopup(undefined, { storage: closed.storage });
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
const finish = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await finish([MILLION]);
await ticking;
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh in which the explorer did not answer leaves the new token's line quantity unknown", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await finish(null);
// Drawn again: the ETH balance it fetched is shown.
expect(page.node("address-balances").innerHTML).toContain("2.0000");
expect(newTokenRow(page)).toContain("quantity unknown");
});
test("AddressToken for the new token reads quantity unknown until the refresh returns, then the fetched balance", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await openToken(page, NEW_TOKEN.toLowerCase());
expect(page.visibleViews()).toEqual(["address-token"]);
const before = page.node("address-token-balance").innerHTML;
expect(before).toContain("quantity unknown");
expect(before).not.toContain("0.0000");
await finish([MILLION]);
expect(page.node("address-token-balance").innerHTML).toContain(
"1000000.0000",
);
});
test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.visibleViews()).toEqual(["main"]);
const finish = holdNextRefresh(page);
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-settings");
await page.click("btn-settings-add-token");
page.node("settings-addtoken-address").value = NEW_TOKEN;
await page.click("btn-settings-addtoken-manual");
await page.click("btn-settings-addtoken-back");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
const onHome = newTokenRow(page, "wallet-list");
expect(onHome).toContain("quantity unknown");
expect(onHome).not.toContain("0.0000");
await openAddress(page);
const onAddress = newTokenRow(page);
expect(onAddress).toContain("quantity unknown");
expect(onAddress).not.toContain("0.0000");
await finish([MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh that returns while AddressToken is up draws its balance again", async () => {
const page = await bootPopup(
unversionedValidProfile({
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: "ETH",
}),
);
expect(page.visibleViews()).toEqual(["address-token"]);
expect(page.node("address-token-balance").innerHTML).toContain("1.5000");
const finish = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await finish(null);
await ticking;
expect(page.node("address-token-balance").innerHTML).toContain("2.0000");
});
+2 -1
View File
@@ -651,7 +651,8 @@ test("a token added by its contract address is listed on the address screen (#15
await visible(env.page, "#view-address");
// No wait: the confirm renders the balance list before it shows the
// screen, and nothing renders the list again while the screen is up.
// screen. The refresh it starts draws the list again, with the row still
// in it.
const row = env.page.locator(
'#address-balances [data-token="' + STUB_TOKEN.address + '"]',
{ hasText: STUB_TOKEN.symbol },
+1
View File
@@ -88,6 +88,7 @@ describe.each([
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
goBack: () => {},
trackToken: () => {},
}));
require("../src/popup/views/" + view).init({
+7
View File
@@ -248,6 +248,13 @@ const CONTRACT = [
floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, isText),
},
{
field: "tokensNotFetched",
kind: KIND.ENTRIES,
hostile: [42, "notalist", { a: 1 }],
floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, isText),
},
{
field: "viewStack",
kind: KIND.ENTRIES,
+340 -63
View File
@@ -1447,6 +1447,20 @@ describe("uniswap decoder", () => {
USDT_ADDR,
USDC_ADDR,
]);
// The lines of its 600 USDT when there is no Amount line.
// https://git.eeqj.de/sneak/AutistMask/issues/659 — on next at
// 5f2b606 no line gave the 600 USDT then.
const USDT_600_GROUP = [
["Moved from the sender", "600.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// The same for a step's 400 USDT.
const USDT_400_GROUP = [
["Moved from the sender", "400.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of
// `currency` to `recipient`.
const settle = (currency, amount, payerIsUser) =>
@@ -1673,6 +1687,7 @@ describe("uniswap decoder", () => {
encodeV4SettleAll(USDT_ADDR, USDT_400),
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1693,6 +1708,7 @@ describe("uniswap decoder", () => {
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1824,6 +1840,8 @@ describe("uniswap decoder", () => {
// A step that may take from the sender what the decoder did not read
// leaves no Amount figure, which may be too low, and one line says so.
// The amount of the step that set the input side then gets lines of
// its own.
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
test.each([
{
@@ -1871,6 +1889,7 @@ describe("uniswap decoder", () => {
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
]);
});
@@ -1893,6 +1912,9 @@ describe("uniswap decoder", () => {
],
),
],
// Nothing read says the user pays this step, so its 400 USDT
// gets no lines of its own.
first: [],
},
{
kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
@@ -1963,9 +1985,27 @@ describe("uniswap decoder", () => {
),
],
},
])("$kind", ({ commands, inputs }) => {
// Each take the total would add up gets lines of its own.
{
kind: "a V3 step of 600 USDT and a V2 step of 400 USDT the user pays for, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x00, 0x08, 0x0d],
inputs: [
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
),
],
first: [...USDT_600_GROUP, ...USDT_400_GROUP],
},
])("$kind", ({ commands, inputs, first = USDT_600_GROUP }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
...first,
["Moved from the sender", UNDECODED],
]);
});
@@ -1984,6 +2024,7 @@ describe("uniswap decoder", () => {
commands: [0x00, 0x08, 0x21],
inputs: [V3_600, V2_DAI_400, SUB_PLAN],
lines: [
...USDT_600_GROUP,
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1995,6 +2036,7 @@ describe("uniswap decoder", () => {
commands: [0x21, 0x00, 0x08],
inputs: [SUB_PLAN, V3_600, V2_DAI_400],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
@@ -2013,6 +2055,7 @@ describe("uniswap decoder", () => {
SUB_PLAN,
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
@@ -2065,12 +2108,6 @@ describe("uniswap decoder", () => {
const ONE_USDT_LINES = (spender) =>
permitLines("1.0000 USDT", USDT_ADDR, "USDT", spender);
test.each([
{
kind: "to another spender",
commands: [0x0a],
permits: [ONE_USDT_PERMIT(OTHER_ADDR)],
lines: ONE_USDT_LINES(OTHER_ADDR),
},
{
kind: "that the router lets fail",
commands: [0x8a],
@@ -2290,6 +2327,103 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A step that sets the input side and is paid from the router takes
// nothing from the sender, so with no Amount line its amount gets no
// lines of its own: in each route below the sender's 600 USDT moves
// into the router first, and such lines would say it left twice.
const INTO_ROUTER_600 = permit2TransferFrom(
USDT_ADDR,
ROUTER_ADDR,
USDT_600,
);
const V3_600_FROM_ROUTER = coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
USDT_600,
590000000n,
encodeV3Path([USDT_ADDR, USDC_ADDR]),
false,
],
);
const TRANSFER_FROM_BATCH = coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
);
test.each([
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V3 exact-in of 600 USDT paid from the router, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x02, 0x00, 0x0d],
inputs: [
INTO_ROUTER_600,
V3_600_FROM_ROUTER,
TRANSFER_FROM_BATCH,
],
lines: [["Moved from the sender", UNDECODED]],
},
{
kind: "a PERMIT2_PERMIT that cannot be read, a PERMIT2_TRANSFER_FROM of 600 USDT into the router, then a V3 exact-in of 600 USDT paid from the router",
commands: [0x0a, 0x02, 0x00],
inputs: [
dirtyWord(
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
0,
),
INTO_ROUTER_600,
V3_600_FROM_ROUTER,
],
lines: [["Permit2 allowance", UNDECODED]],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V4 step swapping 600 USDT whose SETTLE the router pays, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x02, 0x10, 0x0d],
inputs: [
INTO_ROUTER_600,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_600,
590000000n,
),
settle(USDT_ADDR, USDT_600, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
TRANSFER_FROM_BATCH,
],
lines: [["Moved from the sender", UNDECODED]],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V2 exact-in of CONTRACT_BALANCE paid from the router, then an EXECUTE_SUB_PLAN",
commands: [0x02, 0x08, 0x21],
inputs: [
INTO_ROUTER_600,
coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[
USER_ADDR,
2n ** 255n, // CONTRACT_BALANCE
590000000n,
[USDT_ADDR, USDC_ADDR],
false,
],
),
SUB_PLAN,
],
lines: [["Moved from the sender", UNDECODED]],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC.
const v3ExactOut = (amountInMax) =>
@@ -2410,20 +2544,6 @@ describe("uniswap decoder", () => {
],
lines: USDT_600_LINES,
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
V3_600,
],
lines: permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
OTHER_ADDR,
),
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router that the router may let fail, then a V3 exact-in of 600 USDT,",
commands: [0x8a, 0x00],
@@ -2537,45 +2657,41 @@ describe("uniswap decoder", () => {
const DAI_5000 = 5000000000000000000000n;
const MAX_UINT160 = 2n ** 160n - 1n;
// A batch names one spender for all its allowances.
test.each([
{ kind: "the router", spender: ROUTER_ADDR },
{ kind: "another spender", spender: OTHER_ADDR },
])(
"a PERMIT2_PERMIT_BATCH of 5,000 USDT and unlimited DAI to $kind, then a V3 exact-in of 600 USDT, shows each allowance",
({ spender }) => {
const result = decodeSteps(
[0x03, 0x00],
[
encodePermit2Batch(
[
[USDT_ADDR, USDT_5000],
[DAI_ADDR, MAX_UINT160],
],
spender,
),
V3_600,
],
);
expect(result.name).toBe("Swap USDT → USDC");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
spender,
// A batch names one spender for all its allowances. One to
// another spender is under "an allowance to a spender other than
// the router" below.
test("a PERMIT2_PERMIT_BATCH of 5,000 USDT and unlimited DAI to the router, then a V3 exact-in of 600 USDT, shows each allowance", () => {
const result = decodeSteps(
[0x03, 0x00],
[
encodePermit2Batch(
[
[USDT_ADDR, USDT_5000],
[DAI_ADDR, MAX_UINT160],
],
ROUTER_ADDR,
),
...permitLines("Unlimited", DAI_ADDR, "DAI", spender),
]);
// A batch pays nobody, so the swap's minimum is what the
// sender receives.
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe(
"590.0000 USDC",
);
},
);
V3_600,
],
);
expect(result.name).toBe("Swap USDT → USDC");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
...permitLines("Unlimited", DAI_ADDR, "DAI", ROUTER_ADDR),
]);
// A batch pays nobody, so the swap's minimum is what the
// sender receives.
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe(
"590.0000 USDC",
);
});
// A batch keeps a PERMIT2_PERMIT's amount from standing as the
// Amount figure, before the permit as after it.
@@ -2628,17 +2744,169 @@ describe("uniswap decoder", () => {
),
},
])(
"$kind that cannot be read, then a V3 exact-in of 600 USDT, says its allowances could not be decoded and shows no Amount figure",
"$kind that cannot be read, then a V3 exact-in of 600 USDT, says its allowances could not be decoded and shows no Amount figure and no Min. received",
({ command, input }) => {
const result = decodeSteps(
[command, 0x00],
[input, V3_600],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
["Permit2 allowance", UNDECODED],
]);
// Its spender may be any address, as with an allowance
// to a spender other than the router (below).
// https://git.eeqj.de/sneak/AutistMask/issues/658 — on
// next at e296546 this read Min. received.
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe(
"590.0000 USDC",
);
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/659 — on next at
// 5f2b606 only the 400 DAI was shown.
test("a PERMIT2_PERMIT that cannot be read, then a V3 exact-in of 600 USDT and a V2 exact-in of 400 DAI, both paid by the user, shows both amounts", () => {
const result = decodeSteps(
[0x0a, 0x00, 0x08],
[
dirtyWord(
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
0,
),
V3_600,
encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [
DAI_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
...DAI_GROUP,
["Permit2 allowance", UNDECODED],
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/658 — code that a step
// runs can call a spender other than the router, which can then take
// up to its allowance from the sender before the transaction ends.
// On next at e296546 the Amount figure and Min. received took no
// account of it. An allowance of Token In to another spender now
// leaves no Amount figure, the swap steps' own takes get lines of
// their own, and any allowance to another spender leaves no Min.
// received.
describe("an allowance to a spender other than the router", () => {
const USDT_5000 = 5000000000n;
const DAI_5000 = 5000000000000000000000n;
const MAX_UINT160 = 2n ** 160n - 1n;
const USDT_5000_LINES = permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
OTHER_ADDR,
);
test.each([
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
V3_600,
],
lines: [...USDT_600_GROUP, ...USDT_5000_LINES],
minimum: "590.0000 USDC",
},
{
kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 5,000 USDT to another spender,",
commands: [0x00, 0x0a],
inputs: [
V3_600,
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
],
lines: [...USDT_600_GROUP, ...USDT_5000_LINES],
minimum: "590.0000 USDC",
},
{
kind: "a PERMIT2_PERMIT_BATCH of 5,000 USDT and unlimited DAI to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x03, 0x00],
inputs: [
encodePermit2Batch(
[
[USDT_ADDR, USDT_5000],
[DAI_ADDR, MAX_UINT160],
],
OTHER_ADDR,
),
V3_600,
],
lines: [
...USDT_600_GROUP,
...USDT_5000_LINES,
...permitLines(
"Unlimited",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
],
minimum: "590.0000 USDC",
},
{
kind: "a PERMIT2_PERMIT of 1 USDT to another spender, then a V3 exact-in of 600 USDT and a V2 exact-in of 400 USDT, both paid by the user,",
commands: [0x0a, 0x00, 0x08],
inputs: [
ONE_USDT_PERMIT(OTHER_ADDR),
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
lines: [
...USDT_600_GROUP,
...USDT_400_GROUP,
...ONE_USDT_LINES(OTHER_ADDR),
],
// The last swap step's minimum.
minimum: "390.0000 USDC",
},
])(
"$kind shows each take's own lines and the allowances, and no Amount figure or Min. received",
({ commands, inputs, lines, minimum }) => {
const result = decodeSteps(commands, inputs);
expect(result.name).toBe("Swap USDT → USDC");
expect(linesBetweenTokens(result)).toEqual(lines);
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe(minimum);
},
);
// An allowance of another token leaves the Amount figure for
// Token In.
test("a PERMIT2_PERMIT of 5,000 DAI to another spender, then a V3 exact-in of 600 USDT, shows the swap's 600 USDT and no Min. received", () => {
const result = decodeSteps(
[0x0a, 0x00],
[encodePermit2(DAI_ADDR, DAI_5000, OTHER_ADDR), V3_600],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe(
"590.0000 USDC",
);
});
});
// A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be
@@ -2650,7 +2918,12 @@ describe("uniswap decoder", () => {
[USER_ADDR, 390000000n, USDT_400, "0x", true],
);
const result = decodeSteps([0x00, 0x01], [V3_600, unreadable]);
// With a V3 exact-out step, the 600 USDT reads as a maximum, as
// the Amount line would.
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", "Up to 600.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", UNDECODED],
]);
expect(detail(result, "Paid to").value).toBe(UNDECODED);
@@ -2701,6 +2974,9 @@ describe("uniswap decoder", () => {
],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", "Up to 0.5000 WETH"],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", UNDECODED],
...permitLines("1.0000 WETH", WETH_ADDR, "WETH", ROUTER_ADDR),
]);
@@ -2856,14 +3132,15 @@ describe("uniswap decoder", () => {
);
// The V4 exact-out route above the other way round, where the later
// take's unknown amount leaves no figure at all, and other ways the
// step that sets the input side states no amount.
// take's unknown amount leaves no Amount figure and the 400 USDT gets
// lines of its own, and other ways the step that sets the input side
// states no amount.
test.each([
{
kind: "a V2 exact-out of at most 400 USDT, then a V4 exact-out step of at most 600 USDT",
commands: [0x09, 0x10],
inputs: [V2_EXACT_OUT_400, V4_EXACT_OUT_600],
lines: UNKNOWN_USDT,
lines: [...UP_TO_400_USDT, ...UNKNOWN_USDT],
},
{
kind: "a PERMIT2_PERMIT of 1 USDT to another spender, a V4 exact-out step of at most 600 USDT, then a V2 exact-out of at most 400 USDT",
+30 -8
View File
@@ -1439,8 +1439,17 @@ describe("any token left in the router is not received", () => {
"Token In",
// A later step whose SETTLE or SETTLE_ALL the decoder cannot read
// may take anything from the sender: one line says so, in place
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625).
unread ? "Moved from the sender" : "Amount",
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625),
// after the lines the WRAP_ETH's 1 ETH then gets
// (https://git.eeqj.de/sneak/AutistMask/issues/659).
...(unread
? [
"Moved from the sender",
"Token moved",
"Moved to",
"Moved from the sender",
]
: ["Amount"]),
// A later swap step the user pays for spends `spent` of a token
// other than Token In.
...(spent
@@ -1457,9 +1466,11 @@ describe("any token left in the router is not received", () => {
expect(detail(result, "Moved from the sender").value).toBe(spent);
}
if (unread) {
expect(detail(result, "Moved from the sender").value).toBe(
UNDECODED,
);
expect(
result.details
.filter((d) => d.label === "Moved from the sender")
.map((d) => d.value),
).toEqual(["1.0000 ETH", UNDECODED]);
}
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left);
@@ -2791,26 +2802,37 @@ describe("the allow-revert flag", () => {
});
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
// The permit's spender is the router at ROUTER, which on every other
// router is an allowance to another spender, so the minimum reads
// Min. paid out there.
test.each([
{ router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
{
router: "UniversalRouterV1",
address: ROUTER_V1,
mark: "",
minimum: "Min. paid out",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
mark: "",
minimum: "Min. paid out",
},
{
router: "UniversalRouterV2",
address: ROUTER,
mark: " (allowed to fail)",
minimum: "Min. received",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
mark: " (allowed to fail)",
minimum: "Min. paid out",
},
])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag",
({ address, mark }) => {
({ address, mark, minimum }) => {
const steps = [
permit2Permit(USDT, USDT_2000),
swapToWeth(MSG_SENDER),
@@ -2842,7 +2864,7 @@ describe("the allow-revert flag", () => {
expect(
labels(withFlag).filter((l) => permitLines.includes(l)),
).toEqual(permitLines);
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
expect(detail(withFlag, minimum).value).toBe("1.0000 WETH");
},
);