fix: say a contract creation has no recipient instead of a blank line (closes #250)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A transaction with no `to` showed a blank recipient line on the wait,
success and error screens and the transaction detail view: an empty
address, with a colour dot whose colour was `undefined`. The approval
screen showed "(contract creation)".

All five now say "This transaction creates a new contract. It has no
recipient." A transaction with a real `to` is unchanged. The new test
drives each screen both ways.

Model: opus-5-5
This commit is contained in:
2026-10-04 07:38:00 +00:00
parent 4b62e31e80
commit 5aa4010f6c
7 changed files with 293 additions and 11 deletions
+10 -6
View File
@@ -1437,7 +1437,9 @@ view would leave a wallet one click from deletion.
- **Elements**: - **Elements**:
- "Transaction Broadcast" heading (no back button — tx is irreversible) - "Transaction Broadcast" heading (no back button — tx is irreversible)
- Amount + symbol - 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 - Transaction hash: full hash (tap to copy) + etherscan link
- Count-up timer: "Waiting for confirmation... Ns" - Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is - **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 - Decoded action well (shown when the transaction carried recognized
calldata; the top-level Amount and To are hidden in that case) calldata; the top-level Amount and To are hidden in that case)
- Amount + symbol - 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 - Block number
- Transaction hash: full hash (tap to copy) + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button - "Done" button
@@ -1481,7 +1484,8 @@ view would leave a wallet one click from deletion.
- **Elements**: - **Elements**:
- "Transaction Failed" heading - "Transaction Failed" heading
- Amount + symbol - 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) - Error message (dashed border box)
- Transaction hash section (hidden if broadcast failed before getting hash): - Transaction hash section (hidden if broadcast failed before getting hash):
full hash (tap to copy) + etherscan link 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; - From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available ENS name if available
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS - 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 - Time: ISO datetime + relative age in parentheses
- Block: block number (tap to copy) + etherscan block link - Block: block number (tap to copy) + etherscan block link
- Amount: value + symbol (bold) - 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, - Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding) amounts, steps, deadline (see Transaction Decoding)
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link (or "contract - Contract: color dot + full address + etherscan link, token symbol label if
creation"), token symbol label if known known; for a contract creation, the same sentence as on WaitTx
- Value: amount in ETH (4 decimal places, USD in parentheses) - Value: amount in ETH (4 decimal places, USD in parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD - 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 in parentheses), with the gas limit and the fee per gas in gwei below it
+7
View File
@@ -45,6 +45,13 @@ but the review is broader than any of them.
# Completed Steps # 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 - 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 code `4200` ([#279](https://git.eeqj.de/sneak/AutistMask/issues/279)). The
background's `Unsupported method: <method>` error carried no code, so a site background's `Unsupported method: <method>` error carried no code, so a site
+2 -1
View File
@@ -1,6 +1,7 @@
const { const {
$, $,
addressTitle, addressTitle,
CONTRACT_CREATION_TEXT,
escapeHtml, escapeHtml,
showView, showView,
showError, showError,
@@ -320,7 +321,7 @@ function showTxApproval(details) {
toHtml += approvalAddressHtml(toAddr); toHtml += approvalAddressHtml(toAddr);
$("approve-tx-to").innerHTML = toHtml; $("approve-tx-to").innerHTML = toHtml;
} else { } else {
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)"); $("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
} }
const ethValueFormatted = formatTxValue( const ethValueFormatted = formatTxValue(
+7
View File
@@ -404,6 +404,12 @@ function addressTitle(address, wallets) {
return null; 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, // Render an address with color dot, optional ENS name, optional title,
// and optional truncation. Title and ENS are shown as bold labels above // and optional truncation. Title and ENS are shown as bold labels above
// the full address. // the full address.
@@ -615,6 +621,7 @@ module.exports = {
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
addressTitle, addressTitle,
CONTRACT_CREATION_TEXT,
formatAddressHtml, formatAddressHtml,
renderAddressHtml, renderAddressHtml,
copyableHtml, copyableHtml,
+8 -2
View File
@@ -7,6 +7,7 @@ const {
showFlash, showFlash,
flashCopyFeedback, flashCopyFeedback,
addressTitle, addressTitle,
CONTRACT_CREATION_TEXT,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
isoDate, isoDate,
@@ -94,13 +95,18 @@ function render() {
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash); $("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
const fromTitle = addressTitle(tx.from, state.wallets); const fromTitle = addressTitle(tx.from, state.wallets);
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml( $("tx-detail-from").innerHTML = txAddressHtml(
tx.from, tx.from,
tx.fromEns, tx.fromEns,
fromTitle, 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) // Exact amount (full precision, copyable)
const detailSym = displaySymbol(tx.symbol); const detailSym = displaySymbol(tx.symbol);
+7 -2
View File
@@ -4,6 +4,7 @@ const {
$, $,
showView, showView,
addressTitle, addressTitle,
CONTRACT_CREATION_TEXT,
escapeHtml, escapeHtml,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -58,7 +59,10 @@ function endWait() {
} }
} }
// A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`).
function toAddressHtml(address) { function toAddressHtml(address) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets); const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title }); return renderAddressHtml(address, { title });
} }
@@ -202,8 +206,9 @@ function restoreWait() {
if (!info || typeof info !== "object" || Array.isArray(info)) return false; if (!info || typeof info !== "object" || Array.isArray(info)) return false;
// A string is the whole requirement: the empty string is what a // A string is the whole requirement: the empty string is what a
// contract-deployment approval persists (approval.js writes `to: toAddr // contract-deployment approval persists (approval.js writes `to: toAddr
// || ""`), and both fields render harmlessly when empty, so refusing it // || ""`), an empty `to` renders as a contract creation and an empty
// would abandon a wait the live path itself created. // 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.to !== "string") return false;
if (typeof info.amount !== "string") return false; if (typeof info.amount !== "string") return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) { if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
+252
View File
@@ -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);
});
});