Compare commits
2
Commits
main
..
2a918bf6bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a918bf6bf | ||
|
|
1197d2171b |
@@ -800,7 +800,12 @@ 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 styling co-located with the markup and eliminates CSS file management. The
|
||||||
|
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
|
||||||
@@ -849,6 +854,12 @@ 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
|
||||||
@@ -1182,13 +1193,17 @@ 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 with "Address
|
button for HD and xprv wallets, then one block per address. The block
|
||||||
N" (bold when active), the ENS name if resolved, the full address, an
|
opens with a row carrying the colour dot, "Address N" (bold when active),
|
||||||
`[info]` button, an `[x]` button (only on HD and xprv wallets holding more
|
an `[info]` button and an `[x]` button (only on HD and xprv wallets
|
||||||
than one address), the address USD total, and a balance line for ETH and
|
holding more than one address); the ENS name, if resolved, is below it;
|
||||||
for each token shown for that address
|
then the full address on a row of its own, followed by the address USD
|
||||||
|
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
|
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
|
||||||
- "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
|
||||||
|
|||||||
@@ -45,6 +45,32 @@ 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
|
||||||
|
|||||||
+20
-29
@@ -213,10 +213,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- active address display -->
|
<!-- active address display -->
|
||||||
<div
|
<div id="active-address-display" class="text-xs mb-3"></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">
|
||||||
@@ -292,7 +289,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 break-all"
|
class="text-xs mb-1 cursor-pointer"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
id="address-line"
|
id="address-line"
|
||||||
>
|
>
|
||||||
@@ -380,14 +377,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>
|
||||||
<p class="text-xs mb-3">
|
<div 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>
|
||||||
</p>
|
</div>
|
||||||
<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.
|
||||||
@@ -440,7 +437,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="text-xs mb-1 cursor-pointer break-all"
|
class="text-xs mb-1 cursor-pointer"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
id="address-token-line"
|
id="address-token-line"
|
||||||
>
|
>
|
||||||
@@ -573,19 +570,16 @@
|
|||||||
<!-- 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
|
<div id="confirm-token-contract" class="text-xs"></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 break-all"></div>
|
<div id="confirm-from" class="text-xs"></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 break-all"></div>
|
<div id="confirm-to" class="text-xs"></div>
|
||||||
<div
|
<div
|
||||||
id="confirm-to-ens"
|
id="confirm-to-ens"
|
||||||
class="text-xs text-muted hidden"
|
class="text-xs text-muted hidden"
|
||||||
@@ -728,7 +722,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 break-all"></div>
|
<div id="wait-tx-to" class="text-xs"></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>
|
||||||
@@ -747,7 +741,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 break-all"></div>
|
<div id="success-tx-to" class="text-xs"></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>
|
||||||
@@ -774,7 +768,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 break-all"></div>
|
<div id="error-tx-to" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div
|
<div
|
||||||
@@ -811,9 +805,9 @@
|
|||||||
<canvas id="receive-qr"></canvas>
|
<canvas id="receive-qr"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<span id="receive-etherscan-link"></span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -1239,7 +1233,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="delete-address-value"
|
id="delete-address-value"
|
||||||
class="text-xs mb-2 break-all min-h-[1rem]"
|
class="text-xs mb-2 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"
|
||||||
@@ -1429,14 +1423,11 @@
|
|||||||
</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
|
<div id="tx-detail-from" class="text-xs"></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 break-all"></div>
|
<div id="tx-detail-to" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1473,7 +1464,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="tx-detail-token-contract"
|
id="tx-detail-token-contract"
|
||||||
class="text-xs break-all"
|
class="text-xs"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1567,11 +1558,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 break-all"></div>
|
<div id="approve-tx-from" class="text-xs"></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 break-all"></div>
|
<div id="approve-tx-to" class="text-xs"></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>
|
||||||
@@ -1673,7 +1664,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 break-all"></div>
|
<div id="approve-sign-from" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
@@ -44,3 +44,23 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
truncateMiddle,
|
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
@@ -229,10 +228,12 @@ function renderTransactions(txs) {
|
|||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + sym)
|
? escapeHtml(tx.value + " " + sym)
|
||||||
: escapeHtml(sym);
|
: escapeHtml(sym);
|
||||||
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
// The counterparty used to be squeezed in beside the amount and
|
||||||
const displayAddr =
|
// truncated to whatever was left over. It gets its own row now and
|
||||||
title || ensName || truncateMiddle(counterparty, maxAddr);
|
// is shown whole; the title or ENS name, where there is one, names
|
||||||
const addrStr = escapeHtml(displayAddr);
|
// it on the line above rather than replacing it.
|
||||||
|
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;" : "";
|
||||||
@@ -240,7 +241,8 @@ 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}${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>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
truncateMiddle,
|
|
||||||
balanceLine,
|
balanceLine,
|
||||||
unknownableAmount,
|
unknownableAmount,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
@@ -305,10 +304,12 @@ function renderTransactions(txs) {
|
|||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + sym)
|
? escapeHtml(tx.value + " " + sym)
|
||||||
: escapeHtml(sym);
|
: escapeHtml(sym);
|
||||||
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
// The counterparty used to be squeezed in beside the amount and
|
||||||
const displayAddr =
|
// truncated to whatever was left over. It gets its own row now and
|
||||||
title || ensName || truncateMiddle(counterparty, maxAddr);
|
// is shown whole; the title or ENS name, where there is one, names
|
||||||
const addrStr = escapeHtml(displayAddr);
|
// it on the line above rather than replacing it.
|
||||||
|
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;" : "";
|
||||||
@@ -316,7 +317,8 @@ 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}${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>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-21
@@ -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,9 +63,15 @@ 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) {
|
||||||
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
return resolveTokenSymbol(address, {
|
||||||
return t ? t.symbol : null;
|
trackedTokens: state.trackedTokens,
|
||||||
|
wallets: state.wallets,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to decode calldata using known ABIs.
|
// Try to decode calldata using known ABIs.
|
||||||
@@ -85,8 +91,7 @@ function decodeCalldata(data, toAddress) {
|
|||||||
try {
|
try {
|
||||||
const parsed = erc20Iface.parseTransaction({ data });
|
const parsed = erc20Iface.parseTransaction({ data });
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
|
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
|
||||||
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
|
||||||
@@ -242,8 +247,11 @@ 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 = {
|
||||||
@@ -251,14 +259,17 @@ function showTxApproval(details) {
|
|||||||
to: toAddr || "",
|
to: toAddr || "",
|
||||||
amount: formatTxValue(ethValue),
|
amount: formatTxValue(ethValue),
|
||||||
token: "ETH",
|
token: "ETH",
|
||||||
tokenSymbol: token ? token.symbol : null,
|
tokenSymbol: 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) {
|
||||||
let decodedTokenAddr = null;
|
// The asset the status summary is counted in: an ERC-20 call's Token
|
||||||
let decodedTokenSymbol = null;
|
// contract, or a swap's input token. Its symbol is resolved from the
|
||||||
|
// 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;
|
||||||
@@ -266,20 +277,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 (d.label === "Token In" && d.isToken && d.address) {
|
if (
|
||||||
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
|
(d.label === "Token" || d.label === "Token In") &&
|
||||||
if (t) {
|
d.isToken &&
|
||||||
decodedTokenAddr = d.address;
|
d.address
|
||||||
decodedTokenSymbol = t.symbol;
|
) {
|
||||||
}
|
assetAddr = d.address;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (token) {
|
if (assetAddr) {
|
||||||
pendingTxDetails.token = toAddr;
|
pendingTxDetails.token = assetAddr;
|
||||||
pendingTxDetails.tokenSymbol = token.symbol;
|
pendingTxDetails.tokenSymbol = resolveTokenSymbol(
|
||||||
} else if (decodedTokenAddr) {
|
assetAddr,
|
||||||
pendingTxDetails.token = decodedTokenAddr;
|
sources,
|
||||||
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-15
@@ -331,6 +331,12 @@ 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
|
||||||
@@ -518,17 +524,29 @@ function attachCopyHandlers(container) {
|
|||||||
|
|
||||||
// Unified address rendering.
|
// Unified address rendering.
|
||||||
//
|
//
|
||||||
// Produces consistent HTML for any Ethereum address:
|
// Two stacked rows, in this order:
|
||||||
// • Color dot
|
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 —
|
||||||
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
|
// Address 2") and the explorer link icon. Optional ENS name below it.
|
||||||
// • Optional ENS name shown bold above address
|
// 2. The address itself, alone on a full-width row that never wraps
|
||||||
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
|
// (see .am-address in styles/main.css).
|
||||||
// • 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 (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
|
// noLink — if true, omit etherscan link
|
||||||
//
|
//
|
||||||
// After inserting the returned HTML into the DOM, call
|
// After inserting the returned HTML into the DOM, call
|
||||||
@@ -536,22 +554,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 ? truncateMiddle(address, maxLen) : address;
|
const displayAddr = maxLen
|
||||||
|
? 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 += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
|
html += `<span class="font-bold">${escapeHtml(title)}</span>`;
|
||||||
}
|
}
|
||||||
|
html += `${extLink}</div>`;
|
||||||
if (ensName) {
|
if (ensName) {
|
||||||
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
|
html += `<div class="font-bold">${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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-11
@@ -9,7 +9,6 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
truncateMiddle,
|
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
@@ -117,10 +116,13 @@ 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 maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
const titleStr = title ? escapeHtml(title) : "";
|
||||||
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
|
const addrStr = escapeHtml(counterparty);
|
||||||
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;" : "";
|
||||||
@@ -128,7 +130,8 @@ 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}${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>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
@@ -252,17 +255,22 @@ function walletListHtml() {
|
|||||||
: "";
|
: "";
|
||||||
const dot = addressDotHtml(addr.address);
|
const dot = addressDotHtml(addr.address);
|
||||||
const titleBold = isActive ? "font-bold" : "";
|
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) {
|
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 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 += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
||||||
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 || " "}</div>`;
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
||||||
html += balanceLinesForAddress(
|
html += balanceLinesForAddress(
|
||||||
|
|||||||
@@ -137,10 +137,16 @@ 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 {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const {
|
|||||||
displaySymbol,
|
displaySymbol,
|
||||||
clearViewStack,
|
clearViewStack,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||||
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,9 +232,15 @@ 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) {
|
||||||
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
return resolveTokenSymbol(address, {
|
||||||
return t ? t.symbol : null;
|
trackedTokens: state.trackedTokens,
|
||||||
|
wallets: state.wallets,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodedDetailsHtml(decoded) {
|
function decodedDetailsHtml(decoded) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
// 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
|
||||||
@@ -74,6 +75,59 @@ 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
|
||||||
@@ -84,5 +138,6 @@ function unknownDecimalsAmount(rawAmount) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
resolveTokenDecimals,
|
resolveTokenDecimals,
|
||||||
|
resolveTokenSymbol,
|
||||||
unknownDecimalsAmount,
|
unknownDecimalsAmount,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,9 +123,8 @@ 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: t ? t.symbol : null,
|
symbol: resolveTokenSymbol(address, sources),
|
||||||
decimals: resolveTokenDecimals(address, sources),
|
decimals: resolveTokenDecimals(address, sources),
|
||||||
address,
|
address,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -143,16 +143,18 @@ 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");
|
).toBe("5000.0000 NOVEL");
|
||||||
});
|
});
|
||||||
|
|
||||||
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");
|
).toBe("5000.0000 NOVEL");
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
||||||
@@ -172,7 +174,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",
|
"5000.0000 NOVEL",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1525,6 +1525,7 @@ 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
|
||||||
@@ -3262,6 +3263,8 @@ 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");
|
||||||
@@ -3425,6 +3428,206 @@ 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() {
|
||||||
|
|||||||
@@ -253,8 +253,9 @@ 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",
|
"1000.0000 NOVEL",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -273,7 +274,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");
|
).toBe("1000.0000 NOVEL");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,15 +78,20 @@ describe("a swap to a token absent from the bundled list", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("names the address when the scale is known but the symbol is not", () => {
|
test("names the tracked symbol alongside the address (issue #323)", () => {
|
||||||
|
// 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(NOVEL_OUT);
|
expect(detail(data(), "Token Out", sources).value).toBe(
|
||||||
|
"NOVEL (" + NOVEL_OUT + ")",
|
||||||
|
);
|
||||||
expect(detail(data(), "Min. received", sources).value).toBe(
|
expect(detail(data(), "Min. received", sources).value).toBe(
|
||||||
"1000.0000",
|
"1000.0000 NOVEL",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,12 +98,13 @@ 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 },
|
||||||
];
|
];
|
||||||
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
|
// The tracked entry names the token as well as scaling it (issue #323).
|
||||||
|
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");
|
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refuses to format when nothing knows the scale", () => {
|
test("refuses to format when nothing knows the scale", () => {
|
||||||
@@ -140,7 +141,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");
|
expect(swapDetail(data, "Min. received").value).toBe("1000.0000 NOVEL");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user