fix: say a contract creation has no recipient instead of a blank line #422

Merged
clawbot merged 1 commits from issue-250-contract-deployment-label into next 2026-10-04 12:24:41 +02:00
10 changed files with 385 additions and 26 deletions
Showing only changes of commit 98e19d8e0f - Show all commits
+14 -7
View File
@@ -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
+8
View File
@@ -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
+2 -5
View File
@@ -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 += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`;
i++;
}
+2 -5
View File
@@ -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 += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`;
i++;
}
+2 -1
View File
@@ -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(
+25
View File
@@ -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 (
`<div class="flex justify-between"><span></span><span>${amountHtml}</span></div>` +
`<div>${escapeHtml(CONTRACT_CREATION_TEXT)}</div>`
);
}
return (
`<div class="flex justify-between"><span class="flex items-center">${addressDotHtml(address)}${nameHtml}</span><span>${amountHtml}</span></div>` +
`<div class="am-address">${escapeHtml(address)}</div>`
);
}
// 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,
+2 -4
View File
@@ -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 += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${titleStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `</div>`;
i++;
}
+8 -2
View File
@@ -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);
+7 -2
View File
@@ -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)) {
+315
View File
@@ -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(`<div class="am-address">${RECIPIENT}</div>`);
});
});