From bec20aa2bbdf56823e4ac847c7ae0c07df5550da Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 12:24:39 +0200 Subject: [PATCH] fix: say a contract creation has no recipient instead of a blank line (closes #250) A transaction with no `to` showed a blank address, with a colour dot whose colour was `undefined`, on the wait, success and error screens, the transaction detail view and the history rows on Home, AddressDetail and AddressToken. The approval screen showed "(contract creation)". All of them now say "This transaction creates a new contract. It has no recipient." The three history lists draw a row's counterparty lines through one helper in helpers.js. A transaction with a real `to` is unchanged. The new test drives each screen and list both ways. Model: opus-5-5 --- README.md | 21 +- TODO.md | 8 + src/popup/views/addressDetail.js | 7 +- src/popup/views/addressToken.js | 7 +- src/popup/views/approval.js | 3 +- src/popup/views/helpers.js | 25 +++ src/popup/views/home.js | 6 +- src/popup/views/transactionDetail.js | 10 +- src/popup/views/txStatus.js | 9 +- tests/contractCreation.test.js | 315 +++++++++++++++++++++++++++ 10 files changed, 385 insertions(+), 26 deletions(-) create mode 100644 tests/contractCreation.test.js diff --git a/README.md b/README.md index 1df5679..cd152d0 100644 --- a/README.md +++ b/README.md @@ -1258,7 +1258,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 @@ -1451,7 +1454,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 @@ -1480,7 +1485,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 @@ -1495,7 +1501,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 @@ -1533,7 +1540,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) @@ -1844,8 +1851,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 00b7845..eddcc3a 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 Send and confirmation screens no longer show an ETH balance, a token balance or a network fee below 0.000001 as zero ([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances 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 b3738c5..ea706a4 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, @@ -318,7 +319,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 3e96760..8a01449 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -431,6 +431,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. @@ -643,6 +666,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}
`); + }); +});