fix: give every address a row of its own, so none wraps or is shortened (closes #380) (#381)
check / check (push) Successful in 56s
e2e / e2e-chrome (push) Successful in 1m51s
e2e / e2e-firefox (push) Successful in 40s

This commit was merged in pull request #381.
This commit is contained in:
2026-08-30 05:25:00 +02:00
parent a098bb0c32
commit 1197d2171b
10 changed files with 356 additions and 75 deletions
+20 -29
View File
@@ -213,10 +213,7 @@
</div>
<!-- active address display -->
<div
id="active-address-display"
class="text-xs break-all mb-3"
></div>
<div id="active-address-display" class="text-xs mb-3"></div>
<!-- quick actions for active address -->
<div class="flex gap-2 mb-2">
@@ -292,7 +289,7 @@
class="font-bold mb-1 hidden flex items-center"
></div>
<div
class="text-xs mb-1 cursor-pointer break-all"
class="text-xs mb-1 cursor-pointer"
title="Click to copy"
id="address-line"
>
@@ -380,14 +377,14 @@
></div>
<h2 class="font-bold mb-1">Export Private Key</h2>
<p class="text-xs mb-1" id="export-privkey-title"></p>
<p class="text-xs mb-3">
<div class="text-xs mb-3">
<span id="export-privkey-dot"></span>
<span
id="export-privkey-address"
class="cursor-pointer"
title="Click to copy"
></span>
</p>
</div>
<p class="text-xs mb-3 text-muted">
Warning: anyone with this private key can access and
transfer all funds from this address. Never share it.
@@ -440,7 +437,7 @@
</div>
<div
class="text-xs mb-1 cursor-pointer break-all"
class="text-xs mb-1 cursor-pointer"
title="Click to copy"
id="address-token-line"
>
@@ -573,19 +570,16 @@
<!-- ERC-20 token contract (hidden for ETH) -->
<div id="confirm-token-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Token contract</div>
<div
id="confirm-token-contract"
class="text-xs break-all"
></div>
<div id="confirm-token-contract" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div id="confirm-from" class="text-xs break-all"></div>
<div id="confirm-from" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="confirm-to" class="text-xs break-all"></div>
<div id="confirm-to" class="text-xs"></div>
<div
id="confirm-to-ens"
class="text-xs text-muted hidden"
@@ -728,7 +722,7 @@
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="wait-tx-to" class="text-xs break-all"></div>
<div id="wait-tx-to" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Transaction hash</div>
@@ -747,7 +741,7 @@
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="success-tx-to" class="text-xs break-all"></div>
<div id="success-tx-to" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Block</div>
@@ -774,7 +768,7 @@
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="error-tx-to" class="text-xs break-all"></div>
<div id="error-tx-to" class="text-xs"></div>
</div>
<div class="mb-3">
<div
@@ -811,9 +805,9 @@
<canvas id="receive-qr"></canvas>
</div>
<div
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
class="border border-border p-2 mb-3 text-xs cursor-pointer"
>
<span id="receive-address-block" class="select-all"></span>
<div id="receive-address-block" class="select-all"></div>
<span id="receive-etherscan-link"></span>
</div>
<button
@@ -1239,7 +1233,7 @@
</p>
<div
id="delete-address-value"
class="text-xs mb-2 break-all min-h-[1rem]"
class="text-xs mb-2 min-h-[1rem]"
></div>
<div
class="text-xs mb-2 border border-border border-dashed p-2"
@@ -1429,14 +1423,11 @@
</div>
<div class="mb-2">
<div class="text-xs text-muted mb-1">From</div>
<div
id="tx-detail-from"
class="text-xs break-all"
></div>
<div id="tx-detail-from" class="text-xs"></div>
</div>
<div class="mb-2">
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
<div id="tx-detail-to" class="text-xs"></div>
</div>
</div>
@@ -1473,7 +1464,7 @@
</div>
<div
id="tx-detail-token-contract"
class="text-xs break-all"
class="text-xs"
></div>
</div>
</div>
@@ -1567,11 +1558,11 @@
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div id="approve-tx-from" class="text-xs break-all"></div>
<div id="approve-tx-from" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Contract</div>
<div id="approve-tx-to" class="text-xs break-all"></div>
<div id="approve-tx-to" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Value</div>
@@ -1673,7 +1664,7 @@
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div id="approve-sign-from" class="text-xs break-all"></div>
<div id="approve-sign-from" class="text-xs"></div>
</div>
<div class="mb-3">
+20
View File
@@ -44,3 +44,23 @@ body {
background-color 225ms ease-out,
color 225ms ease-out;
}
/* An address is one atomic string, so it gets a row of its own and never
* breaks across lines. A wrapped address reads as two shorter strings, and
* two shorter strings are exactly what an address-poisoning attack needs
* the user to compare instead of the whole thing. Every view that shows an
* address puts it in one of these, alone: the colour dot, the wallet title,
* the ENS name and the explorer link all live on their own line above, so
* nothing competes with the 42 characters for width.
*
* overflow-x is the escape hatch, not the mechanism. The row is wide enough
* for a full address at every nesting depth the popup uses; if that ever
* stops being true — a font with wider glyphs, a browser zoom — the row
* scrolls and the user can still reach the last character, rather than the
* tail being clipped away by #app's overflow-x-hidden with nothing to say
* it happened. tests/e2e asserts the scroll is never actually needed. */
.am-address {
display: block;
white-space: nowrap;
overflow-x: auto;
}
+8 -6
View File
@@ -7,7 +7,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -229,10 +228,12 @@ function renderTransactions(txs) {
const amountStr = tx.value
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
// The counterparty used to be squeezed in beside the amount and
// truncated to whatever was left over. It gets its own row now and
// 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;" : "";
@@ -240,7 +241,8 @@ function renderTransactions(txs) {
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}${addrStr}</span><span>${amountStr}</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 += `</div>`;
i++;
}
+8 -6
View File
@@ -10,7 +10,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
balanceLine,
unknownableAmount,
renderAddressHtml,
@@ -305,10 +304,12 @@ function renderTransactions(txs) {
const amountStr = tx.value
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
// The counterparty used to be squeezed in beside the amount and
// truncated to whatever was left over. It gets its own row now and
// 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;" : "";
@@ -316,7 +317,8 @@ function renderTransactions(txs) {
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}${addrStr}</span><span>${amountStr}</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 += `</div>`;
i++;
}
+33 -15
View File
@@ -331,6 +331,12 @@ function addressHoldsFunds(addr) {
return false;
}
// The fewest characters of an address any caller may ask to display. The
// 10-character cap inside truncateMiddle() is the other half of the same
// guarantee; this is the half that used to be spelled out at each call
// site, and is now enforced once in renderAddressHtml().
const ADDRESS_MIN_DISPLAY_LEN = 32;
// Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in
@@ -518,17 +524,29 @@ function attachCopyHandlers(container) {
// Unified address rendering.
//
// Produces consistent HTML for any Ethereum address:
// • Color dot
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
// • Optional ENS name shown bold above address
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
// • Etherscan external link icon
// Two stacked rows, in this order:
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 —
// Address 2") and the explorer link icon. Optional ENS name below it.
// 2. The address itself, alone on a full-width row that never wraps
// (see .am-address in styles/main.css).
//
// The split is the point. Everything used to sit on one line: dot, address
// and link together, with `break-all` to let the address fold when the line
// ran out. In the wallet list, where the row also carried [info] and [x],
// it ran out every time — the bug in #380 — and a folded address is a
// spoofing hazard, not a cosmetic one. Nothing shares the address's row
// now, so all 42 characters fit at every nesting depth the popup uses and
// nothing has to be dropped or folded to make room.
//
// Options object:
// title — wallet title string (from addressTitle)
// ensName — ENS name string
// maxLen — if set, truncate address display (min 32 chars enforced)
// maxLen — if set, truncate address display. Floored at 32 characters
// here rather than by the caller: no view passes it any more
// (every address row is wide enough for all 42 characters),
// so a floor that lived in the callers would have gone away
// with them, and the "at least 32 characters" guarantee has
// to survive having no current callers to be a guarantee.
// noLink — if true, omit etherscan link
//
// After inserting the returned HTML into the DOM, call
@@ -536,22 +554,22 @@ function attachCopyHandlers(container) {
function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {};
const dot = addressDotHtml(address);
const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
const displayAddr = maxLen
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
: address;
const link = etherscanAddressUrl(address);
const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = "";
html += `<div class="flex items-center">${dot}`;
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
html += `<span class="font-bold">${escapeHtml(title)}</span>`;
}
html += `${extLink}</div>`;
if (ensName) {
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
}
if (title || ensName) {
html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
} else {
html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
html += `<div class="font-bold">${escapeHtml(ensName)}</div>`;
}
html += `<div class="am-address">${copyableHtml(displayAddr)}</div>`;
return html;
}
+19 -11
View File
@@ -9,7 +9,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
pushCurrentView,
@@ -117,10 +116,13 @@ function renderHomeTxList(ctx) {
const amountStr = tx.value
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
// The counterparty used to be squeezed in beside the amount and
// truncated to whatever was left over. It gets its own row now and
// is shown whole; the title, when it is one of our own addresses,
// names it on the line above rather than replacing it.
const title = addressTitle(counterparty, state.wallets);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
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;" : "";
@@ -128,7 +130,8 @@ function renderHomeTxList(ctx) {
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}${addrStr}</span><span>${amountStr}</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 += `</div>`;
i++;
}
@@ -252,17 +255,22 @@ function walletListHtml() {
: "";
const dot = addressDotHtml(addr.address);
const titleBold = isActive ? "font-bold" : "";
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
// [info] and [x] ride on the "Address N" line, which was empty
// to its right, so the address below gets the row to itself.
// They used to sit beside the address and take about a third of
// the width off it, which is what made a 42-character address
// fold onto a second line here and nowhere else (#380).
html += `<div class="flex text-xs items-center justify-between">`;
html += `<span class="flex items-center ${titleBold}">${dot}Address ${ai + 1}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`;
if (addr.ensName) {
// An ENS reverse record is whatever the name owner set it
// to; renderAddressHtml() escapes its own copy of this and
// this list was the one that did not.
html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
html += `<div class="text-xs font-bold">${escapeHtml(addr.ensName)}</div>`;
}
html += `<div class="flex text-xs items-center justify-between">`;
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${escapeHtml(addr.address)}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`;
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress(
+7 -1
View File
@@ -137,10 +137,16 @@ function render() {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress);
// Hand-rolled rather than renderAddressHtml() because the
// link goes to the explorer's /token/ page, not /address/.
// Same two-row shape though: dot and link on the strip, the
// contract address alone on the row below it.
tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") +
etherscanLinkHtml(link) +
`</div>` +
`<div class="am-address">` +
copyableHtml(tx.contractAddress) +
`</div>`;
tokenContractSection.classList.remove("hidden");
} else {