1 Commits
Author SHA1 Message Date
sneak 22170faae6 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.

Model: opus-5-5
2026-11-03 06:40:39 +00:00
6 changed files with 39 additions and 314 deletions
+1 -4
View File
@@ -786,7 +786,6 @@ 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".
@@ -824,9 +823,7 @@ Shown when signing or a broadcast fails, or when a wait ends without a receipt.
The screen holds:
- the heading "Transaction Failed";
- the amount;
- each `Moved from the sender` amount of a swap;
- the recipient;
- the amount and the recipient;
- the error message, in a dashed box;
- the transaction hash, when one exists;
- a "Done" button that behaves as on SuccessTx.
+6 -11
View File
@@ -98,13 +98,11 @@ 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. 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.
// balance this refresh read at every address. 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.
//
// 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
@@ -151,10 +149,7 @@ async function doRefreshAndRender() {
if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens);
}
const readEveryAddress = state.wallets.every((wallet) =>
wallet.addresses.every((addr) => refreshed.has(addr.address)),
);
if (readEveryAddress) clearBalanceNotFetched(stored, readTokens);
clearBalanceNotFetched(stored, readTokens);
state.lastBalanceRefresh = Date.now();
return true;
});
+9 -31
View File
@@ -73,29 +73,6 @@ 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);
@@ -132,9 +109,10 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-token-note").innerHTML =
blockscoutOffTokenNoteHtml(txInfo.token, txInfo.chainId) +
movedFromSenderHtml(txInfo.decoded);
$("wait-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
txInfo.token,
txInfo.chainId,
);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx");
@@ -245,7 +223,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. txInfo.decoded is not checked either: see movedFromSenderHtml().
// ETH.
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -378,7 +356,6 @@ function showError(txInfo, txHash, message) {
to: txInfo.to,
hash: txHash || null,
message: message,
decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
};
renderError();
@@ -389,9 +366,10 @@ 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) +
movedFromSenderHtml(d.decoded);
$("error-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
d.token,
d.chainId,
);
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-message").textContent = d.message;
+23 -116
View File
@@ -96,34 +96,6 @@ 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.
@@ -557,7 +529,29 @@ test("a refresh does not bring back a token that another page removed after this
});
test("an address just added reads quantity unknown for a tracked token until a refresh has read it", async () => {
const page = await bootOnDerivingWallet();
const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE);
const page = await 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],
}),
);
const plus = plusButton(page);
await refreshOnce(page, [MILLION]);
const finish = holdNextRefresh(page);
@@ -579,30 +573,6 @@ test("an address just added reads quantity unknown for a tracked token until a r
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) => {
@@ -624,29 +594,6 @@ test.each([FROM_PHRASE, FROM_PRIVATE_KEY, FROM_XPRV])(
},
);
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) => {
@@ -681,46 +628,6 @@ test.each([FROM_PHRASE, FROM_XPRV])(
},
);
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],
-72
View File
@@ -340,75 +340,3 @@ 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");
});
});
-80
View File
@@ -672,86 +672,6 @@ 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