diff --git a/README.md b/README.md index bb0d196..19a068c 100644 --- a/README.md +++ b/README.md @@ -1437,7 +1437,9 @@ view would leave a wallet one click from deletion. - **Elements**: - "Transaction Broadcast" heading (no back button — tx is irreversible) - Amount + symbol - - To: color dot + full address + etherscan link + - To: color dot + full address + etherscan link; for a contract creation, + which has no recipient, "This transaction creates a new contract. It has + no recipient." instead - Transaction hash: full hash (tap to copy) + etherscan link - Count-up timer: "Waiting for confirmation... Ns" - **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is @@ -1466,7 +1468,8 @@ view would leave a wallet one click from deletion. - Decoded action well (shown when the transaction carried recognized calldata; the top-level Amount and To are hidden in that case) - Amount + symbol - - To: color dot + full address + etherscan link + - To: color dot + full address + etherscan link, or for a contract creation + the same sentence as on WaitTx - Block number - Transaction hash: full hash (tap to copy) + etherscan link - "Done" button @@ -1481,7 +1484,8 @@ view would leave a wallet one click from deletion. - **Elements**: - "Transaction Failed" heading - Amount + symbol - - To: color dot + full address + etherscan link + - To: color dot + full address + etherscan link, or for a contract creation + the same sentence as on WaitTx - Error message (dashed border box) - Transaction hash section (hidden if broadcast failed before getting hash): full hash (tap to copy) + etherscan link @@ -1519,7 +1523,7 @@ view would leave a wallet one click from deletion. - From: blockie + color dot + full address (tap to copy) + etherscan link; ENS name if available - To: blockie + color dot + full address (tap to copy) + etherscan link; ENS - name if available + name if available. For a contract creation, the same sentence as on WaitTx - Time: ISO datetime + relative age in parentheses - Block: block number (tap to copy) + etherscan block link - Amount: value + symbol (bold) @@ -1830,8 +1834,8 @@ view would leave a wallet one click from deletion. - Decoded action (if calldata is recognized): action name, token details, amounts, steps, deadline (see Transaction Decoding) - From: color dot + full address + etherscan link - - Contract: color dot + full address + etherscan link (or "contract - creation"), token symbol label if known + - Contract: color dot + full address + etherscan link, token symbol label if + known; for a contract creation, the same sentence as on WaitTx - Value: amount in ETH (4 decimal places, USD in parentheses) - Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD in parentheses), with the gas limit and the fee per gas in gwei below it diff --git a/TODO.md b/TODO.md index 7004d95..a6ca01b 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,13 @@ but the review is broader than any of them. # Completed Steps +- 2026-10-04: A transaction with no `to` says "This transaction creates a new + contract. It has no recipient." on its recipient line + ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The wait, success + and error screens and the transaction detail view showed a blank line there, + with a colour dot whose colour was `undefined`; the approval screen showed + "(contract creation)". A transaction with a real `to` is unchanged. + - 2026-10-04: A method the wallet does not implement is refused with EIP-1193 code `4200` ([#279](https://git.eeqj.de/sneak/AutistMask/issues/279)). The background's `Unsupported method: ` error carried no code, so a site diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 2428dc5..92097ed 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -1,6 +1,7 @@ const { $, addressTitle, + CONTRACT_CREATION_TEXT, escapeHtml, showView, showError, @@ -320,7 +321,7 @@ function showTxApproval(details) { toHtml += approvalAddressHtml(toAddr); $("approve-tx-to").innerHTML = toHtml; } else { - $("approve-tx-to").innerHTML = escapeHtml("(contract creation)"); + $("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT); } const ethValueFormatted = formatTxValue( diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index f1e5f9f..7e8f3c5 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -404,6 +404,12 @@ function addressTitle(address, wallets) { return null; } +// What every recipient line says for a transaction with no `to`. Such a +// transaction creates a contract, so there is no address to show, and a blank +// line on these screens reads as a rendering fault. +const CONTRACT_CREATION_TEXT = + "This transaction creates a new contract. It has no recipient."; + // Render an address with color dot, optional ENS name, optional title, // and optional truncation. Title and ENS are shown as bold labels above // the full address. @@ -615,6 +621,7 @@ module.exports = { escapeHtml, displaySymbol, addressTitle, + CONTRACT_CREATION_TEXT, formatAddressHtml, renderAddressHtml, copyableHtml, diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index f3df42a..f13eb10 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -7,6 +7,7 @@ const { showFlash, flashCopyFeedback, addressTitle, + CONTRACT_CREATION_TEXT, addressDotHtml, escapeHtml, isoDate, @@ -94,13 +95,18 @@ function render() { $("tx-detail-hash").innerHTML = txHashHtml(tx.hash); const fromTitle = addressTitle(tx.from, state.wallets); - const toTitle = addressTitle(tx.to, state.wallets); $("tx-detail-from").innerHTML = txAddressHtml( tx.from, tx.fromEns, fromTitle, ); - $("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle); + // A contract creation has no recipient: transactions.js gives it `to: ""`. + if (tx.to) { + const toTitle = addressTitle(tx.to, state.wallets); + $("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle); + } else { + $("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT); + } // Exact amount (full precision, copyable) const detailSym = displaySymbol(tx.symbol); diff --git a/src/popup/views/txStatus.js b/src/popup/views/txStatus.js index 7284f37..2c2a101 100644 --- a/src/popup/views/txStatus.js +++ b/src/popup/views/txStatus.js @@ -4,6 +4,7 @@ const { $, showView, addressTitle, + CONTRACT_CREATION_TEXT, escapeHtml, renderAddressHtml, attachCopyHandlers, @@ -58,7 +59,10 @@ function endWait() { } } +// A contract creation reaches these screens with `to` as "" (approval.js +// writes `to: toAddr || ""`). function toAddressHtml(address) { + if (!address) return escapeHtml(CONTRACT_CREATION_TEXT); const title = addressTitle(address, state.wallets); return renderAddressHtml(address, { title }); } @@ -202,8 +206,9 @@ function restoreWait() { if (!info || typeof info !== "object" || Array.isArray(info)) return false; // A string is the whole requirement: the empty string is what a // contract-deployment approval persists (approval.js writes `to: toAddr - // || ""`), and both fields render harmlessly when empty, so refusing it - // would abandon a wait the live path itself created. + // || ""`), an empty `to` renders as a contract creation and an empty + // amount renders harmlessly, so refusing it would abandon a wait the live + // path itself created. if (typeof info.to !== "string") return false; if (typeof info.amount !== "string") return false; if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) { diff --git a/tests/contractCreation.test.js b/tests/contractCreation.test.js new file mode 100644 index 0000000..022ebc0 --- /dev/null +++ b/tests/contractCreation.test.js @@ -0,0 +1,252 @@ +// The recipient line of a contract creation +// (https://git.eeqj.de/sneak/AutistMask/issues/250). +// +// A transaction with no `to` creates a contract. The approval screen, the +// wait, success and error screens, and the transaction detail view each say +// so in a sentence on the recipient line, where they used to show a blank +// line (an empty address, with a colour dot whose colour was `undefined`) or, +// on the approval screen, "(contract creation)". A transaction with a real +// `to` still shows that address. +// +// Driven against a minimal DOM stub in the shape +// tests/typedDataPermit.test.js uses. + +jest.mock("../src/shared/log", () => ({ + log: { + debugf: () => {}, + infof: () => {}, + warnf: () => {}, + errorf: () => {}, + }, + // The transaction detail view fetches on-chain details after drawing; an + // answer that is not ok leaves the drawn lines as they are. + debugFetch: async () => ({ ok: false }), + setRuntimeDebug: () => {}, + isDebug: () => false, +})); + +// The wait screen polls for a receipt; this one never arrives. +jest.mock("../src/shared/balances", () => ({ + getProvider: () => ({ getTransactionReceipt: () => new Promise(() => {}) }), + refreshBalances: () => {}, +})); + +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const { state } = require("../src/shared/state"); +const approval = require("../src/popup/views/approval"); +const txStatus = require("../src/popup/views/txStatus"); +const transactionDetail = require("../src/popup/views/transactionDetail"); + +const SENTENCE = + "This transaction creates a new contract. It has no recipient."; + +const FROM = "0x0000000000000000000000000000000000000a11"; +const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; +const TX_HASH = + "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84"; +// Init code for a contract creation's data. +const INIT_CODE = "0x600160005500"; + +function makeElement(id) { + const classes = new Set(); + const el = { + id, + textContent: "", + value: "", + innerHTML: "", + disabled: false, + style: {}, + dataset: {}, + classList: { + add: (...names) => names.forEach((n) => classes.add(n)), + remove: (...names) => names.forEach((n) => classes.delete(n)), + contains: (n) => classes.has(n), + toggle: (n, force) => { + const on = force === undefined ? !classes.has(n) : force; + if (on) classes.add(n); + else classes.delete(n); + return on; + }, + }, + addEventListener: () => {}, + querySelectorAll: () => [], + }; + // Views reach for .parentElement to hide whole sections. + Object.defineProperty(el, "parentElement", { + get: () => node(id + "-parent"), + }); + return el; +} + +function makeDocument() { + const els = new Map(); + return { + getElementById(id) { + // The debug banner is created on demand by helpers.js; absent + // is the state a non-debug, non-testnet popup is in. + if (id === "debug-banner") return null; + if (!els.has(id)) els.set(id, makeElement(id)); + return els.get(id); + }, + createElement: () => makeElement("created"), + body: { prepend: () => {} }, + }; +} + +function node(id) { + return globalThis.document.getElementById(id); +} + +// The line a transaction with a real `to` shows: that address, and nothing +// left over from an empty one. +function expectAddressLine(html) { + expect(html).toContain(RECIPIENT); + expect(html).not.toContain(SENTENCE); + expect(html).not.toContain("undefined"); +} + +beforeEach(() => { + globalThis.document = makeDocument(); + globalThis.window = { location: { search: "" } }; + state.wallets = []; + state.trackedTokens = []; + state.viewData = {}; + state.viewStack = []; + state.currentView = null; + txStatus.init({ doRefreshAndRender: () => {} }); +}); + +afterEach(() => { + txStatus.endWait(); +}); + +// Open the transaction approval screen the way the popup does: the background +// hands over the populated transaction and show() draws it. +async function openTxApproval(to, data) { + globalThis.chrome.runtime = { + connect: () => ({ postMessage: () => {} }), + sendMessage: (msg, reply) => { + if (!reply) return; + if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null); + reply({ + type: "tx", + hostname: "dapp.example", + isPhishingDomain: false, + approvedFrom: FROM, + approvedTx: { + type: 2, + from: FROM, + chainId: "0x1", + nonce: "0x7", + gasLimit: "0x5208", + maxPriorityFeePerGas: "0x3b9aca00", + maxFeePerGas: "0x77359400", + to, + value: "0x0", + data, + accessList: [], + }, + }); + }, + }; + approval.init({}); + await approval.show(1); +} + +describe("the transaction approval screen", () => { + test("a contract creation says so instead of naming a contract", async () => { + await openTxApproval(null, INIT_CODE); + expect(node("approve-tx-to").innerHTML).toBe(SENTENCE); + }); + + test("a transaction with a recipient shows its address", async () => { + await openTxApproval(RECIPIENT, "0x"); + expectAddressLine(node("approve-tx-to").innerHTML); + }); +}); + +// approval.js carries a contract creation to these screens with `to` as "". +describe("the wait, success and error screens", () => { + const creation = { + to: "", + amount: "0.0000", + token: "ETH", + tokenSymbol: null, + }; + const transfer = { ...creation, to: RECIPIENT }; + + test("a contract creation says so on the wait screen", () => { + txStatus.showWait(creation, TX_HASH); + expect(node("wait-tx-to").innerHTML).toBe(SENTENCE); + }); + + test("a transaction with a recipient shows its address on the wait screen", () => { + txStatus.showWait(transfer, TX_HASH); + expectAddressLine(node("wait-tx-to").innerHTML); + }); + + test("a contract creation says so on the success and error screens", () => { + state.viewData = { + amount: "0.0000", + symbol: "ETH", + to: "", + hash: TX_HASH, + blockNumber: 1, + }; + txStatus.renderSuccess(); + expect(node("success-tx-to").innerHTML).toBe(SENTENCE); + + txStatus.showError(creation, TX_HASH, "The transaction failed."); + expect(node("error-tx-to").innerHTML).toBe(SENTENCE); + }); + + test("a transaction with a recipient shows its address on the success and error screens", () => { + state.viewData = { + amount: "0.0050", + symbol: "ETH", + to: RECIPIENT, + hash: TX_HASH, + blockNumber: 1, + }; + txStatus.renderSuccess(); + expectAddressLine(node("success-tx-to").innerHTML); + + txStatus.showError(transfer, TX_HASH, "The transaction failed."); + expectAddressLine(node("error-tx-to").innerHTML); + }); +}); + +// The history list gives a contract creation `to: ""` (src/shared/ +// transactions.js), and that is what the detail view is opened with. +describe("the transaction detail view", () => { + function historyTx(to) { + return { + hash: TX_HASH, + from: FROM, + to, + value: "0.0000", + exactValue: "0.0", + rawAmount: "0", + rawUnit: "wei", + symbol: "ETH", + timestamp: 1790000000, + isError: false, + directionLabel: "Sent", + direction: "sent", + }; + } + + test("a contract creation says so", () => { + transactionDetail.show(historyTx("")); + expect(node("tx-detail-to").innerHTML).toBe(SENTENCE); + expect(node("tx-detail-type").textContent).toBe("Contract Creation"); + }); + + test("a transaction with a recipient shows its address", () => { + transactionDetail.show(historyTx(RECIPIENT)); + expectAddressLine(node("tx-detail-to").innerHTML); + }); +});