Compare commits

...
2 Commits
Author SHA1 Message Date
sneak 066842bcec chore: escape every value the views write as markup, and cut symbols on code points (closes #329)
e2e / e2e-chrome (push) Failing after 24s
e2e / e2e-firefox (push) Failing after 2s
check / check (push) Failing after 3h8m15s
The token screen's decimals and holder count, the ETH price, every address
total and each balance row's USD value went into innerHTML unescaped, against
the rule at the top of src/popup/views/helpers.js. They are escaped now. None
could carry markup, but formatUsd() writes a value under a cent as "< $0.01".

displaySymbol() counts a symbol in code points, not UTF-16 units, so the cut
never leaves half of an emoji, which rendered as U+FFFD.

explorerLink() was already removed on next.

Model: opus-5-5
2026-10-05 08:09:55 +00:00
clawbot 0af8b09305 test: a failing e2e test leaves no fixture switch or send screen behind (closes #318)
check / check (push) Failing after 4s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 3s
A test that turns a fixture switch on for itself alone turns it off in a
finally, so a failure no longer reddens the tests after it. The two tests
that drive the popup's own send also return it to the address screen,
reopening the popup to leave a wait for a receipt. The lying-decimals()
test asserts that nothing was broadcast as soon as the send ends, before
waiting for the failure screen. ethCallResult() answers an override of 0
instead of falling back to the explorer's scale.

Model: opus-5-5
2026-10-05 10:09:07 +02:00
11 changed files with 475 additions and 352 deletions
+22
View File
@@ -45,6 +45,28 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-05: Escaping in the popup's views follows its own rule with no
exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The
decimals and holder count on a token's screen, and every USD figure (the ETH
price, each total and each balance row's value), went into `innerHTML`
unescaped; they are escaped now. None could carry markup, but `formatUsd()`
writes a value under a cent as `< $0.01`. `displaySymbol()` counts a symbol in
code points rather than UTF-16 units, so a cut never splits an emoji into a
half that renders as U+FFFD. `explorerLink()`, also named in the issue, was
already removed by [#168](https://git.eeqj.de/sneak/AutistMask/issues/168).
- 2026-10-05: A Chrome end-to-end test that fails no longer takes later tests
down with it ([#318](https://git.eeqj.de/sneak/AutistMask/issues/318)). Each
test that turns a fixture switch on for itself alone (a held or failing gas
estimate, a seeded native transfer or receipt, a token's lying `decimals()` or
markup symbol) turns it off again in a `finally`, and the two tests that drive
the popup's own send end on the address screen whether they pass or not,
reopening the popup to leave a wait for a receipt. The lying-`decimals()` test
checks that nothing was broadcast as soon as the send ends, before it waits
for the failure screen, so a broadcast fails it in seconds rather than after a
60-second wait. The fixture's `decimals()` override tells 0 from no override,
so a token with no decimal places can be fixtured.
- 2026-10-05: Chrome draws the popup in its monospace font
([#418](https://git.eeqj.de/sneak/AutistMask/issues/418)), as Firefox does.
Chrome adds a stylesheet of its own to extension pages that sets the font on
+1 -1
View File
@@ -66,7 +66,7 @@ function show() {
$("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = usdTotal || "&nbsp;";
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden");
+3 -3
View File
@@ -103,7 +103,7 @@ function show() {
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = usdStr || "&nbsp;";
$("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);
@@ -148,9 +148,9 @@ function show() {
if (tokenSymbol)
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
if (tokenDecimals != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`;
if (tokenHolders != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${escapeHtml(Number(tokenHolders).toLocaleString())}</div>`;
if (projectUrl)
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
contractInfo.innerHTML = infoHtml;
+1 -1
View File
@@ -325,7 +325,7 @@ function balanceLine(symbol, amount, price, tokenId) {
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
const usd =
price && amount !== null
? formatUsd(amount * price) || "&nbsp;"
? escapeHtml(formatUsd(amount * price)) || "&nbsp;"
: "&nbsp;";
// tokenId is a contract address out of the same explorer JSON, and it
// lands inside a quoted attribute.
+4 -3
View File
@@ -63,7 +63,7 @@ function renderTotalValue() {
const ethPrice = getPrice("ETH");
if (priceEl) {
priceEl.innerHTML = ethPrice
? formatUsd(ethPrice) + " USD/ETH"
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
: "&nbsp;";
}
@@ -79,7 +79,8 @@ function renderTotalValue() {
el.textContent = ethStr + ethUsd;
if (subEl) {
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&nbsp;";
subEl.innerHTML =
escapeHtml(formatAddressTotal(getAddressValue(addr))) || "&nbsp;";
}
}
@@ -280,7 +281,7 @@ function walletListHtml() {
}
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || "&nbsp;"}</div>`;
html += balanceLinesForAddress(
addr,
state.trackedTokens,
+7 -2
View File
@@ -20,6 +20,10 @@
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
// truncated. The ellipsis is what tells the user the name they are looking
// at is not the whole name — worth knowing before they send to it.
//
// Characters are counted as code points, not UTF-16 units, so an emoji is
// one character and the cut never falls between the two halves of one: a
// half on its own renders as U+FFFD.
const MAX_SYMBOL_LENGTH = 12;
@@ -32,8 +36,9 @@ const UNKNOWN_SYMBOL = "???";
function displaySymbol(symbol) {
const s = symbol === null || symbol === undefined ? "" : String(symbol);
if (s.length === 0) return UNKNOWN_SYMBOL;
if (s.length <= MAX_SYMBOL_LENGTH) return s;
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
const chars = Array.from(s);
if (chars.length <= MAX_SYMBOL_LENGTH) return s;
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
}
module.exports = {
+8
View File
@@ -194,6 +194,14 @@ describe("the wallet list on Home", () => {
clearPrices();
expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;");
});
// A total under a cent is written "< $0.01", and the "<" is escaped
// here as the removal warning escapes it.
test("a total under a cent is escaped, as on the removal warning", () => {
const tiny = { ...EMPTY, balance: "0.000001" };
expect(walletListTotal(tiny)).toBe("Total: &lt; $0.01");
expect(removalWarningTotal(tiny)).toBe("Total: &lt; $0.01");
});
});
describe("the balance warning on the address-removal confirmation", () => {
+7
View File
@@ -66,4 +66,11 @@ describe("balanceLine", () => {
expect(html).toContain("<span>1.5000</span>");
expect(html).toContain('data-token="0xabc"');
});
// formatUsd() writes a value under a cent as "< $0.01".
test("escapes the USD value along with the symbol", () => {
const html = balanceLine("USDC", 0.001, 1, null);
expect(html).toContain("&lt; $0.01");
expect(html).not.toContain("< $0.01");
});
});
+7 -5
View File
@@ -256,14 +256,15 @@ const SELECTOR_DECIMALS = "0x313ce567";
// Blockscout, which is exactly the disagreement the wallet must refuse to
// sign over. It is read at request time, so a test flips it on the options
// object the route was registered with — after the confirmation screen has
// been built — without re-registering anything.
// been built — without re-registering anything. Only null or undefined means
// no override: 0 is a token with no decimal places, and is answered as one.
function ethCallResult(req, opts) {
const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
}
return ZERO_WORD;
}
@@ -597,9 +598,10 @@ function traceEnabled(raw) {
* eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order.
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
* the stub token, in place of the value Blockscout reports for it. This is
* the token that lies about its scale; read at request time.
* @param {number|string|null} [opts.tokenDecimalsOverride] the scale
* decimals() answers for the stub token, in place of the value Blockscout
* reports for it; null for none, while 0 is a scale like any other. This
* is the token that lies about its scale; read at request time.
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as
* the stub token's symbol, in place of "E2E". This is the token whose
* symbol is markup; read at request time.
+404 -337
View File
@@ -366,54 +366,59 @@ test("the native ETH transaction detail still renders (#151)", async (env) => {
// the assertions below are as much about that row staying hidden as
// about the screen coming up.
env.routeOpts.seedNativeTransfer = true;
await env.page.reload();
await openAddressDetail(env.page);
try {
await env.page.reload();
await openAddressDetail(env.page);
const row = env.page
.locator("#tx-list .tx-row")
.filter({ hasText: NATIVE_ROW_TEXT });
await row.waitFor({ state: "visible", timeout: 30000 });
await row.click();
await visible(env.page, "#view-transaction");
const row = env.page
.locator("#tx-list .tx-row")
.filter({ hasText: NATIVE_ROW_TEXT });
await row.waitFor({ state: "visible", timeout: 30000 });
await row.click();
await visible(env.page, "#view-transaction");
const hash = await env.page.locator("#tx-detail-hash").innerText();
assert(
hash.includes(STUB_NATIVE_TX_HASH),
"the native transaction detail shows the wrong hash: " + hash,
);
const hash = await env.page.locator("#tx-detail-hash").innerText();
assert(
hash.includes(STUB_NATIVE_TX_HASH),
"the native transaction detail shows the wrong hash: " + hash,
);
const type = (await env.page.locator("#tx-detail-type").innerText()).trim();
assert(
type === "Native ETH Transfer",
"the native transaction was classified " + JSON.stringify(type),
);
const type = (
await env.page.locator("#tx-detail-type").innerText()
).trim();
assert(
type === "Native ETH Transfer",
"the native transaction was classified " + JSON.stringify(type),
);
const value = await env.page.locator("#tx-detail-value").innerText();
assert(
value.includes(NATIVE_DETAIL_TEXT),
"the native transaction detail shows " +
JSON.stringify(value) +
", expected it to contain " +
NATIVE_DETAIL_TEXT,
);
const value = await env.page.locator("#tx-detail-value").innerText();
assert(
value.includes(NATIVE_DETAIL_TEXT),
"the native transaction detail shows " +
JSON.stringify(value) +
", expected it to contain " +
NATIVE_DETAIL_TEXT,
);
const native = await env.page.locator("#tx-detail-native").innerText();
assert(
native.includes(STUB_NATIVE_VALUE_WEI + " wei"),
"the raw quantity row shows " +
JSON.stringify(native) +
", expected the value in wei",
);
const native = await env.page.locator("#tx-detail-native").innerText();
assert(
native.includes(STUB_NATIVE_VALUE_WEI + " wei"),
"the raw quantity row shows " +
JSON.stringify(native) +
", expected the value in wei",
);
assert(
!(await env.page.isVisible("#tx-detail-token-contract-section")),
"the token contract row is showing on a transfer that has no token " +
"contract",
);
// Back to one seeded transaction for everything after this: the tests
// below were written against a list holding the token transfer alone.
env.routeOpts.seedNativeTransfer = false;
assert(
!(await env.page.isVisible("#tx-detail-token-contract-section")),
"the token contract row is showing on a transfer that has no " +
"token contract",
);
} finally {
// Back to one seeded transaction for everything after this: the
// tests below were written against a list holding the token transfer
// alone.
env.routeOpts.seedNativeTransfer = false;
}
});
test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => {
@@ -1785,6 +1790,7 @@ test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async
expectToken: true,
});
// Released by the next test, which watches the estimate land.
env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, {
token: "ETH",
@@ -1947,102 +1953,117 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
);
env.routeOpts.failGasEstimate = true;
env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
try {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
const pending = await confirmState(env.page);
assert(
pending.fee === "Estimating..." && pending.sendDisabled,
"the screen is not in the pending state before the estimate fails",
);
const pending = await confirmState(env.page);
assert(
pending.fee === "Estimating..." && pending.sendDisabled,
"the screen is not in the pending state before the estimate fails",
);
env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page);
env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page);
const st = await confirmState(env.page);
env.routeOpts.failGasEstimate = false;
assert(
st.fee === "Unable to estimate",
"the fee line does not report the failure: " + JSON.stringify(st.fee),
);
assert(
!st.reserveShown,
"the reserve line is shown after a failed estimate",
);
assert(
st.feeUnknownError,
"the estimate-failed message is not shown after a failed estimate",
);
assert(
!st.amountFeeError && !st.gasError && st.errors === "",
"a balance message is shown for an estimate that simply failed",
);
assert(
st.sendDisabled,
"Send is enabled with no usable fee estimate — an unknown fee is being treated as zero",
);
assert(
st.height === pending.height,
"the view changed height when the estimate failed: " +
pending.height +
"px -> " +
st.height +
"px",
);
const st = await confirmState(env.page);
assert(
st.fee === "Unable to estimate",
"the fee line does not report the failure: " +
JSON.stringify(st.fee),
);
assert(
!st.reserveShown,
"the reserve line is shown after a failed estimate",
);
assert(
st.feeUnknownError,
"the estimate-failed message is not shown after a failed estimate",
);
assert(
!st.amountFeeError && !st.gasError && st.errors === "",
"a balance message is shown for an estimate that simply failed",
);
assert(
st.sendDisabled,
"Send is enabled with no usable fee estimate — an unknown fee is being treated as zero",
);
assert(
st.height === pending.height,
"the view changed height when the estimate failed: " +
pending.height +
"px -> " +
st.height +
"px",
);
} finally {
env.routeOpts.failGasEstimate = false;
env.routeOpts.holdGasEstimate = false;
}
});
test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (env) => {
env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
try {
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
const pending = await confirmState(env.page);
env.erc20PendingHeight = pending.height;
console.log("# confirm-tx ERC-20 view height: " + pending.height + "px");
assert(
pending.type === "ERC-20 token transfer (" + STUB_TOKEN.symbol + ")",
"unexpected transaction type: " + JSON.stringify(pending.type),
);
assert(
pending.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"the ERC-20 screen shows the wrong balance: " +
JSON.stringify(pending.balance),
);
assert(
pending.fee === "Estimating..." && pending.sendDisabled,
"the ERC-20 screen does not block sending while its estimate is pending",
);
const pending = await confirmState(env.page);
env.erc20PendingHeight = pending.height;
console.log(
"# confirm-tx ERC-20 view height: " + pending.height + "px",
);
assert(
pending.type ===
"ERC-20 token transfer (" + STUB_TOKEN.symbol + ")",
"unexpected transaction type: " + JSON.stringify(pending.type),
);
assert(
pending.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"the ERC-20 screen shows the wrong balance: " +
JSON.stringify(pending.balance),
);
assert(
pending.fee === "Estimating..." && pending.sendDisabled,
"the ERC-20 screen does not block sending while its estimate is pending",
);
env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page);
env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page);
const st = await confirmState(env.page);
assert(
st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
"the ERC-20 fee line does not quote the estimate: " +
JSON.stringify(st.fee),
);
assert(
st.reserveShown &&
st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved",
"the ERC-20 fee block does not quote the reserve: " +
JSON.stringify(st.reserve),
);
assert(!st.sendDisabled, "Send is disabled for a funded ERC-20 transfer");
assert(
st.height === pending.height,
"the ERC-20 view changed height when the estimate landed: " +
pending.height +
"px -> " +
st.height +
"px",
);
const st = await confirmState(env.page);
assert(
st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
"the ERC-20 fee line does not quote the estimate: " +
JSON.stringify(st.fee),
);
assert(
st.reserveShown &&
st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved",
"the ERC-20 fee block does not quote the reserve: " +
JSON.stringify(st.reserve),
);
assert(
!st.sendDisabled,
"Send is disabled for a funded ERC-20 transfer",
);
assert(
st.height === pending.height,
"the ERC-20 view changed height when the estimate landed: " +
pending.height +
"px -> " +
st.height +
"px",
);
} finally {
env.routeOpts.holdGasEstimate = false;
}
});
test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", async (env) => {
@@ -2144,40 +2165,43 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
/gas estimation failed/,
);
env.routeOpts.failGasEstimate = true;
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
try {
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
env.routeOpts.failGasEstimate = false;
assert(
st.fee === "Unable to estimate",
"the ERC-20 fee line does not report the failure: " +
JSON.stringify(st.fee),
);
assert(
st.feeUnknownError,
"the estimate-failed message is not shown on the ERC-20 path",
);
assert(
!st.gasError,
"the ERC-20 network-fee message is shown for a fee that is unknown rather than unaffordable",
);
assert(
st.sendDisabled,
"Send is enabled on the ERC-20 path with no usable fee estimate",
);
assert(
st.height === env.erc20PendingHeight,
"the ERC-20 estimate-failed state is a different height than its pending state: " +
env.erc20PendingHeight +
"px -> " +
st.height +
"px",
);
const st = await confirmState(env.page);
assert(
st.fee === "Unable to estimate",
"the ERC-20 fee line does not report the failure: " +
JSON.stringify(st.fee),
);
assert(
st.feeUnknownError,
"the estimate-failed message is not shown on the ERC-20 path",
);
assert(
!st.gasError,
"the ERC-20 network-fee message is shown for a fee that is unknown rather than unaffordable",
);
assert(
st.sendDisabled,
"Send is enabled on the ERC-20 path with no usable fee estimate",
);
assert(
st.height === env.erc20PendingHeight,
"the ERC-20 estimate-failed state is a different height than its pending state: " +
env.erc20PendingHeight +
"px -> " +
st.height +
"px",
);
} finally {
env.routeOpts.failGasEstimate = false;
}
});
// ------------------------- the popup's own send, end to end (#305)
@@ -2234,6 +2258,34 @@ async function fillPasswordAndSend(page) {
await page.click("#btn-confirm-send");
}
// Back to the address screen from wherever a send stopped, which is where a
// passing send test leaves the popup for the next one. The success and failure
// screens are left by their Done button. The wait for a receipt has no button
// and asks only every ten seconds; a reopened popup resumes it and asks at
// once, and seedReceipt has the stub node confirm the transaction.
async function backToAddressAfterSend(env) {
if (await env.page.isVisible("#view-wait-tx")) {
env.routeOpts.seedReceipt = true;
await waitForPersisted(
env.page,
"currentView",
"wait-tx",
"before closing the popup",
);
await env.page.close();
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-success-tx");
env.routeOpts.seedReceipt = false;
}
for (const done of ["#btn-success-tx-done", "#btn-error-tx-done"]) {
if (await env.page.isVisible(done)) {
await env.page.click(done);
await visible(env.page, "#view-address");
}
}
await backToAddress(env.page);
}
async function goToTokenConfirm(env) {
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
@@ -2254,132 +2306,143 @@ test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", as
// The previous test left the ETH balance at the fee-only fixture, which
// blocks sending outright; this one has to be able to press Send.
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
const shown = await goToTokenConfirm(env);
const before = env.routeOpts.broadcastTransactions.length;
// Confirm the transaction once it is broadcast, so the wait screen
// resolves to the success view instead of polling for the rest of the run.
env.routeOpts.seedReceipt = true;
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-wait-tx", 60000);
try {
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
const shown = await goToTokenConfirm(env);
const broadcast = env.routeOpts.broadcastTransactions;
assert(
broadcast.length === before + 1,
"expected exactly one raw transaction to reach the RPC, got " +
(broadcast.length - before),
);
const { signed, recipient, rawAmount } = decodeTransfer(
broadcast[broadcast.length - 1],
);
const before = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-wait-tx", 60000);
// The measurement, printed on every run: the amount the user read, and
// what the signed bytes mean at each of the two candidate scales. Under
// the defect these three lines disagree.
console.log(
"# erc-20 send artifact: displayed=" +
JSON.stringify(shown) +
" rawAmount=" +
rawAmount +
" asIf" +
TOKEN_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" asIf" +
LYING_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, Number(LYING_DECIMALS)),
);
const broadcast = env.routeOpts.broadcastTransactions;
assert(
broadcast.length === before + 1,
"expected exactly one raw transaction to reach the RPC, got " +
(broadcast.length - before),
);
const { signed, recipient, rawAmount } = decodeTransfer(
broadcast[broadcast.length - 1],
);
assert(
getAddress(signed.to) === getAddress(STUB_TOKEN.address),
"the broadcast transaction does not call the token contract: " +
signed.to,
);
assert(
recipient === getAddress(STUB_COUNTERPARTY),
"the broadcast transfer goes to " + recipient,
);
// What the whole issue turns on: the signed amount, read back at the
// scale the SCREEN rendered with, is the number the screen rendered.
const wanted = parseUnits(shown.split(" ")[0], TOKEN_DECIMALS);
assert(
rawAmount === wanted,
"the broadcast transfer moves " +
rawAmount +
" base units, which is " +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" " +
STUB_TOKEN.symbol +
" at the scale the confirmation screen displayed — but the screen" +
" displayed " +
JSON.stringify(shown) +
", i.e. " +
wanted +
" base units (#305)",
);
// The measurement, printed on every run: the amount the user read,
// and what the signed bytes mean at each of the two candidate scales.
// Under the defect these three lines disagree.
console.log(
"# erc-20 send artifact: displayed=" +
JSON.stringify(shown) +
" rawAmount=" +
rawAmount +
" asIf" +
TOKEN_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" asIf" +
LYING_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, Number(LYING_DECIMALS)),
);
const summary = (
await env.page.locator("#wait-tx-summary").innerText()
).trim();
assert(
summary === shown,
"the wait screen summarises the send as " +
JSON.stringify(summary) +
", not as the approved " +
JSON.stringify(shown),
);
assert(
getAddress(signed.to) === getAddress(STUB_TOKEN.address),
"the broadcast transaction does not call the token contract: " +
signed.to,
);
assert(
recipient === getAddress(STUB_COUNTERPARTY),
"the broadcast transfer goes to " + recipient,
);
// What the whole issue turns on: the signed amount, read back at the
// scale the SCREEN rendered with, is the number the screen rendered.
const wanted = parseUnits(shown.split(" ")[0], TOKEN_DECIMALS);
assert(
rawAmount === wanted,
"the broadcast transfer moves " +
rawAmount +
" base units, which is " +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" " +
STUB_TOKEN.symbol +
" at the scale the confirmation screen displayed — but the screen" +
" displayed " +
JSON.stringify(shown) +
", i.e. " +
wanted +
" base units (#305)",
);
await visible(env.page, "#view-success-tx", 60000);
await env.page.click("#btn-success-tx-done");
await visible(env.page, "#view-address");
env.routeOpts.seedReceipt = false;
const summary = (
await env.page.locator("#wait-tx-summary").innerText()
).trim();
assert(
summary === shown,
"the wait screen summarises the send as " +
JSON.stringify(summary) +
", not as the approved " +
JSON.stringify(shown),
);
await visible(env.page, "#view-success-tx", 60000);
} finally {
env.routeOpts.seedReceipt = false;
await backToAddressAfterSend(env);
}
});
test("a token that lies about decimals() at signing time broadcasts nothing (#305)", async (env) => {
const shown = await goToTokenConfirm(env);
try {
const shown = await goToTokenConfirm(env);
// Only now, with the screen already built and its estimate already taken
// at the explorer's scale, does the contract start answering differently.
// This is the whole shape of the defect: a value read at signing time that
// nothing on screen was ever derived from.
env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS;
const before = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-error-tx", 60000);
env.routeOpts.tokenDecimalsOverride = null;
// Only now, with the screen already built and its estimate already
// taken at the explorer's scale, does the contract start answering
// differently. This is the whole shape of the defect: a value read at
// signing time that nothing on screen was ever derived from.
env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS;
const before = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
assert(
env.routeOpts.broadcastTransactions.length === before,
"a transfer encoded against a contract that contradicts the " +
"confirmation screen still reached the RPC (#305)",
);
// The count is asserted as soon as the send has ended either way, and
// the screen only after it: a transfer that got through shows as the
// wait for its receipt, never as the failure screen expected here.
await visible(
env.page,
"#view-wait-tx:not(.hidden), #view-error-tx:not(.hidden)",
60000,
);
assert(
env.routeOpts.broadcastTransactions.length === before,
"a transfer encoded against a contract that contradicts the " +
"confirmation screen still reached the RPC (#305)",
);
await visible(env.page, "#view-error-tx");
const message = (
await env.page.locator("#error-tx-message").innerText()
).trim();
console.log(
"# erc-20 decimals refusal: displayed=" +
JSON.stringify(shown) +
" contract=" +
LYING_DECIMALS +
" message=" +
JSON.stringify(message),
);
assert(
message.includes("reports " + LYING_DECIMALS + " decimal places") &&
message.includes("displayed using " + STUB_TOKEN.decimals),
"the refusal does not name both scales it is refusing over: " +
JSON.stringify(message),
);
assert(
/^[A-Z].*\.$/s.test(message),
"the refusal is not a full sentence: " + JSON.stringify(message),
);
await env.page.click("#btn-error-tx-done");
await visible(env.page, "#view-address");
const message = (
await env.page.locator("#error-tx-message").innerText()
).trim();
console.log(
"# erc-20 decimals refusal: displayed=" +
JSON.stringify(shown) +
" contract=" +
LYING_DECIMALS +
" message=" +
JSON.stringify(message),
);
assert(
message.includes("reports " + LYING_DECIMALS + " decimal places") &&
message.includes("displayed using " + STUB_TOKEN.decimals),
"the refusal does not name both scales it is refusing over: " +
JSON.stringify(message),
);
assert(
/^[A-Z].*\.$/s.test(message),
"the refusal is not a full sentence: " + JSON.stringify(message),
);
} finally {
env.routeOpts.tokenDecimalsOverride = null;
await backToAddressAfterSend(env);
}
});
// ------------------------------------------- hostile token symbol (#307)
@@ -2451,74 +2514,78 @@ test("a token whose symbol() returns markup renders as text (#307)", async (env)
"# stub token symbol() now returns: " + JSON.stringify(HOSTILE_SYMBOL),
);
// Close and reopen so the refresh that runs on open fetches balances
// with the hostile symbol in them.
await reopenPopup(env, "address");
await env.page.waitForFunction(
(addr) =>
!!document.querySelector(
'#address-balances [data-token="' + addr + '"]',
),
STUB_TOKEN.address,
{ timeout: 60000 },
);
const onAddress = await env.page.evaluate(() => ({
iframes: document.querySelectorAll("iframe").length,
pwnPresent: !!document.getElementById("pwn"),
}));
console.log("# address-detail iframes = " + onAddress.iframes);
assert(
onAddress.iframes === 0 && !onAddress.pwnPresent,
"the address screen contains " +
onAddress.iframes +
" iframe(s) after a hostile symbol rendered (#307)",
);
try {
// Close and reopen so the refresh that runs on open fetches balances
// with the hostile symbol in them.
await reopenPopup(env, "address");
await env.page.waitForFunction(
(addr) =>
!!document.querySelector(
'#address-balances [data-token="' + addr + '"]',
),
STUB_TOKEN.address,
{ timeout: 60000 },
);
const onAddress = await env.page.evaluate(() => ({
iframes: document.querySelectorAll("iframe").length,
pwnPresent: !!document.getElementById("pwn"),
}));
console.log("# address-detail iframes = " + onAddress.iframes);
assert(
onAddress.iframes === 0 && !onAddress.pwnPresent,
"the address screen contains " +
onAddress.iframes +
" iframe(s) after a hostile symbol rendered (#307)",
);
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await visible(
env.page,
'#wallet-list [data-token="' + STUB_TOKEN.address + '"]',
60000,
);
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await visible(
env.page,
'#wallet-list [data-token="' + STUB_TOKEN.address + '"]',
60000,
);
const st = await hostileSymbolState(env.page, STUB_TOKEN.address);
console.log(
"# iframes in the popup DOM = " +
st.iframes +
" | #pwn present = " +
st.pwnPresent +
" | symbol = " +
JSON.stringify(st.symbolText),
);
const st = await hostileSymbolState(env.page, STUB_TOKEN.address);
console.log(
"# iframes in the popup DOM = " +
st.iframes +
" | #pwn present = " +
st.pwnPresent +
" | symbol = " +
JSON.stringify(st.symbolText),
);
assert(st.rowFound, "the hostile token never rendered a row at all");
assert(
st.iframes === 0,
"the popup DOM contains " + st.iframes + " iframe(s) (#307)",
);
assert(!st.pwnPresent, "the injected #pwn element is in the popup DOM");
assert(
st.symbolElementChildren === 0,
"the symbol span grew " +
st.symbolElementChildren +
" element children out of a token symbol (#307)",
);
assert(
st.symbolText === HOSTILE_SYMBOL_DISPLAYED,
"the symbol did not render as the literal capped text " +
JSON.stringify(HOSTILE_SYMBOL_DISPLAYED) +
": " +
JSON.stringify(st.symbolText),
);
assert(
!st.rowText.includes("z-index"),
"the uncapped symbol reached the screen: " + JSON.stringify(st.rowText),
);
assert(st.rowFound, "the hostile token never rendered a row at all");
assert(
st.iframes === 0,
"the popup DOM contains " + st.iframes + " iframe(s) (#307)",
);
assert(!st.pwnPresent, "the injected #pwn element is in the popup DOM");
assert(
st.symbolElementChildren === 0,
"the symbol span grew " +
st.symbolElementChildren +
" element children out of a token symbol (#307)",
);
assert(
st.symbolText === HOSTILE_SYMBOL_DISPLAYED,
"the symbol did not render as the literal capped text " +
JSON.stringify(HOSTILE_SYMBOL_DISPLAYED) +
": " +
JSON.stringify(st.symbolText),
);
assert(
!st.rowText.includes("z-index"),
"the uncapped symbol reached the screen: " +
JSON.stringify(st.rowText),
);
} finally {
// Put the fixture back before the next test reads it.
env.routeOpts.tokenSymbolOverride = null;
}
// Put the fixture back before the next test reads it, and let the
// stored balances be rewritten with the honest symbol.
env.routeOpts.tokenSymbolOverride = null;
// Let the stored balances be rewritten with the honest symbol.
await reopenPopup(env, "main");
await env.page.waitForFunction(
(addr) => {
+11
View File
@@ -91,6 +91,17 @@ describe("displaySymbol", () => {
expect(displaySymbol(exact)).toBe(exact);
});
// An emoji outside the Basic Multilingual Plane is two UTF-16 units.
// Cutting between them leaves half of one, which renders as U+FFFD.
test("counts an emoji as one character and never cuts one in half", () => {
expect(displaySymbol("🚀".repeat(MAX_SYMBOL_LENGTH))).toBe(
"🚀".repeat(MAX_SYMBOL_LENGTH),
);
expect(displaySymbol("🚀".repeat(20))).toBe(
"🚀".repeat(MAX_SYMBOL_LENGTH - 1) + "…",
);
});
test("substitutes a placeholder for an absent symbol", () => {
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);