2 Commits
Author SHA1 Message Date
sneak a96085a2ba fix: a tracked token reads quantity unknown at an address no refresh has read (closes #720)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A new address, a new wallet and a network switch each leave tracked tokens
with no balance entry, so their lines read 0.0000 before any refresh had read
them.

Each of these now sets `balanceNotFetched` on the tracked tokens, and a refresh
already clears it. Their lines read `quantity unknown` until then. Addresses
that the wallet scan finds get the same. A refresh that did not read every
address the page now holds leaves the field set.

Model: opus-5-5
2026-11-03 07:16:44 +00:00
clawbot f79390cad5 fix: the wait and error screens show a swap's Moved from the sender amounts (closes #722)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A swap's Amount figure can leave out what its Moved from the sender lines
take, so these screens could read as if less left the wallet than can have
left. Both now show each of those amounts under the figure, using the decoded
details they already receive. The error screen saves those details too, so a
reopened popup keeps the lines. A saved record in another shape shows only the
lines it can read.

Model: opus-5-5
2026-11-03 08:12:41 +01:00
16 changed files with 579 additions and 36 deletions
+21 -9
View File
@@ -786,6 +786,7 @@ Shown after a broadcast. The screen holds:
- the heading "Transaction Broadcast", with no "Back" button;
- the amount and symbol;
- each `Moved from the sender` amount of a swap;
- the recipient;
- the transaction hash, with an explorer link;
- the counter "Waiting for confirmation... Ns".
@@ -823,7 +824,9 @@ Shown when signing or a broadcast fails, or when a wait ends without a receipt.
The screen holds:
- the heading "Transaction Failed";
- the amount and the recipient;
- the amount;
- each `Moved from the sender` amount of a swap;
- the recipient;
- the error message, in a dashed box;
- the transaction hash, when one exists;
- a "Done" button that behaves as on SuccessTx.
@@ -1197,14 +1200,23 @@ The token's symbol comes from the same places. The bundled list answers for a
token on Ethereum Mainnet only. When none of them answers, the amount reads
`5000000000 base units (decimals unknown)`, and nothing is formatted with
guessed decimals. A holding whose decimals are unknown shows "quantity unknown"
in the balance lists. A token just added shows "quantity unknown" in place of a
zero, until a refresh has read its balance at every address. Only a whole number
from 0 to 80 counts as a token's decimals. A token's `decimals` is stored only
as a source reported it, never guessed. A `null` balance means the decimals are
unknown, never that the balance is zero. While "Use Blockscout" is off, the
bundled list and the tracked tokens may lack a token's symbol or decimals. The
approval and status screens then add a line under that token saying that
Blockscout's report is not used.
in the balance lists.
A tracked token with no stored balance at an address can show "quantity unknown"
there in place of a zero. It does so after each of these:
- the token is added;
- an address or a wallet is added;
- the network is switched.
This lasts until a refresh has read the token's balance at every address.
Only a whole number from 0 to 80 counts as a token's decimals. A token's
`decimals` is stored only as a source reported it, never guessed. A `null`
balance means the decimals are unknown, never that the balance is zero. While
"Use Blockscout" is off, the bundled list and the tracked tokens may lack a
token's symbol or decimals. The approval and status screens then add a line
under that token saying that Blockscout's report is not used.
The wallet's own ERC-20 send is encoded with the decimals the screen showed. At
signing time the contract's `decimals()` is read only to be compared with those.
+8 -3
View File
@@ -98,8 +98,10 @@ let refreshTimer = null;
// 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
// balance this refresh read at every address. A refresh that did not read every
// address this page now holds deletes no `balanceNotFetched`. Adding a token
// sets that field (trackToken() in src/popup/views/helpers.js). So does adding
// an address, and so does a network switch. 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.
@@ -149,7 +151,10 @@ async function doRefreshAndRender() {
if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens);
}
clearBalanceNotFetched(stored, readTokens);
const readEveryAddress = state.wallets.every((wallet) =>
wallet.addresses.every((addr) => refreshed.has(addr.address)),
);
if (readEveryAddress) clearBalanceNotFetched(stored, readTokens);
state.lastBalanceRefresh = Date.now();
return true;
});
+6
View File
@@ -7,6 +7,7 @@ const {
goBack,
clearViewStack,
onViewLeave,
markTrackedTokensNotFetched,
} = require("./helpers");
const {
generateMnemonic,
@@ -195,6 +196,7 @@ async function importMnemonic(ctx) {
],
};
state.wallets.push(wallet);
markTrackedTokensNotFetched();
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
@@ -220,6 +222,7 @@ async function importMnemonic(ctx) {
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
markTrackedTokensNotFetched();
// The new list leaves out the first address when the scan found it
// unused. If it was the active one, the new list's first takes over,
// and the background is told as when the user picks one on Home.
@@ -266,6 +269,7 @@ async function importPrivateKey(ctx) {
encryptedSecret: encrypted,
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
});
markTrackedTokensNotFetched();
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = addr;
@@ -330,6 +334,7 @@ async function importXprvKey(ctx) {
],
};
state.wallets.push(wallet);
markTrackedTokensNotFetched();
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
@@ -355,6 +360,7 @@ async function importXprvKey(ctx) {
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
markTrackedTokensNotFetched();
// As for a recovery phrase: an active first address the scan left
// out gives way to the new list's first.
const activeMoved =
+2 -2
View File
@@ -172,8 +172,8 @@ function renderBalance() {
(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.
// token's balance: 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(tokenId);
+8
View File
@@ -430,6 +430,13 @@ function trackToken(token) {
state.trackedTokens.push({ ...token, balanceNotFetched: true });
}
// Gives every tracked token `balanceNotFetched: true`, as trackToken() gives a
// new one. Called when an address is added, before the save: no refresh has
// read any token's balance at that address.
function markTrackedTokensNotFetched() {
for (const token of state.trackedTokens) token.balanceNotFetched = true;
}
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
nativeCurrency(),
@@ -850,6 +857,7 @@ module.exports = {
balanceLine,
balanceLinesForAddress,
trackToken,
markTrackedTokensNotFetched,
addressHoldsFunds,
keyMaterialName,
unknownableAmount,
+2
View File
@@ -3,6 +3,7 @@ const {
showView,
showFlash,
balanceLinesForAddress,
markTrackedTokensNotFetched,
isoDate,
timeAgo,
addressDotHtml,
@@ -394,6 +395,7 @@ function render(ctx) {
tokenBalances: [],
});
wallet.nextIndex++;
markTrackedTokensNotFetched();
await saveState();
render(ctx);
ctx.doRefreshAndRender();
+5 -4
View File
@@ -242,10 +242,11 @@ function updateSendBalance() {
state.trackedTokens,
currentNetwork().chainId,
);
// A null balance is a holding whose scale is unknown. Saying a figure
// for it would be a claim about the amount, so it reads as the
// confirmation screen's balance line reads it; the send itself is
// refused later by transferAmountUnits() for the same missing scale.
// A null balance is a holding whose scale is unknown, or a tracked
// token whose balance no refresh has read. Saying a figure for it would
// be a claim about the amount, so it reads as the confirmation screen's
// balance line reads it. transferAmountUnits() later refuses a send at
// a missing scale.
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
$("send-balance").textContent =
bal == null
+31 -9
View File
@@ -73,6 +73,29 @@ function toAddressHtml(address, chainId) {
return renderAddressHtml(address, { title, chainId });
}
// The amount of each `Moved from the sender` line of a decoded swap, for the
// wait and error screens to show under the Amount figure. That figure can leave
// these amounts out, so alone it can read as less than can have left the
// wallet. `decoded` can come back from storage in any shape, so this shows only
// the lines whose value is text.
function movedFromSenderHtml(decoded) {
if (!decoded || !Array.isArray(decoded.details)) return "";
let html = "";
for (const d of decoded.details) {
if (
!d ||
d.label !== "Moved from the sender" ||
typeof d.value !== "string"
)
continue;
html += `<div class="mt-2">`;
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
html += `</div>`;
}
return html;
}
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
@@ -109,10 +132,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
txInfo.token,
txInfo.chainId,
);
$("wait-tx-token-note").innerHTML =
blockscoutOffTokenNoteHtml(txInfo.token, txInfo.chainId) +
movedFromSenderHtml(txInfo.decoded);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx");
@@ -223,7 +245,7 @@ function showWait(txInfo, txHash) {
// to name a network. txInfo.token and txInfo.tokenSymbol are not checked here.
// blockscoutOffTokenNoteHtml() shows no line for a token that is not text.
// Everything else only compares and coalesces them, and tokenSymbol is null for
// ETH.
// ETH. txInfo.decoded is not checked either: see movedFromSenderHtml().
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -356,6 +378,7 @@ function showError(txInfo, txHash, message) {
to: txInfo.to,
hash: txHash || null,
message: message,
decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
};
renderError();
@@ -366,10 +389,9 @@ function renderError() {
if (!d || !d.message) return;
$("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
d.token,
d.chainId,
);
$("error-tx-token-note").innerHTML =
blockscoutOffTokenNoteHtml(d.token, d.chainId) +
movedFromSenderHtml(d.decoded);
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-message").textContent = d.message;
+5
View File
@@ -48,9 +48,14 @@ function applyChainSwitchFields(s, newNetworkId) {
// It is a copy because a save writes a changed s.networkTrackedTokens whole,
// over any list another page stored. An add or removal on the active network
// must change only s.trackedTokens.
//
// Each token of the copy gets `balanceNotFetched`, because the balances are
// cleared below and no refresh has read this network's yet. Each reads
// `quantity unknown` until one has, not 0.0000.
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
s.trackedTokens = structuredClone(s.networkTrackedTokens[net.id] || []);
for (const token of s.trackedTokens) token.balanceNotFetched = true;
s.networkId = net.id;
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
+304
View File
@@ -11,14 +11,44 @@
// 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.
//
// Every tracked token carries that field again once an address or a wallet is
// added, and after a network switch, so it reads the same until a refresh has
// read it (https://git.eeqj.de/sneak/AutistMask/issues/720).
//
// 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 { HDNodeWallet, Mnemonic } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const { hdWalletFromMnemonic } = require("../src/shared/wallet");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PASSWORD = "correct horse battery staple";
// What AddWallet's tabs take. The first and last scan for more addresses.
const FROM_PHRASE = {
tab: "tab-mnemonic",
field: "wallet-mnemonic",
secret: PHRASE,
};
const FROM_PRIVATE_KEY = {
tab: "tab-privkey",
field: "import-private-key",
secret: "0x" + "11".repeat(32),
};
const FROM_XPRV = {
tab: "tab-xprv",
field: "import-xprv-key",
secret: HDNodeWallet.fromSeed(Mnemonic.fromPhrase(PHRASE).computeSeed())
.extendedKey,
};
// Tracked by nobody until a test adds it.
const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
@@ -66,6 +96,34 @@ function bootOnAddress(extra) {
);
}
// The popup over one wallet that "+" can derive an address from. Its one
// address holds a million NEW_TOKEN, and NEW_TOKEN is tracked.
function bootOnDerivingWallet() {
const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE);
return bootPopup(
unversionedValidProfile({
wallets: [
{
type: "hd",
name: "Wallet 1",
xpub,
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{
address: firstAddress,
balance: "1.5",
tokenBalances: [MILLION],
},
],
},
],
activeAddress: firstAddress,
trackedTokens: [NEW_TRACKED],
}),
);
}
// 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.
@@ -175,6 +233,47 @@ function newTokenRow(page, id = "address-balances") {
);
}
// Every address's row for NEW_TOKEN on Home, as markup, in the order Home lists
// the addresses.
function newTokenRowsOnHome(page) {
return page
.node("wallet-list")
.innerHTML.split("<div ")
.filter((row) =>
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'),
);
}
// The "+" button Home draws for the first wallet. As in firstRemoveButton(),
// the list hands Home a stand-in for that button each time it is drawn, and
// `press()` presses the last one drawn. Call it before Home next draws its list.
function plusButton(page) {
const button = { press: null };
page.node("wallet-list").querySelectorAll = (selector) =>
selector !== ".btn-add-address"
? []
: [
{
dataset: { wallet: "0" },
addEventListener: (name, fn) => {
button.press = () =>
fn({ stopPropagation: () => {} });
},
},
];
return button;
}
// "Add wallet" on Home, filled in from `source`, and "Import".
async function addWallet(page, { tab, field, secret }) {
await page.click("btn-add-wallet-bottom");
await page.click(tab);
page.node(field).value = secret;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
// AddressDetail for the one address opened from Home, as its `[info]` button
// does.
async function openAddress(page) {
@@ -456,3 +555,208 @@ test("a refresh does not bring back a token that another page removed after this
NEW_TRACKED,
]);
});
test("an address just added reads quantity unknown for a tracked token until a refresh has read it", async () => {
const page = await bootOnDerivingWallet();
const plus = plusButton(page);
await refreshOnce(page, [MILLION]);
const finish = holdNextRefresh(page);
await plus.press();
await page.settle();
const [held, added] = newTokenRowsOnHome(page);
expect(held).toContain("1000000.0000");
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
expect(
page.storage.read("autistmask").trackedTokens[0].balanceNotFetched,
).toBe(true);
// It reads the new address's balance, which is zero.
await finish([MILLION]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
});
test('an address "+" adds during a refresh reads quantity unknown when that refresh returns', async () => {
const page = await bootOnDerivingWallet();
const plus = plusButton(page);
await refreshOnce(page, [MILLION]);
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await plus.press();
await page.settle();
const finishNext = holdNextRefresh(page);
await finishRunning([MILLION]);
await ticking;
const added = newTokenRowsOnHome(page)[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
// It reads the new address's balance, which is zero.
await finishNext([MILLION]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
});
test.each([FROM_PHRASE, FROM_PRIVATE_KEY, FROM_XPRV])(
"a wallet just added from $tab reads quantity unknown for a tracked token until a refresh has read it",
async (source) => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const finish = holdNextRefresh(page);
await addWallet(page, source);
const added = newTokenRowsOnHome(page)[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
// It reads the new address's balance, which is zero.
await finish([]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
},
);
test("a wallet from a private key added during a refresh reads quantity unknown when that refresh returns", async () => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await addWallet(page, FROM_PRIVATE_KEY);
const finishNext = holdNextRefresh(page);
await finishRunning([]);
await ticking;
const added = newTokenRowsOnHome(page)[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
// It reads the new address's balance, which is zero.
await finishNext([]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
});
test.each([FROM_PHRASE, FROM_XPRV])(
"addresses the scan finds for a wallet from $tab read quantity unknown, even after a refresh returned during the scan",
async (source) => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const { scanForAddresses } = require("../src/shared/balances");
let finish;
scanForAddresses.mockImplementationOnce(async () => {
await refreshOnce(page, []);
finish = holdNextRefresh(page);
return {
addresses: [
{ address: "0x" + "dd".repeat(20), index: 0 },
{ address: "0x" + "ee".repeat(20), index: 1 },
],
nextIndex: 2,
};
});
await addWallet(page, source);
const [, first, second] = newTokenRowsOnHome(page);
for (const found of [first, second]) {
expect(found).toContain("quantity unknown");
expect(found).not.toContain("0.0000");
}
await finish([]);
expect(newTokenRowsOnHome(page)[2]).toContain("0.0000");
},
);
test.each([FROM_PHRASE, FROM_XPRV])(
"addresses the scan finds for a wallet from $tab read quantity unknown when a refresh running as the scan ends returns",
async (source) => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const { scanForAddresses } = require("../src/shared/balances");
let finishRunning;
let ticking;
scanForAddresses.mockImplementationOnce(async () => {
finishRunning = holdNextRefresh(page);
ticking = page.tick();
await page.settle();
return {
addresses: [
{ address: "0x" + "dd".repeat(20), index: 0 },
{ address: "0x" + "ee".repeat(20), index: 1 },
],
nextIndex: 2,
};
});
await addWallet(page, source);
const finishNext = holdNextRefresh(page);
await finishRunning([]);
await ticking;
const [, first, second] = newTokenRowsOnHome(page);
for (const found of [first, second]) {
expect(found).toContain("quantity unknown");
expect(found).not.toContain("0.0000");
}
// It reads the new addresses' balances, which are zero.
await finishNext([]);
expect(newTokenRowsOnHome(page)[2]).toContain("0.0000");
},
);
test("after a network switch in Settings, a tracked token reads quantity unknown until a refresh has read it", async () => {
const page = await bootOnAddress({
trackedTokens: [OTHER_TOKEN],
networkTrackedTokens: { sepolia: [NEW_TRACKED] },
});
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
// It reads the balance, which is zero.
await refreshOnce(page, []);
expect(newTokenRow(page)).toContain("0.0000");
});
test("after a switch saved by the background, a tracked token reads quantity unknown until a refresh has read it", async () => {
const page = await bootOnAddress({
networkTrackedTokens: { sepolia: [NEW_TRACKED] },
});
const {
applyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
// The popup takes the switch, and this refresh reads no token's balance.
await page.tick();
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
});
+1
View File
@@ -44,6 +44,7 @@ jest.doMock("../src/popup/views/helpers", () => ({
goBack: () => {},
clearViewStack: () => {},
onViewLeave: () => {},
markTrackedTokensNotFetched: () => {},
}));
// state.js reads chrome.storage.local at load, and saveState() writes it.
+6 -1
View File
@@ -242,7 +242,12 @@ describe("a chain switch on a worker that never loaded state", () => {
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
expect(bg.persisted().wallets).toEqual(walletFixture());
expect(bg.persisted().trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
{
address: TOKEN,
symbol: "DAI",
decimals: 18,
balanceNotFetched: true,
},
]);
});
+3 -1
View File
@@ -245,7 +245,9 @@ test("a switch back in Settings after a follow whose write failed is stored", as
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(stored.trackedTokens).toEqual([
{ ...MAINNET_TOKEN, balanceNotFetched: true },
]);
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
+72
View File
@@ -340,3 +340,75 @@ describe.each([
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/722: a WRAP_ETH of 1 ETH into the
// router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH the user
// settles. The Amount line reads 1.0000 ETH, though up to 2 ETH can leave. The
// rest is on the swap's own Moved from the sender line.
describe("a swap with a Moved from the sender line of its own", () => {
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const TWO_ETH = 2n * ONE_ETH;
const SEND_FAILS = {
error: "The transaction could not be sent.",
retryable: false,
};
const data = routerIface.encodeFunctionData("execute", [
"0x0b0c10",
[
// WRAP_ETH and UNWRAP_WETH have the same layout.
coder.encode(["address", "uint256"], [ADDRESS_THIS, ONE_ETH]),
coder.encode(["address", "uint256"], [ADDRESS_THIS, 0n]),
coder.encode(
["bytes", "bytes[]"],
[
new Uint8Array([SWAP_EXACT_IN_SINGLE, SETTLE, TAKE_ALL]),
[
coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[ETH_USDC_POOL, true, ONE_ETH, 990000n, "0x"]],
),
// The user pays this one, not the router.
coder.encode(
["address", "uint256", "bool"],
[ZERO, 0n, true],
),
takeAllUsdc,
],
],
),
],
9999999999n,
]);
function expectMovedLine(id) {
const html = node(id).innerHTML;
expect(html).toContain("Moved from the sender");
expect(html).toContain("Up to 1.0000 ETH");
}
test("the wait screen shows that line's amount under the Amount figure", async () => {
await approveSwap(data, { txHash: TX_HASH }, TWO_ETH);
expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
expectMovedLine("wait-tx-token-note");
});
test("the error screen shows that line's amount under the Amount figure", async () => {
await approveSwap(data, SEND_FAILS, TWO_ETH);
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
expectMovedLine("error-tx-token-note");
});
test("a reopened popup's wait screen still shows it", async () => {
await approveSwap(data, { txHash: TX_HASH }, TWO_ETH);
node("wait-tx-token-note").innerHTML = "";
txStatus.restoreWait();
expectMovedLine("wait-tx-token-note");
});
test("a reopened popup's error screen still shows it", async () => {
await approveSwap(data, SEND_FAILS, TWO_ETH);
node("error-tx-token-note").innerHTML = "";
txStatus.renderError();
expectMovedLine("error-tx-token-note");
});
});
+25 -7
View File
@@ -180,7 +180,10 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
// No refresh has read a balance on this network since the switch.
expect(stored().trackedTokens).toEqual([
{ ...MAINNET_TOKEN, balanceNotFetched: true },
]);
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
expect(onMainnet).toContain("DAI");
expect(onMainnet).not.toContain("TST");
@@ -276,7 +279,9 @@ test("a token tracked on Sepolia when another page switched there is not tracked
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
@@ -441,7 +446,10 @@ test("a token another page added on Ethereum Mainnet is tracked when this page s
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN, newToken]);
expect(stored.trackedTokens).toEqual([
{ ...MAINNET_TOKEN, balanceNotFetched: true },
{ ...newToken, balanceNotFetched: true },
]);
});
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia. Another
@@ -489,7 +497,10 @@ test("a token another page added on Ethereum Mainnet stays when this page adds a
const stored = boot.storage.read("autistmask");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN, added]);
expect(stored.trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
added,
]);
});
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia, where TST is
@@ -513,7 +524,10 @@ test("a token another page removed on Ethereum Mainnet stays removed when this p
const stored = boot.storage.read("autistmask");
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN, added]);
expect(stored.trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
added,
]);
});
// Another popup page records a fraud contract on Ethereum Mainnet after this
@@ -1031,7 +1045,9 @@ describe("a contract lookup that answers after a switch adds nothing", () => {
await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet");
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
expect(stored().trackedTokens).toEqual([
{ ...MAINNET_TOKEN, balanceNotFetched: true },
]);
expect(stored().networkTrackedTokens.sepolia).toEqual([]);
});
@@ -1044,7 +1060,9 @@ describe("a contract lookup that answers after a switch adds nothing", () => {
await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia");
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored().trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
]);
expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
});
+80
View File
@@ -672,6 +672,86 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
});
});
describe("the Moved from the sender lines of a swap on WaitTx and ErrorTx", () => {
const moved = (value) => ({ label: "Moved from the sender", value });
const decoded = {
details: [
moved("Up to 1.0000 ETH"),
{ label: "Token moved", value: "ETH" },
moved("<b>400.0000</b> DAI"),
],
};
function expectEveryLine(id) {
const html = getElement(id).innerHTML;
expect(html).toContain("Up to 1.0000 ETH");
expect(html).toContain("&lt;b&gt;400.0000&lt;/b&gt; DAI");
expect(html).not.toContain("Token moved");
}
test("WaitTx shows each line's amount, escaped", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
expectEveryLine("wait-tx-token-note");
});
test("ErrorTx shows each line's amount, escaped", () => {
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
expectEveryLine("error-tx-token-note");
});
test("a wait that ends in ErrorTx keeps them", async () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(60000);
expect(state.currentView).toBe("error-tx");
expectEveryLine("error-tx-token-note");
});
// A saved record comes back from storage, in whatever shape it is in. A
// throw here would leave the popup's restore unfinished.
test("a saved record in another shape shows only the lines it can read", () => {
mockReceiptLookup.mockResolvedValue(null);
function noteOnReopen(saved) {
state.viewData = {
pendingWait: {
hash: TX_HASH,
txInfo: { ...TX_INFO, decoded: saved },
broadcastTime: Date.now(),
},
};
expect(txStatus.restoreWait()).toBe(true);
const waitNote = getElement("wait-tx-token-note").innerHTML;
txStatus.endWait();
state.viewData = {
message: "It failed.",
to: RECIPIENT,
chainId: TX_INFO.chainId,
decoded: saved,
};
txStatus.renderError();
expect(getElement("error-tx-token-note").innerHTML).toBe(waitNote);
return waitNote;
}
for (const saved of [
7,
"text",
{ details: 7 },
{ details: [null] },
{ details: [{ label: "Moved from the sender" }] },
{ details: [moved(7)] },
{ details: [moved({ toString: 1 })] },
]) {
expect(noteOnReopen(saved)).toBe("");
}
expect(
noteOnReopen({ details: [null, moved("Up to 1.0000 ETH")] }),
).toContain("Up to 1.0000 ETH");
});
});
describe("wait-tx is a view the popup may reopen onto", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills