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}
`);
+ });
+});