Author SHA1 Message Date
sneak cce6640d23 fix: the swap decoder names where a WRAP_ETH pays and shows a V4 step's ETH as a maximum when the router may hold ETH (closes #665)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A `WRAP_ETH` that sets the input side and pays its WETH to anyone but
the router names that recipient on its `Moved to` line.

V4 pays a `SETTLE` of ETH from the router's own ETH, whichever payer it
names. When an earlier step may leave ETH in the router, the ETH that
the user pays a later V4 step gets lines of its own. Their
`Moved from the sender` line reads `Up to <amount>`, or
`Unknown (could not be decoded)` when the amount is not known. The
decoder never subtracts the router's ETH, so no line shows less ETH
than can leave the wallet. Without such an earlier step, the lines read
as before.

Model: opus-5-5
2026-10-27 01:46:35 +00:00
20 changed files with 557 additions and 767 deletions
+87 -96
View File
@@ -1265,7 +1265,9 @@ see Transaction Decoding), do not always read as a number. They can read:
the same words. `Unlimited`, `All available (V4 open delta)` and the same words. `Unlimited`, `All available (V4 open delta)` and
`Whatever an earlier step sent to the pair (V2 already paid)` keep their `Whatever an earlier step sent to the pair (V2 already paid)` keep their
wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows
its `amountOut`, the exact amount it buys. its `amountOut`, the exact amount it buys. When the router may hold ETH from
an earlier step, the `Moved from the sender` line of ETH that the user pays a
V4 step also reads `Up to <amount>` (see Transaction Decoding).
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. Transaction Decoding whole open delta", so the calldata states no quantity. Transaction Decoding
@@ -1493,9 +1495,7 @@ send-screen token selector, the address total and the remove-address warning
(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked 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 tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on. While that setting is on, a token just added reads zero balance" is on.
`quantity unknown` at an address with no entry for it, until a refresh has read
its balance at every address (see AddToken).
#### Stored state and its version #### Stored state and its version
@@ -1679,13 +1679,6 @@ network switch (below). That popup then redraws its top screen as a reopened
popup would. A screen that a reopened popup cannot render, such as Send, is popup would. A screen that a reopened popup cannot render, such as Send, is
replaced by Home. replaced by Home.
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. When AddressDetail or AddressToken is up, the
refresh also draws that screen's USD total and balance lines again, but not its
transaction list. A refresh draws no other screen again, DeleteAddress and its
balance lines included.
A network switch leaves every screen on the page showing the previous network. 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 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 saves it. Any other popup open meanwhile takes the switch from the stored
@@ -2242,15 +2235,7 @@ view would leave a wallet one click from deletion.
- "Add" button - "Add" button
- **Transitions**: - **Transitions**:
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders - "Add" (valid contract) → tracks the token, pops the stack, and re-renders
**AddressDetail**. Until a balance refresh on the token's network has read **AddressDetail**
the token's balance at every address, its line never reads `0.0000`. The
line reads `quantity unknown` instead, or is left out while "Show tracked
tokens with zero balance" is off. Where Blockscout already listed a
holding of the token, the line shows that holding. Until that refresh, the
tracked token's record carries `balanceNotFetched`, so the line still
reads `quantity unknown` after the popup closes and opens again. 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 - "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change contract lookup → flash message, no screen change
- "Add", then a network switch in Settings before the contract lookup - "Add", then a network switch in Settings before the contract lookup
@@ -2551,9 +2536,7 @@ view would leave a wallet one click from deletion.
add path add path
- **Transitions**: - **Transitions**:
- Any of the add paths shown, on success → adds the token and shows an - Any of the add paths shown, on success → adds the token and shows an
"Added SYMBOL" flash message (no screen change). Until a refresh has read "Added SYMBOL" flash message (no screen change)
the token's balance at every address, its lines read as after "Add" on
AddToken
- A duplicate, a scam-listed address, or a failed contract lookup → flash - A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change message, no screen change
- "Add" by contract address, then a network switch in Settings before the - "Add" by contract address, then a network switch in Settings before the
@@ -3496,81 +3479,89 @@ Currently supported:
of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of
`OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step
that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the
rest taken to another address, reads `5000.0000 USDT`. A later step paid from rest taken to another address, reads `5000.0000 USDT`. V4 pays a `SETTLE` of
the router spends what the router holds and takes nothing, and so does a V2 ETH from the router's own ETH, whichever payer it names. So when the router
exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is may hold ETH from an earlier step (see above), the ETH that the user pays a V4
added to the total while both are quantities: a V3 exact-in of 600 USDT and a step gets lines of its own (see below). A later step paid from the router
V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When spends what the router holds and takes nothing, and so does a V2 exact-in
the decoder does not know a take's amount, the total is not known and there is whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is added to the
no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount, total while both are quantities: a V3 exact-in of 600 USDT and a V2 exact-in
`Unlimited` included, stands as the `Amount` figure in place of the total, of 400 USDT, both paid by the user, read `1000.0000 USDT`. When the decoder
which it does only in the plain shape of Uniswap's own routes: it comes before does not know a take's amount, the total is not known and there is no `Amount`
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the line. A `PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited`
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, included, stands as the `Amount` figure in place of the total, which it does
the router may not let it fail, no step before it takes its token, a only in the plain shape of Uniswap's own routes: it comes before the step that
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know, sets the input side, it is the only `PERMIT2_PERMIT` in the transaction and
and the decoder reads every step after it in full. Permit2 then lets the there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may
router take no more of the token than that amount, so the permit covers every not let it fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM`
take of it. A step before the permit takes under whatever allowance the router of it included, or a token the decoder does not know, and the decoder reads
already had, so it may take more than the permit's amount, which would then every step after it in full. Permit2 then lets the router take no more of the
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of token than that amount, so the permit covers every take of it. A step before
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3 the permit takes under whatever allowance the router already had, so it may
exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A take more than the permit's amount, which would then read lower than what
later permit or batch may raise what Permit2 lets the router take, and so may leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the
a step the decoder does not read in full: a command the decoder does not read router, then a permit of 600 USDT to the router and a V3 exact-in of 2,000
(listed at the end of this section), a step whose input it cannot read, in USDT paid from the router, read `Amount 2000.0000 USDT`. A later permit or
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before batch may raise what Permit2 lets the router take, and so may a step the
the step that sets the input side or after it, leaves `Token In` and the decoder does not read in full: a command the decoder does not read (listed at
`Amount` line as they read with no permit, unless it lets a spender other than the end of this section), a step whose input it cannot read, in whole or in
the router take `Token In` (see below): a permit of 5,000 DAI to another part, and a step it fails on. Every other `PERMIT2_PERMIT`, before the step
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with that sets the input side or after it, leaves `Token In` and the `Amount` line
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI as they read with no permit, unless it lets a spender other than the router
to the router. When a permit's amount stands and the step that sets the input take `Token In` (see below): a permit of 5,000 DAI to another address and then
side spends a token other than the permit's, or one the decoder does not know, a V3 exact-in of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`,
that step's amount gets lines of its own, unless it states no amount and the and so do that exact-in and then a permit of 5,000 DAI to the router. When a
user pays it none of its input token. Each take the total does not count gives permit's amount stands and the step that sets the input side spends a token
three lines after the `Amount` line (after `Token In` when there is none): one other than the permit's, or one the decoder does not know, that step's amount
of a token other than `Token In`; one of `Token In` after an amount that is gets lines of its own, unless it states no amount and the user pays it none of
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on its input token. Each take the total does not count gives three lines after
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one the `Amount` line (after `Token In` when there is none): one of a token other
whose amount the decoder does not know. When there is no `Amount` line, each than `Token In`; one of `Token In` after an amount that is not a quantity (a
take the total counts gets them too, so that every amount the decoder reads is V4 open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line,
shown: the amount the step that sets the input side states, when it is a or no stated amount, as of a V4 exact-out step); one whose amount the decoder
quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is does not know; and one of ETH that the user pays a V4 step when the router may
true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose hold ETH. When there is no `Amount` line, each take the total counts gets them
ETH comes from the transaction's value), and each later take of `Token In` too, so that every amount the decoder reads is shown: the amount the step that
added to it. A step that sets the input side and is paid from the router takes sets the input side states, when it is a quantity and the user pays that step
nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder (a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of
cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's
lines for both amounts. When the step that sets the input side states no value), and each later take of `Token In` added to it. A step that sets the
amount, the user pays it some of its input token, and there is no `Amount` input side and is paid from the router takes nothing from the user's wallet
line, that step gets them too whenever another take's lines give a figure, or and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins
a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into of 600 USDT and 400 DAI paid by the user, give lines for both amounts. When
the router, on its `Paid to` line, so that figure cannot read as all that the step that sets the input side states no amount, the user pays it some of
leaves. When it states no amount and the user pays it none of its input token, its input token, and there is no `Amount` line, that step gets them too
as when the router pays it, that step takes none of that token from the user's whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of
wallet and never gets them, with a permit or without. They come in step order, its token gives one, on the lines below or, into the router, on its `Paid to`
except that the step that sets the input side comes first when it has them, line, so that figure cannot read as all that leaves. When it states no amount
followed, when there is no `Amount` line, by the other takes the total counts: and the user pays it none of its input token, as when the router pays it, that
`Moved from the sender`, its amount, worded as the `Amount` line words one, or 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; `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 `Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A token the decoder `Moved to`, which reads `The swap step that spends it`. For ETH that the user
does not know gives the `Moved from the sender` line alone. A step may also pays a V4 step when the router may hold ETH, `Moved from the sender` reads
take from the user's wallet what the decoder did not read: a V4 step whose `Up to <amount>` for a known amount. For a `WRAP_ETH` that pays its WETH to
input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the anyone but the router, `Moved to` names that recipient instead: `The sender`
router still runs; a V2 or V3 exact-in or exact-out step or a for `address(1)`, and otherwise the address. A token the decoder does not know
`PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose gives the `Moved from the sender` line alone. A step may also take from the
path is shorter than one token address or has bytes after its last hop; every user's wallet what the decoder did not read: a V4 step whose input or one of
`PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands; whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs;
and every step it fails on. Each such step gives one `Moved from the sender` a V2 or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input
line among those, reading `Unknown (could not be decoded)`. That line, or any it cannot read, such as a V3 step whose path is shorter than one token address
line for a token the decoder does not know, leaves no `Amount` line, whose or has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every
figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the `EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such
permit's amount from standing. Then each `PERMIT2_TRANSFER_FROM` but one into step gives one `Moved from the sender` line among those, reading
the router, in step order, gives three lines: `Moved from the sender`, the `Unknown (could not be decoded)`. That line, or any line for a token the
amount it moves, for example `1000000.0000 USDT`; `Token moved`, giving the decoder does not know, leaves no `Amount` line, whose figure might be too low,
token's address as the `Token In` line does; and `Moved to`, which reads and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from
standing. Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step
order, gives three lines: `Moved from the sender`, the amount it moves, for
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the
`Token In` line does; and `Moved to`, which reads
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)` `The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
on every router but `UniversalRouterV1`, and otherwise gives the address. A on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and move into the router needs none of them, because its `Paid to` line and
+1 -16
View File
@@ -6,7 +6,6 @@ const {
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork, takeStoredNetwork,
clearBalanceNotFetched,
loadState, loadState,
} = require("../shared/state"); } = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema"); const { StateUnusableError } = require("../shared/stateSchema");
@@ -89,17 +88,6 @@ let refreshTimer = null;
// removed meanwhile is left as it is. Blockscout may have answered before the // removed meanwhile is left as it is. Blockscout may have answered before the
// user turned "Use Blockscout" off. So the save drops every token the user has // user turned "Use Blockscout" off. So the save drops every token the user has
// not tracked if the stored setting is off by then. // not tracked if the stored setting is off by then.
//
// The save also deletes `balanceNotFetched` from each tracked token whose
// balance this refresh read at every address. trackToken() in
// src/popup/views/helpers.js sets that field. The save takes the field off the
// matching tokens of the stored record (clearBalanceNotFetched() in
// src/shared/state.js). The rest of the stored tracked token list stays as it
// is.
//
// Once the save is done, the wallet list is drawn again. So are the balances
// on AddressDetail or AddressToken when that screen is up. No other screen is
// drawn again, DeleteAddress and its balance lines included.
async function doRefreshAndRender() { async function doRefreshAndRender() {
if (refreshInFlight) return; if (refreshInFlight) return;
refreshInFlight = true; refreshInFlight = true;
@@ -107,7 +95,7 @@ async function doRefreshAndRender() {
await followStoredSettings(); await followStoredSettings();
const networkId = state.networkId; const networkId = state.networkId;
const wallets = structuredClone(state.wallets); const wallets = structuredClone(state.wallets);
const [, readTokens] = await Promise.all([ await Promise.all([
refreshPrices(), refreshPrices(),
refreshBalances( refreshBalances(
wallets, wallets,
@@ -139,13 +127,10 @@ async function doRefreshAndRender() {
if (!stored.useBlockscout) { if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens); dropUntrackedTokens(state.wallets, stored.trackedTokens);
} }
clearBalanceNotFetched(stored, readTokens);
state.lastBalanceRefresh = Date.now(); state.lastBalanceRefresh = Date.now();
return true; return true;
}); });
renderWalletList(); renderWalletList();
if (state.currentView === "address") addressDetail.renderBalances();
if (state.currentView === "address-token") addressToken.renderBalance();
} 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
// second interval, and eight views through ctx — so it must never // second interval, and eight views through ctx — so it must never
+2 -9
View File
@@ -1,11 +1,4 @@
const { const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -79,7 +72,7 @@ function init(ctx) {
return; return;
} }
log.infof("Adding token", info.symbol, contractAddr); log.infof("Adding token", info.symbol, contractAddr);
trackToken({ state.trackedTokens.push({
address: contractAddr, address: contractAddr,
symbol: info.symbol, symbol: info.symbol,
decimals: info.decimals, decimals: info.decimals,
+10 -19
View File
@@ -66,27 +66,11 @@ function show() {
}); });
$("address-line").dataset.full = addr.address; $("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line")); attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element // ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden"); ensEl.classList.add("hidden");
renderBalances();
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
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( $("address-balances").innerHTML = balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,
@@ -100,6 +84,13 @@ function renderBalances() {
ctx.showAddressToken(); ctx.showAddressToken();
}); });
}); });
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
} }
let loadedTxs = []; let loadedTxs = [];
@@ -296,4 +287,4 @@ function init(_ctx) {
exportPrivkey.init(); exportPrivkey.init();
} }
module.exports = { init, show, renderBalances }; module.exports = { init, show };
+32 -48
View File
@@ -49,18 +49,37 @@ function show() {
const addr = wallet.addresses[state.selectedAddress]; const addr = wallet.addresses[state.selectedAddress];
const ai = state.selectedAddress; const ai = state.selectedAddress;
const tokenId = state.selectedToken; const tokenId = state.selectedToken;
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase()); const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
currentSymbol = if (tokenId === "ETH") {
tokenId === "ETH" symbol = nativeCurrency();
? nativeCurrency() amount = parseFloat(addr.balance || "0");
: resolveSymbol(tokenId, addr.tokenBalances, state.trackedTokens); 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;
$("address-token-title").textContent = $("address-token-title").textContent =
wallet.name + wallet.name +
" \u2014 Address " + " \u2014 Address " +
(ai + 1) + (ai + 1) +
" \u2014 " + " \u2014 " +
displaySymbol(currentSymbol); displaySymbol(symbol);
// Blockie // Blockie
const blockieEl = $("address-token-jazzicon"); const blockieEl = $("address-token-jazzicon");
@@ -82,7 +101,13 @@ function show() {
$("address-token-line").dataset.full = addr.address; $("address-token-line").dataset.full = addr.address;
attachCopyHandlers($("address-token-line")); attachCopyHandlers($("address-token-line"));
renderBalance(); // 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);
// Token contract details (ERC-20 only) // Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info"); const contractInfo = $("address-token-contract-info");
@@ -144,47 +169,6 @@ function show() {
loadTransactions(addr.address, tokenId); 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,
);
const tracked = state.trackedTokens.find(
(t) => t.address.toLowerCase() === id,
);
// null when the scale is unknown, or when no refresh has read the
// balance of 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 = tracked && tracked.balanceNotFetched ? 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) { async function loadTransactions(address, tokenId) {
try { try {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
@@ -364,4 +348,4 @@ function init(_ctx) {
}); });
} }
module.exports = { init, show, renderBalance }; module.exports = { init, show };
+1 -11
View File
@@ -417,15 +417,6 @@ function balanceLine(symbol, amount, price, tokenId) {
); );
} }
// Tracks `token` on the active network, as every add path on AddToken and
// SettingsAddToken does. The tracked record carries `balanceNotFetched: true`
// until a balance refresh has read the token's balance at every address
// (doRefreshAndRender() in src/popup/index.js). Until then, an address with no
// balance row for the token shows `quantity unknown` for it, not 0.0000.
function trackToken(token) {
state.trackedTokens.push({ ...token, balanceNotFetched: true });
}
function balanceLinesForAddress(addr, trackedTokens, showZero) { function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine( let html = balanceLine(
nativeCurrency(), nativeCurrency(),
@@ -458,7 +449,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
if (seen.has(t.address.toLowerCase())) continue; if (seen.has(t.address.toLowerCase())) continue;
html += balanceLine( html += balanceLine(
t.symbol, t.symbol,
t.balanceNotFetched ? null : 0, 0,
getPrice(t.symbol), getPrice(t.symbol),
t.address.toLowerCase(), t.address.toLowerCase(),
); );
@@ -838,7 +829,6 @@ module.exports = {
flashCopyFeedback, flashCopyFeedback,
balanceLine, balanceLine,
balanceLinesForAddress, balanceLinesForAddress,
trackToken,
addressHoldsFunds, addressHoldsFunds,
keyMaterialName, keyMaterialName,
unknownableAmount, unknownableAmount,
+4 -11
View File
@@ -1,11 +1,4 @@
const { const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -50,7 +43,7 @@ function renderTop10() {
decimals: parseInt(btn.dataset.decimals, 10), decimals: parseInt(btn.dataset.decimals, 10),
name: btn.dataset.name || btn.dataset.symbol, name: btn.dataset.name || btn.dataset.symbol,
}; };
trackToken(token); state.trackedTokens.push(token);
await saveState(); await saveState();
showFlash("Added " + token.symbol); showFlash("Added " + token.symbol);
renderTop10(); renderTop10();
@@ -117,7 +110,7 @@ function init(_ctx) {
decimals: parseInt(opt.dataset.decimals, 10), decimals: parseInt(opt.dataset.decimals, 10),
name: opt.dataset.name || opt.dataset.symbol, name: opt.dataset.name || opt.dataset.symbol,
}; };
trackToken(token); state.trackedTokens.push(token);
await saveState(); await saveState();
showFlash("Added " + token.symbol); showFlash("Added " + token.symbol);
renderTop10(); renderTop10();
@@ -163,7 +156,7 @@ function init(_ctx) {
return; return;
} }
log.infof("Adding token", info.symbol, addr); log.infof("Adding token", info.symbol, addr);
trackToken({ state.trackedTokens.push({
address: addr, address: addr,
symbol: info.symbol, symbol: info.symbol,
decimals: info.decimals, decimals: info.decimals,
+4 -20
View File
@@ -247,11 +247,7 @@ async function fetchTokenBalances(
// it has none. The failure is logged. A balance of zero gets no entry, as a // it has none. The failure is logged. A balance of zero gets no entry, as a
// zero Blockscout reports gets none: the balance lists take a tracked token's // zero Blockscout reports gets none: the balance lists take a tracked token's
// zero from the tracked list. A token whose symbol the spoof rule refuses gets // zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances(), and its balance is not read. // no entry either, as in fetchTokenBalances().
//
// Resolves with `rows`, the entries, and `unread`, the tracked tokens whose
// balance was not read, as lowercased contract addresses. A token is unread
// when the spoof rule refuses it or its balanceOf fails.
// //
// `signal` is as for fetchTokenBalances(). // `signal` is as for fetchTokenBalances().
async function fetchTrackedTokenBalances( async function fetchTrackedTokenBalances(
@@ -292,16 +288,12 @@ async function fetchTrackedTokenBalances(
const reported = explorerRows ?? previous; const reported = explorerRows ?? previous;
const rows = []; const rows = [];
const unread = trackedTokens
.filter((token) => !listed.includes(token))
.map((token) => token.address.toLowerCase());
listed.forEach((token, i) => { listed.forEach((token, i) => {
const lower = token.address.toLowerCase(); const lower = token.address.toLowerCase();
const entryIn = (list) => const entryIn = (list) =>
list.find((row) => String(row.address).toLowerCase() === lower); list.find((row) => String(row.address).toLowerCase() === lower);
const raw = balances[i]; const raw = balances[i];
if (raw === null) { if (raw === null) {
unread.push(lower);
const kept = entryIn(previous) || entryIn(explorerRows || []); const kept = entryIn(previous) || entryIn(explorerRows || []);
if (kept) rows.push(kept); if (kept) rows.push(kept);
return; return;
@@ -322,15 +314,12 @@ async function fetchTrackedTokenBalances(
holders: entry ? entry.holders : null, holders: entry ? entry.holders : null,
}); });
}); });
return { rows, unread }; return rows;
} }
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. // Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `trackedTokens` are the tokens tracked on the active network. `signal` is as // `trackedTokens` are the tokens tracked on the active network. `signal` is as
// for fetchTokenBalances(). // for fetchTokenBalances().
//
// Resolves with the lowercased contract addresses of the tracked tokens whose
// balance it read at every address.
async function refreshBalances( async function refreshBalances(
wallets, wallets,
rpcUrl, rpcUrl,
@@ -343,8 +332,6 @@ async function refreshBalances(
const provider = getProvider(rpcUrl, networkId); const provider = getProvider(rpcUrl, networkId);
const tracked = trackedTokens || []; const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase())); const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
// The tracked tokens whose balance was not read at some address.
const unread = new Set();
const updates = []; const updates = [];
for (const wallet of wallets) { for (const wallet of wallets) {
@@ -419,14 +406,12 @@ async function refreshBalances(
signal, signal,
), ),
explorerRequest, explorerRequest,
]).then(([trackedResult, explorerRows]) => { ]).then(([trackedRows, explorerRows]) => {
for (const lower of trackedResult.unread) unread.add(lower);
const untrackedRows = (explorerRows ?? previous).filter( const untrackedRows = (explorerRows ?? previous).filter(
(row) => (row) =>
!trackedSet.has(String(row.address).toLowerCase()), !trackedSet.has(String(row.address).toLowerCase()),
); );
addr.tokenBalances = addr.tokenBalances = trackedRows.concat(untrackedRows);
trackedResult.rows.concat(untrackedRows);
log.debugf( log.debugf(
"Token balances", "Token balances",
addr.address, addr.address,
@@ -440,7 +425,6 @@ async function refreshBalances(
await Promise.all(updates); await Promise.all(updates);
log.debugf("refreshBalances done"); log.debugf("refreshBalances done");
return [...trackedSet].filter((lower) => !unread.has(lower));
} }
// Drops the balance of each token not in `trackedTokens` from every address. // Drops the balance of each token not in `trackedTokens` from every address.
+2 -25
View File
@@ -128,9 +128,8 @@ function currentNetwork() {
// The persisted fields as this page held them when its last loadState() // The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began, with any network // finished, or when its last successful saveState() began, with any network
// switch saved elsewhere that this page has taken since (takeStoredNetwork()). // switch saved elsewhere that this page has taken since (takeStoredNetwork()).
// `baseline` also lacks any `balanceNotFetched` field that a balance refresh has // saveState() diffs the live state against this to find only the fields THIS
// deleted since (clearBalanceNotFetched()). saveState() diffs the live state // page changed since.
// against this to find only the fields THIS page changed since.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -614,27 +613,6 @@ function takeStoredNetwork(stored) {
copyChainSwitchFields(baseline, structuredClone(stored)); copyChainSwitchFields(baseline, structuredClone(stored));
} }
// Deletes `balanceNotFetched` from each tracked token whose lowercased
// contract address is in `addresses`. The field goes from `stored`, from
// `state` and from `baseline` alike. `stored` is the record a save of this
// page has just read (doRefreshAndRender() in src/popup/index.js calls this
// from `beforeWrite`). So the deletion is not a change this page made to its
// tracked tokens. Unless this page has changed its tracked tokens itself, the
// save then writes the stored list with only the field gone.
function clearBalanceNotFetched(stored, addresses) {
for (const list of [
stored.trackedTokens,
rawState.trackedTokens,
baseline.trackedTokens,
]) {
for (const token of list) {
if (addresses.includes(token.address.toLowerCase())) {
delete token.balanceNotFetched;
}
}
}
}
// Rejects with StateUnusableError for a stored record this build cannot make // Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a // sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first // caller that ignores the rejection gets StateNotLoadedError on the first
@@ -681,7 +659,6 @@ module.exports = {
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork, takeStoredNetwork,
clearBalanceNotFetched,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
+77 -26
View File
@@ -1244,6 +1244,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// comes from the transaction's value. A step paid from the router // comes from the transaction's value. A step paid from the router
// takes nothing from the sender. // takes nothing from the sender.
let inputPaidByUser = false; let inputPaidByUser = false;
// The recipient of a WRAP_ETH that set the input side, as written,
// when it is not the router; otherwise null. That WRAP_ETH pays its
// WETH there, not to a swap step (see the Moved to lines).
let wrapEthRecipient = null;
// The last PERMIT2_PERMIT this decoder read before a step set the // The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop). // input side, or null (see permitStands after the loop).
let permit = null; let permit = null;
@@ -1336,15 +1340,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}; };
// 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
// that sets the input side, as { token, amount }, in step order. // that sets the input side, as { token, amount, atMost }, in step
// After the loop each joins the Amount line or gets lines of its own // order. After the loop each joins the Amount line or gets lines of
// (see permitStands). // its own (see permitStands). A take with `atMost` set takes at most
// its amount, and always gets lines of its own.
const userPaid = []; const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount` // A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes // of `token` from the sender, or at most that when `atMost` is set. A
// nothing. // V2 exact-in of ALREADY_PAID takes nothing.
const addUserPaid = (token, amount) => { const addUserPaid = (token, amount, atMost = false) => {
if (amount !== ALREADY_PAID) userPaid.push({ token, amount }); if (amount !== ALREADY_PAID) {
userPaid.push({ token, amount, atMost });
}
}; };
// A step may take from the sender what this decoder did not read: a // A step may take from the sender what this decoder did not read: a
// V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it // V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it
@@ -1787,6 +1794,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount, w.amount,
); );
inputPaidByUser = true; inputPaidByUser = true;
if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
} }
addPayee(w.recipient); addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH. // CONTRACT_BALANCE wraps all the router's ETH.
@@ -1815,6 +1825,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V4_SWAP") { } else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
const inputSetBefore = inputEstablished; const inputSetBefore = inputEstablished;
// V4Router pays a SETTLE of ETH from the router's own
// ETH, whichever payer it names. When the router may hold
// ETH from an earlier step, that ETH may pay some of the
// ETH that the user pays this step. The sender then pays
// at most the step's amount.
const routerMayHoldEth = heldByRouter.has(
"0x0000000000000000000000000000000000000000",
);
if (v4) { if (v4) {
// Each SETTLE paid from the router spends the amount // Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays. // decodeV4Swap() says it pays.
@@ -1905,7 +1923,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (v4) { if (v4) {
for (const p of v4.paidByUser) { for (const p of v4.paidByUser) {
if (setInputHere && p === paidIn) continue; if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount); addUserPaid(
p.currency,
p.amount,
routerMayHoldEth &&
p.currency ===
"0x0000000000000000000000000000000000000000",
);
} }
} }
if (!v4 || v4.settleUnread) { if (!v4 || v4.settleUnread) {
@@ -2016,7 +2040,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// being of another token or coming after an amount that is not a // being of another token or coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), gets // quantity (a V4 open delta, ALREADY_PAID, or none stated), gets
// lines of its own. One whose amount is not known also leaves the // lines of its own. One whose amount is not known also leaves the
// total unknown, with no figure. // total unknown, with no figure. A take of at most its amount (see
// userPaid) is never added, and gets lines of its own too.
// //
// A PERMIT2_PERMIT names Token In only when its amount stands as the // A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the // Amount figure in place of the total, which it does only in the
@@ -2051,14 +2076,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
permit.beforeEveryTake && permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step); !stepsUnread.some((step) => step > permit.step);
// What the steps take that the Amount line does not give, as // What the steps take that the Amount line does not give, as
// { token, amount }. A null token is a step this decoder did not read // { token, amount, atMost, recipient }. A null token is a step this
// (see spentUnread()), or a token it does not know. // decoder did not read (see spentUnread()), or a token it does not
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that
// pays elsewhere has a recipient (see wrapEthRecipient).
const spentFromSender = []; const spentFromSender = [];
let total = null; let total = null;
// The takes from the sender that `total` adds up, each a quantity, as // The takes from the sender that `total` adds up, each a quantity, in
// { token, amount }. When there is no Amount line, each gets lines of // the shape of `spentFromSender`. When there is no Amount line, each
// its own (see below). The amount of a step that set the input side // gets lines of its own (see below). The amount of a step that set
// and is paid from the router counts in `total` but is not among them. // the input side and is paid from the router counts in `total` but is
// not among them.
const inTotal = []; const inTotal = [];
// Whether the step that set the input side counts as a take from the // 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 // sender, which may get lines of its own. The amount it stated counts
@@ -2073,7 +2101,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!permitStands || sameAddress(inputToken, permit.token)) { if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount; total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) { if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount }); inTotal.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
} }
if (hasFirstTake && !present(inputAmount)) { if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null }; firstTakeUnstated = { token: inputToken, amount: null };
@@ -2081,7 +2113,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (hasFirstTake) { } else if (hasFirstTake) {
// The step that set the input side spent a token other than the // The step that set the input side spent a token other than the
// permit's, or one not known, so its amount gets lines of its own. // permit's, or one not known, so its amount gets lines of its own.
spentFromSender.push({ token: inputToken, amount: inputAmount }); spentFromSender.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
} }
if (permitStands) inputToken = permit.token; if (permitStands) inputToken = permit.token;
for (const t of userPaid) { for (const t of userPaid) {
@@ -2089,6 +2125,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (ofTokenIn && permitStands) continue; if (ofTokenIn && permitStands) continue;
if ( if (
ofTokenIn && ofTokenIn &&
!t.atMost &&
typeof total === "bigint" && typeof total === "bigint" &&
typeof t.amount === "bigint" typeof t.amount === "bigint"
) { ) {
@@ -2193,8 +2230,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// V2's already-paid zero, neither of which is a quantity at all (see // V2's already-paid zero, neither of which is a quantity at all (see
// OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two // OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
// tests come first — the sentinels are not bigints and cannot be // tests come first — the sentinels are not bigints and cannot be
// compared with one. // compared with one. `atMost` says the amount is a maximum (see
const spentText = (amount, info) => { // userPaid).
const spentText = (amount, info, atMost = false) => {
if (amount === OPEN_DELTA) { if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} }
@@ -2207,7 +2245,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (amount >= MAX_UINT160) { if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" }; return { raw: "Unlimited", display: "Unlimited" };
} }
if (hasV2OrV3ExactOut) { if (hasV2OrV3ExactOut || atMost) {
// A V2 or V3 exact-out swap spends at most this figure, // A V2 or V3 exact-out swap spends at most this figure,
// whichever steps gave it (an amountInMax, the WRAP_ETH of a // whichever steps gave it (an amountInMax, the WRAP_ETH of a
// swap paid in ETH, a permit, or several steps' total), so it // swap paid in ETH, a permit, or several steps' total), so it
@@ -2260,20 +2298,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What steps take from the sender beyond the Amount line, in the shape // What steps take from the sender beyond the Amount line, in the shape
// of the lines below for a PERMIT2_TRANSFER_FROM. An amount this // of the lines below for a PERMIT2_TRANSFER_FROM. An amount this
// decoder does not know is said so, and a step it did not read gets // decoder does not know is said so, and a step it did not read gets
// that one line alone. // that one line alone. A take with no recipient goes to the swap step
// that spends it. A take with a recipient goes to that recipient;
// `MSG_SENDER` means the sender.
for (const s of spentFromSender) { for (const s of spentFromSender) {
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
value: present(s.amount) value: present(s.amount)
? spentText(s.amount, tokenInfo(s.token, sources)).display ? spentText(s.amount, tokenInfo(s.token, sources), s.atMost)
.display
: UNDECODED_CURRENCY, : UNDECODED_CURRENCY,
}); });
if (present(s.token)) { if (present(s.token)) {
details.push(tokenMovedLine("Token moved", s.token, sources)); details.push(tokenMovedLine("Token moved", s.token, sources));
details.push({ if (!present(s.recipient)) {
label: "Moved to", details.push({
value: "The swap step that spends it", label: "Moved to",
}); value: "The swap step that spends it",
});
} else if (s.recipient.toLowerCase() === MSG_SENDER) {
details.push({ label: "Moved to", value: "The sender" });
} else {
details.push({
label: "Moved to",
value: s.recipient,
address: s.recipient,
});
}
} }
} }
-441
View File
@@ -1,441 +0,0 @@
// After "Add" on AddToken, AddressDetail lists the new token. Its line reads
// `quantity unknown`, not 0.0000, until a balance refresh has read the token's
// balance at every address, even if the popup closes and opens again
// meanwhile. A token added on SettingsAddToken reads the same 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).
//
// The tracked token's record carries `balanceNotFetched` until a refresh has
// read the token's balance. Removing the token removes that record. A network
// switch keeps each network's records apart. A refresh takes the field off the
// stored record's tokens and keeps another page's changes to the stored list.
//
// 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";
// A holding of a million NEW_TOKEN, as refreshBalances() stores it.
const MILLION = {
address: NEW_TOKEN,
name: "New Token",
symbol: "NEW",
decimals: 18,
balance: "1000000.0",
holders: 3,
};
// NEW_TOKEN as "Add" on AddToken tracks it, once a refresh has read its
// balance.
const NEW_TRACKED = {
address: NEW_TOKEN,
symbol: "NEW",
name: "New Token",
decimals: 18,
};
// A token that another popup page tracks.
const OTHER_TOKEN = {
address: "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
symbol: "OTH",
name: "Other Token",
decimals: 18,
};
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail for the one address.
function bootOnAddress(extra) {
return bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
...extra,
}),
);
}
// Holds the next balance refresh open. The returned function lets it finish
// with the address's ETH balance at 2 and `tokenBalances` as its token
// entries, having read the balance of every token tracked when it started.
// Given null, it finishes having read no token's balance, as when every
// balanceOf fails, and leaves the token entries as they were.
function holdNextRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let release;
refreshBalances.mockImplementationOnce(
async (wallets, rpcUrl, explorerUrl, trackedTokens) => {
const read = trackedTokens.map((t) => t.address.toLowerCase());
const tokenBalances = await new Promise((resolve) => {
release = resolve;
});
wallets[0].addresses[0].balance = "2.0";
if (tokenBalances === null) return [];
wallets[0].addresses[0].tokenBalances = tokenBalances;
return read;
},
);
return async (tokenBalances) => {
release(tokenBalances);
await page.settle();
};
}
// Runs the ten-second refresh to the end, finishing it as holdNextRefresh()
// does with `tokenBalances`.
async function refreshOnce(page, tokenBalances) {
const finish = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await finish(tokenBalances);
await ticking;
}
// "+ 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");
}
// Settings' "+ Add token", NEW_TOKEN typed in, and "Add", with the contract
// lookup answering at once. It ends on SettingsAddToken.
async function addOnSettingsAddToken(page) {
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-settings-add-token");
page.node("settings-addtoken-address").value = NEW_TOKEN;
await page.click("btn-settings-addtoken-manual");
}
// The "[x]" button Settings draws beside the first tracked token. The page
// stub does not turn the markup Settings draws into elements. So the list
// hands Settings a stand-in for that button each time it is drawn, and
// `press()` presses the last one drawn. Call it before Settings opens.
function firstRemoveButton(page) {
const button = { press: null };
page.node("settings-tracked-tokens").querySelectorAll = () => [
{
dataset: { idx: "0" },
addEventListener: (name, fn) => {
button.press = fn;
},
},
];
return button;
}
// 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();
}
// Another popup page saves `trackedTokens` as the tracked token list. This
// page is not told of the write.
function anotherPageTracks(page, trackedTokens) {
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
trackedTokens,
});
}
// 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 reading its balance", 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 reads the token's balance, which is zero.
await refreshOnce(page, []);
expect(newTokenRow(page)).toContain("0.0000");
});
test("closed and opened again before any refresh has read its balance, 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 reads no token's balance: the boot's stub
// of refreshBalances() reads 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");
await refreshOnce(page, [MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh that could not read the new token's balance leaves its 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);
await page.click("btn-settings");
await addOnSettingsAddToken(page);
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");
await refreshOnce(page, null);
expect(page.node("address-token-balance").innerHTML).toContain("2.0000");
});
test("a token removed before any refresh has read its balance leaves nothing stored, and adding it again tracks it once", async () => {
const page = await bootPopup(unversionedValidProfile());
const removeButton = firstRemoveButton(page);
const stored = () => page.storage.read("autistmask");
await page.click("btn-settings");
await addOnSettingsAddToken(page);
// Settings opened again from Home draws its list with the token in it.
await page.click("btn-settings-addtoken-back");
await page.click("btn-settings-back");
await page.click("btn-settings");
await removeButton.press();
await page.settle();
expect(stored().trackedTokens).toEqual([]);
// Nothing in the stored record names the token any more.
expect(JSON.stringify(stored()).toLowerCase()).not.toContain(
NEW_TOKEN.toLowerCase(),
);
await addOnSettingsAddToken(page);
// A second add of the same token is refused.
page.node("settings-addtoken-address").value = NEW_TOKEN;
await page.click("btn-settings-addtoken-manual");
const added = { address: NEW_TOKEN, symbol: "NEW", decimals: 18 };
expect(stored().trackedTokens).toEqual([
{ ...added, name: "New Token", balanceNotFetched: true },
]);
await refreshOnce(page, []);
expect(stored().trackedTokens).toEqual([{ ...added, name: "New Token" }]);
});
test("a refresh on another network leaves the new token reading quantity unknown on its own", async () => {
// Sepolia tracks a token at the same contract address, also just added.
const sepoliaToken = {
address: NEW_TOKEN,
symbol: "SEP",
name: "Sepolia Token",
decimals: 18,
};
const page = await bootOnAddress({
networkTrackedTokens: {
sepolia: [{ ...sepoliaToken, balanceNotFetched: true }],
},
});
const finishMainnet = holdNextRefresh(page);
await addNewToken(page);
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
// The refresh Add started returns after the switch and writes nothing.
await finishMainnet([MILLION]);
// Sepolia's refresh reads the balance of its own token.
await refreshOnce(page, []);
const stored = page.storage.read("autistmask");
expect(stored.trackedTokens).toEqual([sepoliaToken]);
expect(stored.networkTrackedTokens.mainnet[0].balanceNotFetched).toBe(true);
await chooseNetwork(page, "mainnet");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(newTokenRow(page)).toContain("quantity unknown");
await refreshOnce(page, [MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh keeps a token that another page added after this page loaded", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
const otherAdded = { ...OTHER_TOKEN, balanceNotFetched: true };
anotherPageTracks(page, [
{ ...NEW_TRACKED, balanceNotFetched: true },
otherAdded,
]);
await finish([MILLION]);
expect(page.storage.read("autistmask").trackedTokens).toEqual([
NEW_TRACKED,
otherAdded,
]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh does not bring back a token that another page removed after this page loaded", async () => {
const page = await bootOnAddress({ trackedTokens: [OTHER_TOKEN] });
const finish = holdNextRefresh(page);
await addNewToken(page);
anotherPageTracks(page, [{ ...NEW_TRACKED, balanceNotFetched: true }]);
await finish([MILLION]);
expect(page.storage.read("autistmask").trackedTokens).toEqual([
NEW_TRACKED,
]);
});
@@ -41,7 +41,6 @@ async function holdRefresh(page) {
holders: null, holders: null,
}, },
]; ];
return [];
}); });
const ticking = page.tick(); const ticking = page.tick();
await page.settle(); await page.settle();
@@ -720,7 +720,6 @@ describe("the popup", () => {
tokenRow(TRACKED, "TRK"), tokenRow(TRACKED, "TRK"),
tokenRow(UNTRACKED, "TST"), tokenRow(UNTRACKED, "TST"),
]; ];
return [];
}); });
const ticking = page.tick(); const ticking = page.tick();
await page.settle(); await page.settle();
@@ -765,7 +764,6 @@ describe("the popup", () => {
addr.tokenBalances = after addr.tokenBalances = after
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")] ? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
: [tokenRow(TRACKED, "TRK")]; : [tokenRow(TRACKED, "TRK")];
return [];
}); });
anotherPageSaves(page, { useBlockscout: after }); anotherPageSaves(page, { useBlockscout: after });
+1 -2
View File
@@ -969,8 +969,7 @@ test("a token added by its contract address is listed on the address screen (#15
await visible(env.page, "#view-address"); await visible(env.page, "#view-address");
// No wait: the confirm renders the balance list before it shows the // No wait: the confirm renders the balance list before it shows the
// screen. The refresh it starts draws the list again, with the row still // screen, and nothing renders the list again while the screen is up.
// in it.
const row = env.page.locator( const row = env.page.locator(
'#address-balances [data-token="' + STUB_TOKEN.address + '"]', '#address-balances [data-token="' + STUB_TOKEN.address + '"]',
{ hasText: STUB_TOKEN.symbol }, { hasText: STUB_TOKEN.symbol },
-1
View File
@@ -48,7 +48,6 @@ async function boot(stored) {
for (const wallet of wallets) { for (const wallet of wallets) {
for (const addr of wallet.addresses) addr.balance = chainBalance; for (const addr of wallet.addresses) addr.balance = chainBalance;
} }
return [];
}); });
return { page, refreshBalances }; return { page, refreshBalances };
} }
-1
View File
@@ -88,7 +88,6 @@ describe.each([
showFlash: (msg) => flashes.push(msg), showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s, escapeHtml: (s) => s,
goBack: () => {}, goBack: () => {},
trackToken: () => {},
})); }));
require("../src/popup/views/" + view).init({ require("../src/popup/views/" + view).init({
+2 -3
View File
@@ -279,11 +279,10 @@ async function bootPopup(stored, options) {
getAddressValue: () => ({ usd: null, partial: false }), getAddressValue: () => ({ usd: null, partial: false }),
})); }));
// scanForAddresses() answers in the real one's shape, a scan that found // scanForAddresses() answers in the real one's shape, a scan that found
// no used address: AddWallet reads `addresses` from it. refreshBalances() // no used address: AddWallet reads `addresses` from it.
// answers as a refresh that read no tracked token's balance.
jest.doMock("../../src/shared/balances", () => ({ jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []), fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => []), refreshBalances: jest.fn(async () => {}),
dropUntrackedTokens: jest.requireActual("../../src/shared/balances") dropUntrackedTokens: jest.requireActual("../../src/shared/balances")
.dropUntrackedTokens, .dropUntrackedTokens,
lookupTokenInfo: jest.fn(async () => null), lookupTokenInfo: jest.fn(async () => null),
-28
View File
@@ -307,31 +307,3 @@ test("a tracked token wearing a known symbol from another contract is not listed
); );
expect(first.tokenBalances).toEqual([]); expect(first.tokenBalances).toEqual([]);
}); });
test("the refresh resolves with each tracked token whose balance it read at every address", async () => {
const flaky = "0x9999999999999999999999999999999999999999";
holdings[TOKEN] = { [FIRST]: 2000000n, [SECOND]: 0n };
// balanceOf answers at FIRST and fails at SECOND.
holdings[flaky] = { [FIRST]: 2000000n };
const read = await refreshBalances(
[
{
addresses: [
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
},
],
RPC_URL,
EXPLORER_URL,
[
TRACKED,
{ address: flaky, symbol: "FLK", name: "Flaky", decimals: 6 },
// A known symbol from another contract: its balance is not read.
{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 },
],
"mainnet",
new AbortController().signal,
);
expect(read).toEqual([TOKEN]);
});
+2 -4
View File
@@ -159,9 +159,7 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
"sepolia", "sepolia",
expect.anything(), expect.anything(),
); );
// No refresh has read the new token's balance yet. expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
const added = { ...SEPOLIA_TOKEN, balanceNotFetched: true };
expect(stored().trackedTokens).toEqual([added]);
await boot.click("btn-settings"); await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet"); await chooseNetwork(boot, "mainnet");
@@ -173,7 +171,7 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
await chooseNetwork(boot, "sepolia"); await chooseNetwork(boot, "sepolia");
expect(stored().networkId).toBe("sepolia"); expect(stored().networkId).toBe("sepolia");
expect(stored().trackedTokens).toEqual([added]); expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
const onSepolia = boot.node("settings-tracked-tokens").innerHTML; const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
expect(onSepolia).toContain("TST"); expect(onSepolia).toContain("TST");
expect(onSepolia).not.toContain("DAI"); expect(onSepolia).not.toContain("DAI");
+332 -3
View File
@@ -1428,8 +1428,8 @@ describe("uniswap decoder", () => {
]; ];
// execute() of these command bytes and inputs, decoded as sent by // execute() of these command bytes and inputs, decoded as sent by
// the user. // the user to `router`.
function decodeSteps(commands, inputs) { function decodeSteps(commands, inputs, router = ROUTER_ADDR) {
const data = buildExecute( const data = buildExecute(
solidityPacked( solidityPacked(
commands.map(() => "uint8"), commands.map(() => "uint8"),
@@ -1438,7 +1438,7 @@ describe("uniswap decoder", () => {
inputs, inputs,
9999999999n, 9999999999n,
); );
return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); return uniswap.decode(data, router, "0x1", {}, USER_ADDR);
} }
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the // The first step: a V3 exact-in of 600 USDT for USDC, paid by the
@@ -2424,6 +2424,335 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines); expect(linesBetweenTokens(result)).toEqual(lines);
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/665 — a WRAP_ETH that
// sets the input side and pays its WETH to anyone but the router
// names that recipient on its Moved to line. On next each row read
// The swap step that spends it.
const ONE_ETH = 1000000000000000000n;
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
// The lines of a WRAP_ETH of 1 ETH whose Moved to line reads `to`.
const wrapLines = (to) => [
["Moved from the sender", "1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", to],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(OTHER_ADDR, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines(OTHER_ADDR),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a WRAP_ETH of 1 ETH to address(1), then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(MSG_SENDER, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines("The sender"),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a WRAP_ETH of 1 ETH to another address, then a V3 exact-in of 600 USDT",
commands: [0x0a, 0x0b, 0x00],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V3_600,
],
lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/665 — V4Router pays a
// SETTLE of ETH from the router's own ETH, whichever payer it names.
// After an earlier step that may leave ETH in the router, the ETH that
// the user pays a V4 step reads as a maximum, on lines of its own.
// Every row reads at least the ETH that can leave the wallet. The two
// rows marked "reads as on next" read the same on next. On next every
// other row added that ETH to the Amount line, or read it as an exact
// amount.
//
// ADDRESS_THIS is the router on every release.
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const WRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, ONE_ETH);
// UNWRAP_WETH has the layout of WRAP_ETH.
const UNWRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, 0n);
// A V4 step swapping `amount` of ETH for USDC, with a SETTLE of
// OPEN_DELTA by the user.
const v4EthForUsdc = (amount) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n),
settle(ZERO_ADDR, 0n, true),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const V4_ONE_ETH = v4EthForUsdc(ONE_ETH);
// A TRANSFER or PAY_PORTION of `amount` of `token` to another
// address.
const payOut = (token, amount) =>
coder.encode(
["address", "address", "uint256"],
[token, OTHER_ADDR, amount],
);
// An ACROSS_V4_DEPOSIT_V3 of `amount` of ETH (`useNative` set), from
// the sender to the sender on chain id 8453.
const acrossEth = (amount) =>
coder.encode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
[
[
USER_ADDR,
USER_ADDR,
WETH_ADDR,
WETH_ADDR,
amount,
1n,
8453n,
ZERO_ADDR,
0,
0,
0,
"0x",
true,
],
],
);
// The lines of ETH the sender pays a V4 step, of `amount`.
const ethLines = (amount) => [
["Moved from the sender", amount],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 ETH that the user settles, reads as on next",
commands: [0x0b, 0x10],
inputs: [WRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x0c, 0x10],
inputs: [WRAP_INTO_ROUTER, UNWRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a TRANSFER of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x05, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
payOut(ZERO_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a PAY_PORTION of 5,000 bips of WETH to another address before the UNWRAP_WETH",
commands: [0x0b, 0x06, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
payOut(WETH_ADDR, 5000n),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a WRAP_ETH of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x0b, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same on UniversalRouterV2_1_2 with an ACROSS_V4_DEPOSIT_V3 of 1 ETH after the UNWRAP_WETH",
router: ROUTER_V2_1_2_ADDR,
commands: [0x0b, 0x0c, 0x40, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
acrossEth(ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT that the user pays, a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0b, 0x0c, 0x10],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0c, 0x10],
inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, then a V4 swap of 5 ETH that the user settles",
commands: [0x10, 0x10],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
v4EthForUsdc(5n * ONE_ETH),
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 5.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH, reads as on next",
commands: [0x0b, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
390000000n,
3n * ONE_ETH,
),
encodeV4SettleAll(ZERO_ADDR, 3n * ONE_ETH),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
],
lines: [
...wrapLines("The swap step that spends it"),
...ethLines(UNDECODED),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x01, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV3SwapExactOut(
USER_ADDR,
390000000n,
ONE_ETH,
[USDC_ADDR, WETH_ADDR],
false,
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "Up to 1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V4 exact-out paid from the router's WETH, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x10, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
390000000n,
ONE_ETH,
),
settle(WETH_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
])("$kind", ({ router, commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs, router);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax` // A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC. // USDT for USDC.
const v3ExactOut = (amountInMax) => const v3ExactOut = (amountInMax) =>