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
3 changed files with 423 additions and 342 deletions
+12
View File
@@ -55,6 +55,18 @@ but the review is broader than any of them.
half that renders as U+FFFD. `explorerLink()`, also named in the issue, was 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). 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 - 2026-10-05: Chrome draws the popup in its monospace font
([#418](https://git.eeqj.de/sneak/AutistMask/issues/418)), as Firefox does. ([#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 Chrome adds a stylesheet of its own to extension pages that sets the font on
+7 -5
View File
@@ -256,14 +256,15 @@ const SELECTOR_DECIMALS = "0x313ce567";
// Blockscout, which is exactly the disagreement the wallet must refuse to // 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 // 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 // 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) { function ethCallResult(req, opts) {
const call = Array.isArray(req.params) ? req.params[0] : null; const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD; if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase(); const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase(); const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) { 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; return ZERO_WORD;
} }
@@ -597,9 +598,10 @@ function traceEnabled(raw) {
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed * @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order. * transaction handed to eth_sendRawTransaction, appended in order.
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for * @param {number|string|null} [opts.tokenDecimalsOverride] the scale
* the stub token, in place of the value Blockscout reports for it. This is * decimals() answers for the stub token, in place of the value Blockscout
* the token that lies about its scale; read at request time. * 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 * @param {string} [opts.tokenSymbolOverride] what the explorer reports as
* the stub token's symbol, in place of "E2E". This is the token whose * the stub token's symbol, in place of "E2E". This is the token whose
* symbol is markup; read at request time. * 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 // the assertions below are as much about that row staying hidden as
// about the screen coming up. // about the screen coming up.
env.routeOpts.seedNativeTransfer = true; env.routeOpts.seedNativeTransfer = true;
await env.page.reload(); try {
await openAddressDetail(env.page); await env.page.reload();
await openAddressDetail(env.page);
const row = env.page const row = env.page
.locator("#tx-list .tx-row") .locator("#tx-list .tx-row")
.filter({ hasText: NATIVE_ROW_TEXT }); .filter({ hasText: NATIVE_ROW_TEXT });
await row.waitFor({ state: "visible", timeout: 30000 }); await row.waitFor({ state: "visible", timeout: 30000 });
await row.click(); await row.click();
await visible(env.page, "#view-transaction"); await visible(env.page, "#view-transaction");
const hash = await env.page.locator("#tx-detail-hash").innerText(); const hash = await env.page.locator("#tx-detail-hash").innerText();
assert( assert(
hash.includes(STUB_NATIVE_TX_HASH), hash.includes(STUB_NATIVE_TX_HASH),
"the native transaction detail shows the wrong hash: " + hash, "the native transaction detail shows the wrong hash: " + hash,
); );
const type = (await env.page.locator("#tx-detail-type").innerText()).trim(); const type = (
assert( await env.page.locator("#tx-detail-type").innerText()
type === "Native ETH Transfer", ).trim();
"the native transaction was classified " + JSON.stringify(type), assert(
); type === "Native ETH Transfer",
"the native transaction was classified " + JSON.stringify(type),
);
const value = await env.page.locator("#tx-detail-value").innerText(); const value = await env.page.locator("#tx-detail-value").innerText();
assert( assert(
value.includes(NATIVE_DETAIL_TEXT), value.includes(NATIVE_DETAIL_TEXT),
"the native transaction detail shows " + "the native transaction detail shows " +
JSON.stringify(value) + JSON.stringify(value) +
", expected it to contain " + ", expected it to contain " +
NATIVE_DETAIL_TEXT, NATIVE_DETAIL_TEXT,
); );
const native = await env.page.locator("#tx-detail-native").innerText(); const native = await env.page.locator("#tx-detail-native").innerText();
assert( assert(
native.includes(STUB_NATIVE_VALUE_WEI + " wei"), native.includes(STUB_NATIVE_VALUE_WEI + " wei"),
"the raw quantity row shows " + "the raw quantity row shows " +
JSON.stringify(native) + JSON.stringify(native) +
", expected the value in wei", ", expected the value in wei",
); );
assert( assert(
!(await env.page.isVisible("#tx-detail-token-contract-section")), !(await env.page.isVisible("#tx-detail-token-contract-section")),
"the token contract row is showing on a transfer that has no token " + "the token contract row is showing on a transfer that has no " +
"contract", "token contract",
); );
} finally {
// Back to one seeded transaction for everything after this: the tests // Back to one seeded transaction for everything after this: the
// below were written against a list holding the token transfer alone. // tests below were written against a list holding the token transfer
env.routeOpts.seedNativeTransfer = false; // alone.
env.routeOpts.seedNativeTransfer = false;
}
}); });
test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => { 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, expectToken: true,
}); });
// Released by the next test, which watches the estimate land.
env.routeOpts.holdGasEstimate = true; env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, { await goToConfirm(env.page, {
token: "ETH", 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.failGasEstimate = true;
env.routeOpts.holdGasEstimate = true; env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, { try {
token: "ETH", await goToConfirm(env.page, {
balance: FUNDED_ETH_TEXT + " ETH", token: "ETH",
amount: COMFORTABLE_AMOUNT, balance: FUNDED_ETH_TEXT + " ETH",
}); amount: COMFORTABLE_AMOUNT,
});
const pending = await confirmState(env.page); const pending = await confirmState(env.page);
assert( assert(
pending.fee === "Estimating..." && pending.sendDisabled, pending.fee === "Estimating..." && pending.sendDisabled,
"the screen is not in the pending state before the estimate fails", "the screen is not in the pending state before the estimate fails",
); );
env.routeOpts.holdGasEstimate = false; env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
env.routeOpts.failGasEstimate = false; assert(
assert( st.fee === "Unable to estimate",
st.fee === "Unable to estimate", "the fee line does not report the failure: " +
"the fee line does not report the failure: " + JSON.stringify(st.fee), JSON.stringify(st.fee),
); );
assert( assert(
!st.reserveShown, !st.reserveShown,
"the reserve line is shown after a failed estimate", "the reserve line is shown after a failed estimate",
); );
assert( assert(
st.feeUnknownError, st.feeUnknownError,
"the estimate-failed message is not shown after a failed estimate", "the estimate-failed message is not shown after a failed estimate",
); );
assert( assert(
!st.amountFeeError && !st.gasError && st.errors === "", !st.amountFeeError && !st.gasError && st.errors === "",
"a balance message is shown for an estimate that simply failed", "a balance message is shown for an estimate that simply failed",
); );
assert( assert(
st.sendDisabled, st.sendDisabled,
"Send is enabled with no usable fee estimate — an unknown fee is being treated as zero", "Send is enabled with no usable fee estimate — an unknown fee is being treated as zero",
); );
assert( assert(
st.height === pending.height, st.height === pending.height,
"the view changed height when the estimate failed: " + "the view changed height when the estimate failed: " +
pending.height + pending.height +
"px -> " + "px -> " +
st.height + st.height +
"px", "px",
); );
} finally {
env.routeOpts.failGasEstimate = false;
env.routeOpts.holdGasEstimate = false;
}
}); });
test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (env) => { test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (env) => {
env.routeOpts.holdGasEstimate = true; env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, { try {
token: STUB_TOKEN.address, await goToConfirm(env.page, {
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol, token: STUB_TOKEN.address,
amount: TOKEN_AMOUNT, balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
}); amount: TOKEN_AMOUNT,
});
const pending = await confirmState(env.page); const pending = await confirmState(env.page);
env.erc20PendingHeight = pending.height; env.erc20PendingHeight = pending.height;
console.log("# confirm-tx ERC-20 view height: " + pending.height + "px"); console.log(
assert( "# confirm-tx ERC-20 view height: " + pending.height + "px",
pending.type === "ERC-20 token transfer (" + STUB_TOKEN.symbol + ")", );
"unexpected transaction type: " + JSON.stringify(pending.type), assert(
); pending.type ===
assert( "ERC-20 token transfer (" + STUB_TOKEN.symbol + ")",
pending.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol, "unexpected transaction type: " + JSON.stringify(pending.type),
"the ERC-20 screen shows the wrong balance: " + );
JSON.stringify(pending.balance), assert(
); pending.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
assert( "the ERC-20 screen shows the wrong balance: " +
pending.fee === "Estimating..." && pending.sendDisabled, JSON.stringify(pending.balance),
"the ERC-20 screen does not block sending while its estimate is pending", );
); assert(
pending.fee === "Estimating..." && pending.sendDisabled,
"the ERC-20 screen does not block sending while its estimate is pending",
);
env.routeOpts.holdGasEstimate = false; env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
assert( assert(
st.fee === "~" + feeEth(FEE_ESTIMATE_WEI), st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
"the ERC-20 fee line does not quote the estimate: " + "the ERC-20 fee line does not quote the estimate: " +
JSON.stringify(st.fee), JSON.stringify(st.fee),
); );
assert( assert(
st.reserveShown && st.reserveShown &&
st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved", st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved",
"the ERC-20 fee block does not quote the reserve: " + "the ERC-20 fee block does not quote the reserve: " +
JSON.stringify(st.reserve), JSON.stringify(st.reserve),
); );
assert(!st.sendDisabled, "Send is disabled for a funded ERC-20 transfer"); assert(
assert( !st.sendDisabled,
st.height === pending.height, "Send is disabled for a funded ERC-20 transfer",
"the ERC-20 view changed height when the estimate landed: " + );
pending.height + assert(
"px -> " + st.height === pending.height,
st.height + "the ERC-20 view changed height when the estimate landed: " +
"px", 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) => { 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/, /gas estimation failed/,
); );
env.routeOpts.failGasEstimate = true; env.routeOpts.failGasEstimate = true;
await goToConfirm(env.page, { try {
token: STUB_TOKEN.address, await goToConfirm(env.page, {
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol, token: STUB_TOKEN.address,
amount: TOKEN_AMOUNT, balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
}); amount: TOKEN_AMOUNT,
await waitForEstimate(env.page); });
await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
env.routeOpts.failGasEstimate = false; assert(
assert( st.fee === "Unable to estimate",
st.fee === "Unable to estimate", "the ERC-20 fee line does not report the failure: " +
"the ERC-20 fee line does not report the failure: " + JSON.stringify(st.fee),
JSON.stringify(st.fee), );
); assert(
assert( st.feeUnknownError,
st.feeUnknownError, "the estimate-failed message is not shown on the ERC-20 path",
"the estimate-failed message is not shown on the ERC-20 path", );
); assert(
assert( !st.gasError,
!st.gasError, "the ERC-20 network-fee message is shown for a fee that is unknown rather than unaffordable",
"the ERC-20 network-fee message is shown for a fee that is unknown rather than unaffordable", );
); assert(
assert( st.sendDisabled,
st.sendDisabled, "Send is enabled on the ERC-20 path with no usable fee estimate",
"Send is enabled on the ERC-20 path with no usable fee estimate", );
); assert(
assert( st.height === env.erc20PendingHeight,
st.height === env.erc20PendingHeight, "the ERC-20 estimate-failed state is a different height than its pending state: " +
"the ERC-20 estimate-failed state is a different height than its pending state: " + env.erc20PendingHeight +
env.erc20PendingHeight + "px -> " +
"px -> " + st.height +
st.height + "px",
"px", );
); } finally {
env.routeOpts.failGasEstimate = false;
}
}); });
// ------------------------- the popup's own send, end to end (#305) // ------------------------- the popup's own send, end to end (#305)
@@ -2234,6 +2258,34 @@ async function fillPasswordAndSend(page) {
await page.click("#btn-confirm-send"); 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) { async function goToTokenConfirm(env) {
await goToConfirm(env.page, { await goToConfirm(env.page, {
token: STUB_TOKEN.address, 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 // 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. // blocks sending outright; this one has to be able to press Send.
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI); 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 // 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. // resolves to the success view instead of polling for the rest of the run.
env.routeOpts.seedReceipt = true; env.routeOpts.seedReceipt = true;
await fillPasswordAndSend(env.page); try {
await visible(env.page, "#view-wait-tx", 60000); await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
const shown = await goToTokenConfirm(env);
const broadcast = env.routeOpts.broadcastTransactions; const before = env.routeOpts.broadcastTransactions.length;
assert( await fillPasswordAndSend(env.page);
broadcast.length === before + 1, await visible(env.page, "#view-wait-tx", 60000);
"expected exactly one raw transaction to reach the RPC, got " +
(broadcast.length - before),
);
const { signed, recipient, rawAmount } = decodeTransfer(
broadcast[broadcast.length - 1],
);
// The measurement, printed on every run: the amount the user read, and const broadcast = env.routeOpts.broadcastTransactions;
// what the signed bytes mean at each of the two candidate scales. Under assert(
// the defect these three lines disagree. broadcast.length === before + 1,
console.log( "expected exactly one raw transaction to reach the RPC, got " +
"# erc-20 send artifact: displayed=" + (broadcast.length - before),
JSON.stringify(shown) + );
" rawAmount=" + const { signed, recipient, rawAmount } = decodeTransfer(
rawAmount + broadcast[broadcast.length - 1],
" asIf" + );
TOKEN_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" asIf" +
LYING_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, Number(LYING_DECIMALS)),
);
assert( // The measurement, printed on every run: the amount the user read,
getAddress(signed.to) === getAddress(STUB_TOKEN.address), // and what the signed bytes mean at each of the two candidate scales.
"the broadcast transaction does not call the token contract: " + // Under the defect these three lines disagree.
signed.to, console.log(
); "# erc-20 send artifact: displayed=" +
assert( JSON.stringify(shown) +
recipient === getAddress(STUB_COUNTERPARTY), " rawAmount=" +
"the broadcast transfer goes to " + recipient, rawAmount +
); " asIf" +
// What the whole issue turns on: the signed amount, read back at the TOKEN_DECIMALS +
// scale the SCREEN rendered with, is the number the screen rendered. "Decimals=" +
const wanted = parseUnits(shown.split(" ")[0], TOKEN_DECIMALS); formatUnits(rawAmount, TOKEN_DECIMALS) +
assert( " asIf" +
rawAmount === wanted, LYING_DECIMALS +
"the broadcast transfer moves " + "Decimals=" +
rawAmount + formatUnits(rawAmount, Number(LYING_DECIMALS)),
" 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)",
);
const summary = ( assert(
await env.page.locator("#wait-tx-summary").innerText() getAddress(signed.to) === getAddress(STUB_TOKEN.address),
).trim(); "the broadcast transaction does not call the token contract: " +
assert( signed.to,
summary === shown, );
"the wait screen summarises the send as " + assert(
JSON.stringify(summary) + recipient === getAddress(STUB_COUNTERPARTY),
", not as the approved " + "the broadcast transfer goes to " + recipient,
JSON.stringify(shown), );
); // 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); const summary = (
await env.page.click("#btn-success-tx-done"); await env.page.locator("#wait-tx-summary").innerText()
await visible(env.page, "#view-address"); ).trim();
env.routeOpts.seedReceipt = false; 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) => { 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 // Only now, with the screen already built and its estimate already
// at the explorer's scale, does the contract start answering differently. // taken at the explorer's scale, does the contract start answering
// This is the whole shape of the defect: a value read at signing time that // differently. This is the whole shape of the defect: a value read at
// nothing on screen was ever derived from. // signing time that nothing on screen was ever derived from.
env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS; env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS;
const before = env.routeOpts.broadcastTransactions.length; const before = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page); await fillPasswordAndSend(env.page);
await visible(env.page, "#view-error-tx", 60000);
env.routeOpts.tokenDecimalsOverride = null;
assert( // The count is asserted as soon as the send has ended either way, and
env.routeOpts.broadcastTransactions.length === before, // the screen only after it: a transfer that got through shows as the
"a transfer encoded against a contract that contradicts the " + // wait for its receipt, never as the failure screen expected here.
"confirmation screen still reached the RPC (#305)", 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 = ( const message = (
await env.page.locator("#error-tx-message").innerText() await env.page.locator("#error-tx-message").innerText()
).trim(); ).trim();
console.log( console.log(
"# erc-20 decimals refusal: displayed=" + "# erc-20 decimals refusal: displayed=" +
JSON.stringify(shown) + JSON.stringify(shown) +
" contract=" + " contract=" +
LYING_DECIMALS + LYING_DECIMALS +
" message=" + " message=" +
JSON.stringify(message), JSON.stringify(message),
); );
assert( assert(
message.includes("reports " + LYING_DECIMALS + " decimal places") && message.includes("reports " + LYING_DECIMALS + " decimal places") &&
message.includes("displayed using " + STUB_TOKEN.decimals), message.includes("displayed using " + STUB_TOKEN.decimals),
"the refusal does not name both scales it is refusing over: " + "the refusal does not name both scales it is refusing over: " +
JSON.stringify(message), JSON.stringify(message),
); );
assert( assert(
/^[A-Z].*\.$/s.test(message), /^[A-Z].*\.$/s.test(message),
"the refusal is not a full sentence: " + JSON.stringify(message), "the refusal is not a full sentence: " + JSON.stringify(message),
); );
} finally {
await env.page.click("#btn-error-tx-done"); env.routeOpts.tokenDecimalsOverride = null;
await visible(env.page, "#view-address"); await backToAddressAfterSend(env);
}
}); });
// ------------------------------------------- hostile token symbol (#307) // ------------------------------------------- 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), "# stub token symbol() now returns: " + JSON.stringify(HOSTILE_SYMBOL),
); );
// Close and reopen so the refresh that runs on open fetches balances try {
// with the hostile symbol in them. // Close and reopen so the refresh that runs on open fetches balances
await reopenPopup(env, "address"); // with the hostile symbol in them.
await env.page.waitForFunction( await reopenPopup(env, "address");
(addr) => await env.page.waitForFunction(
!!document.querySelector( (addr) =>
'#address-balances [data-token="' + addr + '"]', !!document.querySelector(
), '#address-balances [data-token="' + addr + '"]',
STUB_TOKEN.address, ),
{ timeout: 60000 }, STUB_TOKEN.address,
); { timeout: 60000 },
const onAddress = await env.page.evaluate(() => ({ );
iframes: document.querySelectorAll("iframe").length, const onAddress = await env.page.evaluate(() => ({
pwnPresent: !!document.getElementById("pwn"), iframes: document.querySelectorAll("iframe").length,
})); pwnPresent: !!document.getElementById("pwn"),
console.log("# address-detail iframes = " + onAddress.iframes); }));
assert( console.log("# address-detail iframes = " + onAddress.iframes);
onAddress.iframes === 0 && !onAddress.pwnPresent, assert(
"the address screen contains " + onAddress.iframes === 0 && !onAddress.pwnPresent,
onAddress.iframes + "the address screen contains " +
" iframe(s) after a hostile symbol rendered (#307)", onAddress.iframes +
); " iframe(s) after a hostile symbol rendered (#307)",
);
await env.page.click("#btn-address-back"); await env.page.click("#btn-address-back");
await visible(env.page, "#view-main"); await visible(env.page, "#view-main");
await visible( await visible(
env.page, env.page,
'#wallet-list [data-token="' + STUB_TOKEN.address + '"]', '#wallet-list [data-token="' + STUB_TOKEN.address + '"]',
60000, 60000,
); );
const st = await hostileSymbolState(env.page, STUB_TOKEN.address); const st = await hostileSymbolState(env.page, STUB_TOKEN.address);
console.log( console.log(
"# iframes in the popup DOM = " + "# iframes in the popup DOM = " +
st.iframes + st.iframes +
" | #pwn present = " + " | #pwn present = " +
st.pwnPresent + st.pwnPresent +
" | symbol = " + " | symbol = " +
JSON.stringify(st.symbolText), JSON.stringify(st.symbolText),
); );
assert(st.rowFound, "the hostile token never rendered a row at all"); assert(st.rowFound, "the hostile token never rendered a row at all");
assert( assert(
st.iframes === 0, st.iframes === 0,
"the popup DOM contains " + st.iframes + " iframe(s) (#307)", "the popup DOM contains " + st.iframes + " iframe(s) (#307)",
); );
assert(!st.pwnPresent, "the injected #pwn element is in the popup DOM"); assert(!st.pwnPresent, "the injected #pwn element is in the popup DOM");
assert( assert(
st.symbolElementChildren === 0, st.symbolElementChildren === 0,
"the symbol span grew " + "the symbol span grew " +
st.symbolElementChildren + st.symbolElementChildren +
" element children out of a token symbol (#307)", " element children out of a token symbol (#307)",
); );
assert( assert(
st.symbolText === HOSTILE_SYMBOL_DISPLAYED, st.symbolText === HOSTILE_SYMBOL_DISPLAYED,
"the symbol did not render as the literal capped text " + "the symbol did not render as the literal capped text " +
JSON.stringify(HOSTILE_SYMBOL_DISPLAYED) + JSON.stringify(HOSTILE_SYMBOL_DISPLAYED) +
": " + ": " +
JSON.stringify(st.symbolText), JSON.stringify(st.symbolText),
); );
assert( assert(
!st.rowText.includes("z-index"), !st.rowText.includes("z-index"),
"the uncapped symbol reached the screen: " + JSON.stringify(st.rowText), "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 // Let the stored balances be rewritten with the honest symbol.
// stored balances be rewritten with the honest symbol.
env.routeOpts.tokenSymbolOverride = null;
await reopenPopup(env, "main"); await reopenPopup(env, "main");
await env.page.waitForFunction( await env.page.waitForFunction(
(addr) => { (addr) => {