diff --git a/README.md b/README.md index bb0d196..f96b22d 100644 --- a/README.md +++ b/README.md @@ -1244,7 +1244,10 @@ view would leave a wallet one click from deletion. of every wallet, deduplicated by hash and filtered. Each row is three lines: age and direction, then the counterparty's colour dot (with our own name for it, where it is one of our addresses) and the amount, then the - counterparty's full address on a row of its own + counterparty's full address on a row of its own. A contract creation has + no counterparty: its second line is the amount alone and its third line + says "This transaction creates a new contract. It has no recipient." The + transaction lists on AddressDetail and AddressToken draw the same rows - "Add additional wallet..." link at bottom - **Transitions**: - Tap address row → sets the active address and broadcasts @@ -1437,7 +1440,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 +1471,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 +1487,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 +1526,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 +1837,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 843c842..09aafed 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,14 @@ 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 and in its transaction + history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The + wait, success and error screens, the transaction detail view and the history + rows on Home, AddressDetail and AddressToken showed a blank address 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: The flash line keeps to the one line it reserves at any message length ([#252](https://git.eeqj.de/sneak/AutistMask/issues/252)). A message that wrapped pushed the whole screen below it down. `#flash-msg` no longer diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js index 4ae35ea..84f24db 100644 --- a/src/popup/views/addressDetail.js +++ b/src/popup/views/addressDetail.js @@ -3,7 +3,7 @@ const { showView, showFlash, balanceLinesForAddress, - addressDotHtml, + txCounterpartyHtml, addressTitle, escapeHtml, displaySymbol, @@ -233,16 +233,13 @@ function renderTransactions(txs) { // is shown whole; the title or ENS name, where there is one, names // it on the line above rather than replacing it. const nameStr = escapeHtml(title || ensName || ""); - const addrStr = escapeHtml(counterparty); - const dot = addressDotHtml(counterparty); const err = tx.isError ? " (failed)" : ""; const opacity = tx.isError ? " opacity:0.5;" : ""; const ago = escapeHtml(timeAgo(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp)); html += `
`; html += `
${ago}${dirLabel}${err}
`; - html += `
${dot}${nameStr}${amountStr}
`; - html += `
${addrStr}
`; + html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += `
`; i++; } diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js index 35792ae..fefdb14 100644 --- a/src/popup/views/addressToken.js +++ b/src/popup/views/addressToken.js @@ -6,7 +6,7 @@ const { showView, showFlash, flashCopyFeedback, - addressDotHtml, + txCounterpartyHtml, addressTitle, escapeHtml, displaySymbol, @@ -309,16 +309,13 @@ function renderTransactions(txs) { // is shown whole; the title or ENS name, where there is one, names // it on the line above rather than replacing it. const nameStr = escapeHtml(title || ensName || ""); - const addrStr = escapeHtml(counterparty); - const dot = addressDotHtml(counterparty); const err = tx.isError ? " (failed)" : ""; const opacity = tx.isError ? " opacity:0.5;" : ""; const ago = escapeHtml(timeAgo(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp)); html += `
`; html += `
${ago}${dirLabel}${err}
`; - html += `
${dot}${nameStr}${amountStr}
`; - html += `
${addrStr}
`; + html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += `
`; i++; } 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 374513f..a099650 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -408,6 +408,29 @@ function addressTitle(address, wallets) { return null; } +// What every recipient line and history row 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."; + +// The last two lines of a transaction history row: the counterparty's colour +// dot and name beside the amount, then its full address. A contract creation +// the user sent has no counterparty (its `to` is ""), so its row has the +// amount alone and the contract creation sentence in place of the address. +function txCounterpartyHtml(address, nameHtml, amountHtml) { + if (!address) { + return ( + `
${amountHtml}
` + + `
${escapeHtml(CONTRACT_CREATION_TEXT)}
` + ); + } + return ( + `
${addressDotHtml(address)}${nameHtml}${amountHtml}
` + + `
${escapeHtml(address)}
` + ); +} + // 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. @@ -619,6 +642,8 @@ module.exports = { escapeHtml, displaySymbol, addressTitle, + CONTRACT_CREATION_TEXT, + txCounterpartyHtml, formatAddressHtml, renderAddressHtml, copyableHtml, diff --git a/src/popup/views/home.js b/src/popup/views/home.js index 9c053f6..aa07101 100644 --- a/src/popup/views/home.js +++ b/src/popup/views/home.js @@ -6,6 +6,7 @@ const { isoDate, timeAgo, addressDotHtml, + txCounterpartyHtml, addressTitle, escapeHtml, displaySymbol, @@ -122,16 +123,13 @@ function renderHomeTxList(ctx) { // names it on the line above rather than replacing it. const title = addressTitle(counterparty, state.wallets); const titleStr = title ? escapeHtml(title) : ""; - const addrStr = escapeHtml(counterparty); - const dot = addressDotHtml(counterparty); const err = tx.isError ? " (failed)" : ""; const opacity = tx.isError ? " opacity:0.5;" : ""; const ago = escapeHtml(timeAgo(tx.timestamp)); const iso = escapeHtml(isoDate(tx.timestamp)); html += `
`; html += `
${ago}${dirLabel}${err}
`; - html += `
${dot}${titleStr}${amountStr}
`; - html += `
${addrStr}
`; + html += txCounterpartyHtml(counterparty, titleStr, amountStr); html += `
`; i++; } 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..21c8bf4 --- /dev/null +++ b/tests/contractCreation.test.js @@ -0,0 +1,315 @@ +// 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, the transaction detail view and the +// transaction history rows each say so in a sentence, 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: () => {}, +})); + +// The history lists ask the explorer for their transactions and resolve ENS +// names for them; here the explorer answers with mockHistory and no name +// resolves. +let mockHistory = []; +jest.mock("../src/shared/transactions", () => ({ + ...jest.requireActual("../src/shared/transactions"), + fetchRecentTransactions: async () => mockHistory, +})); +jest.mock("../src/shared/ens", () => ({ + ...jest.requireActual("../src/shared/ens"), + resolveEnsNames: async () => new Map(), +})); + +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 home = require("../src/popup/views/home"); +const addressDetail = require("../src/popup/views/addressDetail"); +const addressToken = require("../src/popup/views/addressToken"); + +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: () => [], + appendChild: () => {}, + }; + // 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); + }); +}); + +// A transaction FROM sent, as the history lists hold it. The explorer reports a +// contract creation with no `to`, which src/shared/transactions.js turns into +// `to: ""`. +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", + contractAddress: null, + }; +} + +// The detail view is opened with the transaction a history row holds. +describe("the transaction detail view", () => { + 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); + }); +}); + +// The same rows are drawn on Home, AddressDetail and AddressToken (for ETH). +describe.each([ + ["Home", "home-tx-list", () => home.render({})], + ["AddressDetail", "tx-list", () => addressDetail.show()], + ["AddressToken", "address-token-tx-list", () => addressToken.show()], +])("the transaction history on %s", (_name, listId, open) => { + async function rowsFor(tx) { + mockHistory = [tx]; + open(); + // The list is drawn once the history has been fetched. + await new Promise((resolve) => setTimeout(resolve, 0)); + return node(listId).innerHTML; + } + + beforeEach(() => { + state.wallets = [ + { + name: "Main", + type: "key", + addresses: [{ address: FROM, balance: "0.0000" }], + }, + ]; + state.selectedWallet = 0; + state.selectedAddress = 0; + state.selectedToken = "ETH"; + }); + + test("a contract creation's row says so, with no colour dot and no address line", async () => { + const html = await rowsFor(historyTx("")); + expect(html).toContain(SENTENCE); + expect(html).not.toContain("background:"); + expect(html).not.toContain("am-address"); + expect(html).not.toContain("undefined"); + }); + + test("a transaction with a recipient shows its colour dot and address", async () => { + const html = await rowsFor(historyTx(RECIPIENT)); + expectAddressLine(html); + expect(html).toContain("background:#"); + expect(html).toContain(`
${RECIPIENT}
`); + }); +});