Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 23817de7d3 milestone 1.0.0: approval-path security, build-integrity guard, e2e harness and filter coverage (#190)
check / check (push) Successful in 1m6s
e2e / e2e-chrome (push) Successful in 2m4s
e2e / e2e-firefox (push) Successful in 53s
Reviewed-on: #190

I understand this is ready for 1.0.0 - user acceptance testing remains.  No tags for now but we can think of this as `1.0.0b1`.
2026-08-30 04:11:51 +02:00
19 changed files with 113 additions and 640 deletions
+7 -22
View File
@@ -800,12 +800,7 @@ discoverable.
addresses visually, as a security feature. addresses visually, as a security feature.
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for - **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
styling. Tailwind is configured with a minimal monochrome palette. This keeps styling. Tailwind is configured with a minimal monochrome palette. This keeps
the styling co-located with the markup and eliminates CSS file management. The the styling co-located with the markup and eliminates CSS file management.
handful of classes in `styles/main.css` are not styling: `.copy-flash-*`
carries the copy feedback animation, and `.am-address` carries the rule that
an address never wraps. Both are invariants that hold in every place they
appear, and spelling either out as repeated utilities is how one of those
places drifts away from the rest.
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small - **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
enough that vanilla JS with simple view switching is sufficient. A framework enough that vanilla JS with simple view switching is sufficient. A framework
would add bundle size, build complexity, and attack surface for no benefit at would add bundle size, build complexity, and attack surface for no benefit at
@@ -854,12 +849,6 @@ that the portions still displayed will be more than adequate for the user to
verify addresses even in the case of address spoofing attacks. Clicking an verify addresses even in the case of address spoofing attacks. Clicking an
address will always copy the full, untruncated value. address will always copy the full, untruncated value.
As of the address-row layout change, no view invokes that exception: every
address in the popup is rendered on a row of its own, wide enough for all 42
characters, and no screen truncates one to fit. The cap is still enforced in
`truncateMiddle()` and the 32-character floor in `renderAddressHtml()`, so the
guarantee holds for any future caller; there simply are none today.
**Specific Exception — Transaction Detail view:** The transaction detail screen **Specific Exception — Transaction Detail view:** The transaction detail screen
is the authoritative record of a specific transaction and shows the exact, is the authoritative record of a specific transaction and shows the exact,
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216 untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
@@ -1193,17 +1182,13 @@ view would leave a wallet one click from deletion.
- Send / Receive quick-action buttons, both acting on the active address - Send / Receive quick-action buttons, both acting on the active address
- ETH/USD price display - ETH/USD price display
- Wallet list: each wallet shows its name (tap to rename inline) and a "+" - Wallet list: each wallet shows its name (tap to rename inline) and a "+"
button for HD and xprv wallets, then one block per address. The block button for HD and xprv wallets, then one block per address with "Address
opens with a row carrying the colour dot, "Address N" (bold when active), N" (bold when active), the ENS name if resolved, the full address, an
an `[info]` button and an `[x]` button (only on HD and xprv wallets `[info]` button, an `[x]` button (only on HD and xprv wallets holding more
holding more than one address); the ENS name, if resolved, is below it; than one address), the address USD total, and a balance line for ETH and
then the full address on a row of its own, followed by the address USD for each token shown for that address
total and a balance line for ETH and for each token shown for that address
- "Recent Transactions": up to 25 transactions merged across every address - "Recent Transactions": up to 25 transactions merged across every address
of every wallet, deduplicated by hash and filtered. Each row is three of every wallet, deduplicated by hash and filtered
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
- "Add additional wallet..." link at bottom - "Add additional wallet..." link at bottom
- **Transitions**: - **Transitions**:
- Tap address row → sets the active address and broadcasts - Tap address row → sets the active address and broadcasts
-26
View File
@@ -45,32 +45,6 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-09-21: The dApp approval and transaction-status screens resolve a token's
symbol from the bundled list, then the tokens the user tracks, then the block
explorer's report — the same sources and precedence the amount line already
used for the token's scale
([#323](https://git.eeqj.de/sneak/AutistMask/issues/323), folding in
[#354](https://git.eeqj.de/sneak/AutistMask/issues/354)). A token the user
added by hand, or holds a balance of, is now named rather than labelled
`Unknown token`, and a non-bundled ERC-20 is no longer carried onto the wait
screen as `ETH`. A tracked or explorer-reported name stays subject to the
spoof rule, so resolving a symbol is not a new way to wear a known ticker.
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
wallet list was the reported case: the address shared one row with the
`[info]` and `[x]` controls and folded onto a second line, which turns one
42-character string the user is meant to compare into two shorter ones — the
shape an address-poisoning attack wants. The fix is layout, not CSS: every
address in the popup now sits alone on a full-width row, with the colour dot,
the wallet title, the ENS name and the explorer link moved onto a strip above
it, and the transaction rows carry the counterparty's whole address instead of
a `truncateMiddle()`d one squeezed in beside the amount. `truncateMiddle()`
keeps its 10-character cap and its 32-character floor moved into
`renderAddressHtml()`, so the guarantee outlives having no callers. The e2e
suite measures every rendered address in a real Chromium — whole, one line
box, inside its row and inside the popup — across Home, the address, token,
receive, send and transaction detail screens, the confirmation screen and the
dApp transaction prompt.
- 2026-08-23: Both manifests declare toolbar icons, and real PNGs at - 2026-08-23: Both manifests declare toolbar icons, and real PNGs at
16/32/48/128 ship inside both archives 16/32/48/128 ship inside both archives
([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest ([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest
+29 -20
View File
@@ -213,7 +213,10 @@
</div> </div>
<!-- active address display --> <!-- active address display -->
<div id="active-address-display" class="text-xs mb-3"></div> <div
id="active-address-display"
class="text-xs break-all mb-3"
></div>
<!-- quick actions for active address --> <!-- quick actions for active address -->
<div class="flex gap-2 mb-2"> <div class="flex gap-2 mb-2">
@@ -289,7 +292,7 @@
class="font-bold mb-1 hidden flex items-center" class="font-bold mb-1 hidden flex items-center"
></div> ></div>
<div <div
class="text-xs mb-1 cursor-pointer" class="text-xs mb-1 cursor-pointer break-all"
title="Click to copy" title="Click to copy"
id="address-line" id="address-line"
> >
@@ -377,14 +380,14 @@
></div> ></div>
<h2 class="font-bold mb-1">Export Private Key</h2> <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-1" id="export-privkey-title"></p>
<div class="text-xs mb-3"> <p class="text-xs mb-3">
<span id="export-privkey-dot"></span> <span id="export-privkey-dot"></span>
<span <span
id="export-privkey-address" id="export-privkey-address"
class="cursor-pointer" class="cursor-pointer"
title="Click to copy" title="Click to copy"
></span> ></span>
</div> </p>
<p class="text-xs mb-3 text-muted"> <p class="text-xs mb-3 text-muted">
Warning: anyone with this private key can access and Warning: anyone with this private key can access and
transfer all funds from this address. Never share it. transfer all funds from this address. Never share it.
@@ -437,7 +440,7 @@
</div> </div>
<div <div
class="text-xs mb-1 cursor-pointer" class="text-xs mb-1 cursor-pointer break-all"
title="Click to copy" title="Click to copy"
id="address-token-line" id="address-token-line"
> >
@@ -570,16 +573,19 @@
<!-- ERC-20 token contract (hidden for ETH) --> <!-- ERC-20 token contract (hidden for ETH) -->
<div id="confirm-token-section" class="mb-3 hidden"> <div id="confirm-token-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Token contract</div> <div class="text-xs text-muted mb-1">Token contract</div>
<div id="confirm-token-contract" class="text-xs"></div> <div
id="confirm-token-contract"
class="text-xs break-all"
></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">From</div> <div class="text-xs text-muted mb-1">From</div>
<div id="confirm-from" class="text-xs"></div> <div id="confirm-from" class="text-xs break-all"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
<div id="confirm-to" class="text-xs"></div> <div id="confirm-to" class="text-xs break-all"></div>
<div <div
id="confirm-to-ens" id="confirm-to-ens"
class="text-xs text-muted hidden" class="text-xs text-muted hidden"
@@ -722,7 +728,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
<div id="wait-tx-to" class="text-xs"></div> <div id="wait-tx-to" class="text-xs break-all"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Transaction hash</div> <div class="text-xs text-muted mb-1">Transaction hash</div>
@@ -741,7 +747,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
<div id="success-tx-to" class="text-xs"></div> <div id="success-tx-to" class="text-xs break-all"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Block</div> <div class="text-xs text-muted mb-1">Block</div>
@@ -768,7 +774,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
<div id="error-tx-to" class="text-xs"></div> <div id="error-tx-to" class="text-xs break-all"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div <div
@@ -805,9 +811,9 @@
<canvas id="receive-qr"></canvas> <canvas id="receive-qr"></canvas>
</div> </div>
<div <div
class="border border-border p-2 mb-3 text-xs cursor-pointer" class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
> >
<div id="receive-address-block" class="select-all"></div> <span id="receive-address-block" class="select-all"></span>
<span id="receive-etherscan-link"></span> <span id="receive-etherscan-link"></span>
</div> </div>
<button <button
@@ -1233,7 +1239,7 @@
</p> </p>
<div <div
id="delete-address-value" id="delete-address-value"
class="text-xs mb-2 min-h-[1rem]" class="text-xs mb-2 break-all min-h-[1rem]"
></div> ></div>
<div <div
class="text-xs mb-2 border border-border border-dashed p-2" class="text-xs mb-2 border border-border border-dashed p-2"
@@ -1423,11 +1429,14 @@
</div> </div>
<div class="mb-2"> <div class="mb-2">
<div class="text-xs text-muted mb-1">From</div> <div class="text-xs text-muted mb-1">From</div>
<div id="tx-detail-from" class="text-xs"></div> <div
id="tx-detail-from"
class="text-xs break-all"
></div>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs"></div> <div id="tx-detail-to" class="text-xs break-all"></div>
</div> </div>
</div> </div>
@@ -1464,7 +1473,7 @@
</div> </div>
<div <div
id="tx-detail-token-contract" id="tx-detail-token-contract"
class="text-xs" class="text-xs break-all"
></div> ></div>
</div> </div>
</div> </div>
@@ -1558,11 +1567,11 @@
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">From</div> <div class="text-xs text-muted mb-1">From</div>
<div id="approve-tx-from" class="text-xs"></div> <div id="approve-tx-from" class="text-xs break-all"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Contract</div> <div class="text-xs text-muted mb-1">Contract</div>
<div id="approve-tx-to" class="text-xs"></div> <div id="approve-tx-to" class="text-xs break-all"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Value</div> <div class="text-xs text-muted mb-1">Value</div>
@@ -1664,7 +1673,7 @@
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">From</div> <div class="text-xs text-muted mb-1">From</div>
<div id="approve-sign-from" class="text-xs"></div> <div id="approve-sign-from" class="text-xs break-all"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
-20
View File
@@ -44,23 +44,3 @@ body {
background-color 225ms ease-out, background-color 225ms ease-out,
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;
}
+6 -8
View File
@@ -7,6 +7,7 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
truncateMiddle,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
@@ -228,12 +229,10 @@ function renderTransactions(txs) {
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + sym) ? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym); : escapeHtml(sym);
// The counterparty used to be squeezed in beside the amount and const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
// truncated to whatever was left over. It gets its own row now and const displayAddr =
// is shown whole; the title or ENS name, where there is one, names title || ensName || truncateMiddle(counterparty, maxAddr);
// it on the line above rather than replacing it. const addrStr = escapeHtml(displayAddr);
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty); const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : ""; const opacity = tx.isError ? " opacity:0.5;" : "";
@@ -241,8 +240,7 @@ function renderTransactions(txs) {
const iso = escapeHtml(isoDate(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="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="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="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`; html += `</div>`;
i++; i++;
} }
+6 -8
View File
@@ -10,6 +10,7 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
truncateMiddle,
balanceLine, balanceLine,
unknownableAmount, unknownableAmount,
renderAddressHtml, renderAddressHtml,
@@ -304,12 +305,10 @@ function renderTransactions(txs) {
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + sym) ? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym); : escapeHtml(sym);
// The counterparty used to be squeezed in beside the amount and const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
// truncated to whatever was left over. It gets its own row now and const displayAddr =
// is shown whole; the title or ENS name, where there is one, names title || ensName || truncateMiddle(counterparty, maxAddr);
// it on the line above rather than replacing it. const addrStr = escapeHtml(displayAddr);
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty); const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : ""; const opacity = tx.isError ? " opacity:0.5;" : "";
@@ -317,8 +316,7 @@ function renderTransactions(txs) {
const iso = escapeHtml(isoDate(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="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="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="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`; html += `</div>`;
i++; i++;
} }
+21 -32
View File
@@ -20,9 +20,9 @@ const {
} = require("ethers"); } = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices"); const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants"); const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { const {
resolveTokenDecimals, resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount, unknownDecimalsAmount,
} = require("../../shared/approvalAmount"); } = require("../../shared/approvalAmount");
// Four decimals, with the nonzero floor these screens hold: every amount this // Four decimals, with the nonzero floor these screens hold: every amount this
@@ -63,15 +63,9 @@ function tokenAmountText(rawAmount, decimals, symbol) {
}; };
} }
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) { function tokenLabel(address) {
return resolveTokenSymbol(address, { const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
trackedTokens: state.trackedTokens, return t ? t.symbol : null;
wallets: state.wallets,
});
} }
// Try to decode calldata using known ABIs. // Try to decode calldata using known ABIs.
@@ -91,7 +85,8 @@ function decodeCalldata(data, toAddress) {
try { try {
const parsed = erc20Iface.parseTransaction({ data }); const parsed = erc20Iface.parseTransaction({ data });
if (parsed) { if (parsed) {
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources); const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null;
// null when no source knows this token's scale. It is not // null when no source knows this token's scale. It is not
// defaulted to 18: an amount formatted with a guessed scale is // defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the // the wrong number, and for a token with fewer decimals than the
@@ -247,11 +242,8 @@ function showTxApproval(details) {
const approvedTx = details.approvedTx; const approvedTx = details.approvedTx;
const toAddr = approvedTx.to; const toAddr = approvedTx.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(approvedTx.value || "0"); const ethValue = formatEther(approvedTx.value || "0");
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Build txInfo for status screens // Build txInfo for status screens
pendingTxDetails = { pendingTxDetails = {
@@ -259,17 +251,14 @@ function showTxApproval(details) {
to: toAddr || "", to: toAddr || "",
amount: formatTxValue(ethValue), amount: formatTxValue(ethValue),
token: "ETH", token: "ETH",
tokenSymbol: null, tokenSymbol: token ? token.symbol : null,
}; };
// If this is an ERC-20 call, try to extract the real recipient and amount // If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(approvedTx.data, toAddr || ""); const decoded = decodeCalldata(approvedTx.data, toAddr || "");
if (decoded && decoded.details) { if (decoded && decoded.details) {
// The asset the status summary is counted in: an ERC-20 call's Token let decodedTokenAddr = null;
// contract, or a swap's input token. Its symbol is resolved from the let decodedTokenSymbol = null;
// same sources as the approval screen, so a non-bundled token the
// wallet knows is not carried onto the wait and success screens as ETH.
let assetAddr = null;
for (const d of decoded.details) { for (const d of decoded.details) {
if (d.label === "Recipient" && d.address) { if (d.label === "Recipient" && d.address) {
pendingTxDetails.to = d.address; pendingTxDetails.to = d.address;
@@ -277,20 +266,20 @@ function showTxApproval(details) {
if (d.label === "Amount") { if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value; pendingTxDetails.amount = d.rawValue || d.value;
} }
if ( if (d.label === "Token In" && d.isToken && d.address) {
(d.label === "Token" || d.label === "Token In") && const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
d.isToken && if (t) {
d.address decodedTokenAddr = d.address;
) { decodedTokenSymbol = t.symbol;
assetAddr = d.address; }
} }
} }
if (assetAddr) { if (token) {
pendingTxDetails.token = assetAddr; pendingTxDetails.token = toAddr;
pendingTxDetails.tokenSymbol = resolveTokenSymbol( pendingTxDetails.tokenSymbol = token.symbol;
assetAddr, } else if (decodedTokenAddr) {
sources, pendingTxDetails.token = decodedTokenAddr;
); pendingTxDetails.tokenSymbol = decodedTokenSymbol;
} }
} }
+15 -33
View File
@@ -331,12 +331,6 @@ function addressHoldsFunds(addr) {
return false; 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 "…". // Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum // Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in // that still prevents address spoofing attacks (see Display Consistency in
@@ -524,29 +518,17 @@ function attachCopyHandlers(container) {
// Unified address rendering. // Unified address rendering.
// //
// Two stacked rows, in this order: // Produces consistent HTML for any Ethereum address:
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 — // • Color dot
// Address 2") and the explorer link icon. Optional ENS name below it. // • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
// 2. The address itself, alone on a full-width row that never wraps // • Optional ENS name shown bold above address
// (see .am-address in styles/main.css). // • Full address (or truncated via maxLen) with dashed-underline click-to-copy
// // • Etherscan external link icon
// 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: // Options object:
// title — wallet title string (from addressTitle) // title — wallet title string (from addressTitle)
// ensName — ENS name string // ensName — ENS name string
// maxLen — if set, truncate address display. Floored at 32 characters // maxLen — if set, truncate address display (min 32 chars enforced)
// 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 // noLink — if true, omit etherscan link
// //
// After inserting the returned HTML into the DOM, call // After inserting the returned HTML into the DOM, call
@@ -554,22 +536,22 @@ function attachCopyHandlers(container) {
function renderAddressHtml(address, opts) { function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {}; const { title, ensName, maxLen, noLink } = opts || {};
const dot = addressDotHtml(address); const dot = addressDotHtml(address);
const displayAddr = maxLen const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
: address;
const link = etherscanAddressUrl(address); const link = etherscanAddressUrl(address);
const extLink = noLink ? "" : etherscanLinkHtml(link); const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = ""; let html = "";
html += `<div class="flex items-center">${dot}`;
if (title) { if (title) {
html += `<span class="font-bold">${escapeHtml(title)}</span>`; html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
} }
html += `${extLink}</div>`;
if (ensName) { if (ensName) {
html += `<div class="font-bold">${escapeHtml(ensName)}</div>`; 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="am-address">${copyableHtml(displayAddr)}</div>`;
return html; return html;
} }
+11 -19
View File
@@ -9,6 +9,7 @@ const {
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
truncateMiddle,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
pushCurrentView, pushCurrentView,
@@ -116,13 +117,10 @@ function renderHomeTxList(ctx) {
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + sym) ? escapeHtml(tx.value + " " + sym)
: escapeHtml(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 title = addressTitle(counterparty, state.wallets);
const titleStr = title ? escapeHtml(title) : ""; const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const addrStr = escapeHtml(counterparty); const displayAddr = title || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty); const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : ""; const opacity = tx.isError ? " opacity:0.5;" : "";
@@ -130,8 +128,7 @@ function renderHomeTxList(ctx) {
const iso = escapeHtml(isoDate(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="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="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="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`; html += `</div>`;
i++; i++;
} }
@@ -255,22 +252,17 @@ function walletListHtml() {
: ""; : "";
const dot = addressDotHtml(addr.address); const dot = addressDotHtml(addr.address);
const titleBold = isActive ? "font-bold" : ""; const titleBold = isActive ? "font-bold" : "";
// [info] and [x] ride on the "Address N" line, which was empty html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
// 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) { if (addr.ensName) {
// An ENS reverse record is whatever the name owner set it // An ENS reverse record is whatever the name owner set it
// to; renderAddressHtml() escapes its own copy of this and // to; renderAddressHtml() escapes its own copy of this and
// this list was the one that did not. // this list was the one that did not.
html += `<div class="text-xs font-bold">${escapeHtml(addr.ensName)}</div>`; html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
} }
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</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>`;
const addrTotal = formatAddressTotal(getAddressValue(addr)); const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`; html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress( html += balanceLinesForAddress(
+1 -7
View File
@@ -137,16 +137,10 @@ function render() {
if (tx.contractAddress) { if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress); const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", 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 = tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` + `<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") +
etherscanLinkHtml(link) + etherscanLinkHtml(link) +
`</div>` +
`<div class="am-address">` +
copyableHtml(tx.contractAddress) +
`</div>`; `</div>`;
tokenContractSection.classList.remove("hidden"); tokenContractSection.classList.remove("hidden");
} else { } else {
+3 -9
View File
@@ -13,7 +13,7 @@ const {
displaySymbol, displaySymbol,
clearViewStack, clearViewStack,
} = require("./helpers"); } = require("./helpers");
const { resolveTokenSymbol } = require("../../shared/approvalAmount"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -232,15 +232,9 @@ function showSuccess(txInfo, txHash, blockNumber) {
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} }
// The symbol shown for a decoded token line, resolved from the bundled list,
// the tokens the user tracks, and the explorer's report — the same chain the
// approval screen uses. Null when no source names one, so the line keeps
// saying `Unknown token`.
function tokenLabel(address) { function tokenLabel(address) {
return resolveTokenSymbol(address, { const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
trackedTokens: state.trackedTokens, return t ? t.symbol : null;
wallets: state.wallets,
});
} }
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded) {
-55
View File
@@ -30,7 +30,6 @@
// enumerated rather than coerced. // enumerated rather than coerced.
const { toDecimals } = require("./transferAmount"); const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof");
// Every decimals the explorer reported for this contract, across all the // Every decimals the explorer reported for this contract, across all the
// addresses whose balances have been fetched. They describe one contract, so // addresses whose balances have been fetched. They describe one contract, so
@@ -75,59 +74,6 @@ function resolveTokenDecimals(tokenAddress, sources) {
return explorerDecimals(lower, sources && sources.wallets); return explorerDecimals(lower, sources && sources.wallets);
} }
// Every symbol the explorer reported for this contract, across the addresses
// whose balances have been fetched. The counterpart to explorerDecimals(): one
// contract, so the reports should agree, and a set that does not agree is a
// name this screen has no way to choose between.
function explorerSymbol(lower, wallets) {
let found = null;
for (const wallet of wallets || []) {
for (const addr of wallet.addresses || []) {
for (const tb of addr.tokenBalances || []) {
if ((tb.address || "").toLowerCase() !== lower) continue;
if (!tb.symbol) continue;
if (found !== null && found !== tb.symbol) return null;
found = tb.symbol;
}
}
}
return found;
}
// The symbol to label a token with, or null when no source the wallet trusts
// names one — in which case the screen keeps saying `Unknown token` rather than
// guessing. The bundled list, then the tokens the user tracks, then what the
// explorer reported: the same sources and the same precedence
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from
// the same place and the two can no longer disagree about which sources they
// trust. `sources` is { trackedTokens, wallets }, shaped as on `state`.
//
// A tracked or explorer-reported symbol is attacker-influenced text, so it is
// held to the spoof rule (symbolSpoof.js): a candidate that wears a bundled or
// native ticker from a contract not entitled to it is refused and the next
// source tried, so resolving a symbol never becomes a new way to claim a known
// ticker. The bundled list is the wallet's own data and is trusted as it is.
function resolveTokenSymbol(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = TOKEN_BY_ADDRESS.get(lower);
if (bundled && bundled.symbol) return bundled.symbol;
const tracked = ((sources && sources.trackedTokens) || []).find(
(t) => (t.address || "").toLowerCase() === lower,
);
const candidates = [];
if (tracked && tracked.symbol) candidates.push(tracked.symbol);
const reported = explorerSymbol(lower, sources && sources.wallets);
if (reported) candidates.push(reported);
for (const symbol of candidates) {
if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
}
return null;
}
// What the amount line reads when the scale is unknown. The base units are // What the amount line reads when the scale is unknown. The base units are
// exact and the caveat is part of the same string, so the number on the screen // exact and the caveat is part of the same string, so the number on the screen
// cannot be mistaken for a token quantity, and it can never read as zero for a // cannot be mistaken for a token quantity, and it can never read as zero for a
@@ -138,6 +84,5 @@ function unknownDecimalsAmount(rawAmount) {
module.exports = { module.exports = {
resolveTokenDecimals, resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount, unknownDecimalsAmount,
}; };
+3 -2
View File
@@ -2,10 +2,10 @@
// swap details. Designed to be extended with other DEX decoders later. // swap details. Designed to be extended with other DEX decoders later.
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers"); const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { truncateAmountNeverZero } = require("./amountDisplay"); const { truncateAmountNeverZero } = require("./amountDisplay");
const { const {
resolveTokenDecimals, resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount, unknownDecimalsAmount,
} = require("./approvalAmount"); } = require("./approvalAmount");
@@ -123,8 +123,9 @@ function tokenInfo(address, sources) {
if (address === "0x0000000000000000000000000000000000000000") { if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: "ETH", decimals: 18, address: null }; return { symbol: "ETH", decimals: 18, address: null };
} }
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return { return {
symbol: resolveTokenSymbol(address, sources), symbol: t ? t.symbol : null,
decimals: resolveTokenDecimals(address, sources), decimals: resolveTokenDecimals(address, sources),
address, address,
}; };
+3 -5
View File
@@ -143,18 +143,16 @@ describe("decodeCalldata amount", () => {
state.trackedTokens = [ state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 }, { address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
]; ];
// The tracked entry supplies both: the scale (5000.0000) and, since
// issue #323, the symbol that the scale is counted in.
expect( expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN), amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000 NOVEL"); ).toBe("5000.0000");
}); });
test("transfer priced off the explorer's decimals shows the true quantity", () => { test("transfer priced off the explorer's decimals shows the true quantity", () => {
state.wallets = walletsHolding(NOVEL_TOKEN, "6"); state.wallets = walletsHolding(NOVEL_TOKEN, "6");
expect( expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN), amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000 NOVEL"); ).toBe("5000.0000");
}); });
test("transfer of an unknown-decimals token shows base units, not a number", () => { test("transfer of an unknown-decimals token shows base units, not a number", () => {
@@ -174,7 +172,7 @@ describe("decodeCalldata amount", () => {
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 }, { address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
]; ];
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe( expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
"5000.0000 NOVEL", "5000.0000",
); );
}); });
-203
View File
@@ -1525,7 +1525,6 @@ async function goToConfirm(page, { token, balance, amount }) {
await page.fill("#send-amount", amount); await page.fill("#send-amount", amount);
await page.click("#btn-send-review"); await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx"); await visible(page, "#view-confirm-tx");
await assertAddressesFit(page, "the confirmation screen");
} }
// A balance as the main view renders it: balanceLinesForAddress() writes // A balance as the main view renders it: balanceLinesForAddress() writes
@@ -3263,8 +3262,6 @@ test("eth_sendTransaction signs the approved transaction and broadcasts it (#183
JSON.stringify(screen.data), JSON.stringify(screen.data),
); );
await assertAddressesFit(popup, "the dApp transaction prompt");
const broadcastBefore = env.routeOpts.broadcastTransactions.length; const broadcastBefore = env.routeOpts.broadcastTransactions.length;
await popup.fill("#approve-tx-password", PASSWORD); await popup.fill("#approve-tx-password", PASSWORD);
await popup.click("#btn-approve-tx"); await popup.click("#btn-approve-tx");
@@ -3428,206 +3425,6 @@ test("the password never crossed either boundary in this section (#183)", async
await env.dapp.close(); await env.dapp.close();
}); });
// ------------------------------------------- address layout (#380)
//
// "addresses should never wrap in the common views. this doesn't mean to
// just change the css, but update the layout itself so the untruncated
// addresses are shown in full and don't mess up the layout."
//
// Every one of these questions is about glyph advances and the width of
// the box an address landed in, and nothing in the markup answers any of
// them: a row can hold `white-space: nowrap` and still be too narrow, and
// the popup's own `overflow-x-hidden` would then hide the evidence by
// clipping the tail. So they are measured in a real Chromium, on the real
// rendered views, one assertion per property #380 names:
//
// - the whole address is there (42 characters, no ellipsis)
// - it occupies exactly one line box
// - it fits its row, so the overflow-x escape hatch never engages
// - its row ends inside the popup's content box
// - and the document itself does not scroll sideways
//
// The narrowest containers the popup has are covered here — the
// transaction detail wells (`bg-well p-3 mx-1`) and the token contract
// well — so the wider ones cannot fail while these pass.
// Everything on screen that carries an address, measured in one pass.
// Views other than the current one are display:none and measure zero, so
// filtering on width leaves exactly what a user can see right now.
function addressRowReport(page) {
return page.evaluate(() => {
const app = document.getElementById("app");
const appRight = app.getBoundingClientRect().right;
const rows = [];
for (const el of document.querySelectorAll(".am-address")) {
const box = el.getBoundingClientRect();
if (box.width === 0) continue;
// Line boxes are counted off the inline content, because the
// element's own rect is one box whether the text inside it
// wrapped or not. A Range yields a rect per contained node as
// well as per line, so it is the distinct tops that count:
// a copyable span and the text inside it share one.
const range = document.createRange();
range.selectNodeContents(el);
const tops = new Set(
Array.from(range.getClientRects()).map((r) =>
Math.round(r.top),
),
);
rows.push({
text: el.innerText.trim(),
lineBoxes: tops.size,
overflow: el.scrollWidth - el.clientWidth,
overhang: Math.round(box.right - appRight),
});
}
return {
rows,
pageOverflow:
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
};
});
}
async function assertAddressesFit(page, where) {
const report = await addressRowReport(page);
assert(
report.rows.length > 0,
where + ": no address rows were rendered, so nothing was measured",
);
for (const row of report.rows) {
assert(
/^0x[0-9a-fA-F]{40}$/.test(row.text),
where +
": the address is not shown whole: " +
JSON.stringify(row.text),
);
assert(
row.lineBoxes === 1,
where +
": " +
row.text +
" wrapped onto " +
row.lineBoxes +
" lines",
);
assert(
row.overflow <= 1,
where +
": " +
row.text +
" is " +
row.overflow +
"px wider than the row holding it",
);
assert(
row.overhang <= 1,
where +
": " +
row.text +
" reaches " +
row.overhang +
"px past the popup's content box",
);
}
assert(
report.pageOverflow <= 0,
where + ": the popup scrolls sideways by " + report.pageOverflow + "px",
);
return report.rows.length;
}
// Back to Home from wherever the suite above finished, without assuming
// which screen that was. Every screen the popup can rest on has a Back
// button, and Home has none, so unwinding until Home shows is the one
// route that does not depend on the order of the tests before this point.
async function unwindToHome(page) {
for (let i = 0; i < 12; i++) {
if (await page.isVisible("#view-main")) return;
const back = page
.locator(".view:not(.hidden) button", { hasText: "Back" })
.first();
if ((await back.count()) === 0) break;
await back.click();
await page.waitForTimeout(150);
}
await visible(page, "#view-main");
}
// The reproduction from the issue: a wallet holding more than one address.
// Every address in the list is a full 42 characters competing with the
// [info] and [x] controls for one row's width, which is the state the
// wallet view was reported wrapping in.
test("a wallet with two addresses lists both in full, unwrapped (#380)", async (env) => {
await unwindToHome(env.page);
const before = await env.page
.locator("#wallet-list .btn-addr-info")
.count();
await env.page.locator("#wallet-list .btn-add-address").first().click();
await env.page.waitForFunction(
(n) =>
document.querySelectorAll("#wallet-list .btn-addr-info").length > n,
before,
{ timeout: 60000 },
);
const shown = await assertAddressesFit(env.page, "the wallet list");
assert(
shown >= before + 1,
"the wallet list measured " +
shown +
" addresses, fewer than the " +
(before + 1) +
" it now holds",
);
// The [x] control only exists on a wallet holding more than one
// address, so its presence is also the proof the second one landed.
const removable = await env.page
.locator("#wallet-list .btn-remove-address")
.count();
assert(removable > 0, "the second address did not reach the wallet list");
});
test("every common view shows its addresses in full on one line (#380)", async (env) => {
await unwindToHome(env.page);
await assertAddressesFit(env.page, "Home");
await env.page.locator("#wallet-list .btn-addr-info").first().click();
await visible(env.page, "#view-address");
await visible(env.page, "#tx-list .tx-row");
await assertAddressesFit(env.page, "the address screen");
await env.page.click("#btn-receive");
await visible(env.page, "#view-receive");
await assertAddressesFit(env.page, "the receive screen");
await env.page.click("#btn-receive-back");
await visible(env.page, "#view-address");
await env.page.click("#btn-send");
await visible(env.page, "#view-send");
await assertAddressesFit(env.page, "the send screen");
await env.page.click("#btn-send-back");
await visible(env.page, "#view-address");
// The transaction detail screen carries the narrowest address rows in
// the popup: its fields sit inside a well that takes another 24px of
// padding and 8px of margin off the content width, and the token
// contract row there is narrower still.
await env.page.locator("#address-balances .balance-row").first().click();
await visible(env.page, "#view-address-token");
await assertAddressesFit(env.page, "the token screen");
await env.page.click("#btn-address-token-back");
await visible(env.page, "#view-address");
await env.page.locator("#tx-list .tx-row").first().click();
await visible(env.page, "#view-transaction");
await visible(env.page, "#tx-detail-token-contract-section");
await assertAddressesFit(env.page, "the transaction detail screen");
});
// ---------------------------------------------------------------- runner // ---------------------------------------------------------------- runner
async function main() { async function main() {
+2 -3
View File
@@ -253,9 +253,8 @@ describe("the ERC-20 approval line reaches its refusal", () => {
test("a scale the explorer did report still formats", async () => { test("a scale the explorer did report still formats", async () => {
await fetchOnto([row({ decimals: "6" })]); await fetchOnto([row({ decimals: "6" })]);
// The same explorer entry now also names the token (issue #323).
expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe( expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
"1000.0000 NOVEL", "1000.0000",
); );
}); });
}); });
@@ -274,7 +273,7 @@ describe("the swap approval line reaches its refusal", () => {
await fetchOnto([row({ decimals: "6" })]); await fetchOnto([row({ decimals: "6" })]);
expect( expect(
swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)), swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
).toBe("1000.0000 NOVEL"); ).toBe("1000.0000");
}); });
}); });
-156
View File
@@ -1,156 +0,0 @@
// The symbol the dApp approval and status screens label a token with.
//
// Issue #323: the approval screen labelled anything outside the bundled list
// `Unknown token`, even a token the user tracks or holds a balance of, while
// the amount line already read that token's *scale* from those same sources
// (issue #306). The name and the scale disagreed about which sources they
// trust. resolveTokenSymbol() closes that gap: it draws the symbol from the
// bundled list, then the tracked tokens, then the explorer's report — the
// precedence resolveTokenDecimals() uses — and returns null, not a guess,
// when nothing names it, so the screens keep saying `Unknown token`.
//
// A tracked or explorer-reported symbol is attacker-influenced text, so it
// stays subject to the spoof rule (src/shared/symbolSpoof.js): resolving a
// symbol must not become a new way for a stray contract to wear a bundled or
// native ticker.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const { resolveTokenSymbol } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const iface = new Interface(ERC20_ABI);
// Outside the bundled list, as the great majority of ERC-20s are.
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list: USDC at 6 decimals, DAI at 18.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const FIVE_THOUSAND_AT_SIX = 5000000000n;
function transferData(amount) {
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}
// A wallet whose block-explorer balance for `token` reports `symbol`, shaped
// as balances.js writes it onto state.
function walletsReporting(token, symbol) {
return [
{
name: "Wallet 1",
addresses: [
{
address: "0x" + "a".repeat(40),
balance: "1.0",
tokenBalances: [
{
address: token,
symbol,
decimals: 6,
balance: "5000.0",
},
],
},
],
},
];
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("resolveTokenSymbol", () => {
test("reads the bundled list", () => {
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
});
test("prefers the bundled list over a tracked entry", () => {
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
});
test("reads a token the user tracks", () => {
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "NOVEL" }];
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBe("NOVEL");
});
test("reads the symbol the explorer reported", () => {
state.wallets = walletsReporting(NOVEL_TOKEN, "NOVEL");
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBe("NOVEL");
});
test("is null when no source names the token", () => {
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
test("refuses a name the explorer's own entries disagree about", () => {
const wallets = walletsReporting(NOVEL_TOKEN, "NOVEL");
wallets[0].addresses.push({
address: "0x" + "b".repeat(40),
balance: "0.0",
tokenBalances: [{ address: NOVEL_TOKEN, symbol: "OTHER" }],
});
state.wallets = wallets;
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
test("rejects a tracked entry claiming a bundled ticker it is not", () => {
// NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC.
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
test("rejects an explorer entry claiming the native ETH ticker", () => {
state.wallets = walletsReporting(NOVEL_TOKEN, "ETH");
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
});
describe("decodeCalldata symbol", () => {
test("a tracked token is named, not called Unknown", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(decoded.description).toBe("Transfer NOVEL");
const amount = decoded.details.find((d) => d.label === "Amount");
expect(amount.value).toBe("5000.0000 NOVEL");
});
test("a token nothing knows keeps a symbol-less label", () => {
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(decoded.description).toBe("Transfer ERC-20 token");
const token = decoded.details.find((d) => d.label === "Token");
// The Token line carries the address and is flagged for the screen's
// symbol lookup, which resolves to nothing here — so `Unknown token`.
expect(token.isToken).toBe(true);
expect(token.address).toBe(NOVEL_TOKEN);
expect(resolveTokenSymbol(token.address, state)).toBeNull();
});
test("a tracked token spoofing a bundled ticker is not named by it", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "USDC", decimals: 6 },
];
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(decoded.description).toBe("Transfer ERC-20 token");
const amount = decoded.details.find((d) => d.label === "Amount");
expect(amount.value).not.toMatch(/USDC/);
});
});
+3 -8
View File
@@ -78,20 +78,15 @@ describe("a swap to a token absent from the bundled list", () => {
); );
}); });
test("names the tracked symbol alongside the address (issue #323)", () => { test("names the address when the scale is known but the symbol is not", () => {
// The tracked entry supplies both halves now: the scale, and the
// symbol the output line is named by. Before #323 the symbol was read
// from the bundled list alone, so this line fell back to the address.
const sources = { const sources = {
trackedTokens: [ trackedTokens: [
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 }, { address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
], ],
}; };
expect(detail(data(), "Token Out", sources).value).toBe( expect(detail(data(), "Token Out", sources).value).toBe(NOVEL_OUT);
"NOVEL (" + NOVEL_OUT + ")",
);
expect(detail(data(), "Min. received", sources).value).toBe( expect(detail(data(), "Min. received", sources).value).toBe(
"1000.0000 NOVEL", "1000.0000",
); );
}); });
}); });
+3 -4
View File
@@ -98,13 +98,12 @@ describe("a swap of a token outside the bundled list", () => {
state.trackedTokens = [ state.trackedTokens = [
{ address: NOVEL, symbol: "NOVEL", decimals: 6 }, { address: NOVEL, symbol: "NOVEL", decimals: 6 },
]; ];
// The tracked entry names the token as well as scaling it (issue #323). expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
}); });
test("shows the true quantity from the explorer's decimals", () => { test("shows the true quantity from the explorer's decimals", () => {
state.wallets = walletsHolding(NOVEL, "6"); state.wallets = walletsHolding(NOVEL, "6");
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL"); expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
}); });
test("refuses to format when nothing knows the scale", () => { test("refuses to format when nothing knows the scale", () => {
@@ -141,7 +140,7 @@ describe("the Min. received line takes the same rule", () => {
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 }, { address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
]; ];
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX); const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
expect(swapDetail(data, "Min. received").value).toBe("1000.0000 NOVEL"); expect(swapDetail(data, "Min. received").value).toBe("1000.0000");
}); });
}); });