Compare commits

...
2 Commits
Author SHA1 Message Date
clawbot 090a5e785c fix: show balances and fees below 0.000001 as nonzero on the send screens (closes #343)
check / check (push) Successful in 2m28s
e2e / e2e-chrome (push) Successful in 3m15s
e2e / e2e-firefox (push) Successful in 2m38s
The stored ETH and token balances and the send-confirm screen's fee were each
cut to six decimal places, so a value below 0.000001 read as zero. Balances are
now stored exactly; a token declaring more than 18 decimals is cut to 18, the
most the balance check reads. A token holding below 0.000001 is still left off
the lists as dust, except for a token the user tracks, whose holding now
reaches the Send screen and is what the send is checked against. The send and
send-confirm screens' balances, reserve and insufficient-balance messages go
through truncateAmountNeverZero(). The send-confirm and approval screens both
render the fee through formatFee(), which prices the exact fee in USD. The
balance lists still round with toFixed(4).

Model: opus-5-5
2026-10-04 04:45:02 +00:00
clawbot 49a7da87e8 harden: list and end site connections made without Remember in Settings (closes #406)
check / check (push) Successful in 2m25s
e2e / e2e-chrome (push) Successful in 3m15s
e2e / e2e-firefox (push) Successful in 2m21s
A site allowed without "Remember" lives only in the background's
in-memory connectedSites map. Settings never listed it, and
AUTISTMASK_REMOVE_SITE, sent on every remove, did nothing, so the user
could not end such a connection.

Settings now asks the background for those sites and lists them under
Connected Sites. Removing a site from Allowed Sites or Connected Sites
drops its remembered entry under every address and sends
AUTISTMASK_REMOVE_SITE with the hostname; the background deletes every
matching connectedSites entry and sends accountsChanged with an empty
list to the site's tabs. Only the extension's own pages may send either
message.

Model: opus-5-5
2026-10-04 06:41:39 +02:00
14 changed files with 802 additions and 121 deletions
+44 -18
View File
@@ -892,12 +892,22 @@ shows is a V4 exact-in `amountIn` of zero.
The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
confirmation screens display goes through the floored one — the ERC-20 amount,
the ETH value and max fee (`src/popup/views/approval.js`), and the swap's
`Amount` and `Min. received` lines (`src/shared/uniswap.js`). The history and
balance lists (`src/shared/transactions.js`) use the unfloored one: the
transaction detail view is the authoritative record and already shows exact
precision. The 4-decimal rule is unchanged everywhere else, including for
amounts at or above the floor on the approval screens.
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
on the confirmation screen for the wallet's own send
(`src/popup/views/confirmTx.js`). Both screens render a network fee through
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
rather than its truncated figure, so the same fee reads the same on both, USD
value included. Balances are stored exactly (`src/shared/balances.js`), so a
balance below the floor reaches these screens as it is; the one cut is that a
token declaring more than 18 decimals is stored to 18, the most the confirmation
screen's balance check reads. The history list (`src/shared/transactions.js`)
uses the unfloored one: the transaction detail view is the authoritative record
and already shows exact precision. The balance lists use neither: they round to
four places with `toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`).
The 4-decimal rule is unchanged everywhere else, including for amounts at or
above the floor on the approval screens.
The floor applies only where the token's scale is known. Where it is not, the
approval screen states base units instead of a quantity — see Unknown token
@@ -1050,13 +1060,15 @@ Which tokens an address shows is decided by `fetchTokenBalances()` in
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled known-token list, is tracked by the
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
list from any other contract address is always dropped, and so is any token
claiming a symbol that belongs to the native asset and therefore has no
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. Tracked tokens with a zero
balance are listed as well while "Show tracked tokens with zero balance" is on.
user, or has 1,000 or more holders. A holding below 0.000001 of a token the user
does not track is dust and is not shown; a tracked token's holding is shown
whatever its size. A token claiming a symbol from the bundled list from any
other contract address is always dropped, and so is any token claiming a symbol
that belongs to the native asset and therefore has no legitimate contract at all
(`"ETH"`). That filter is unconditional — the "Hide tokens with fewer than 1,000
holders" setting governs the transaction history and the send-screen token
selector, not this list. Tracked tokens with a zero balance are listed as well
while "Show tracked tokens with zero balance" is on.
#### Stored state and its version
@@ -1582,8 +1594,14 @@ view would leave a wallet one click from deletion.
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
is refused with a flash message and the field snaps back to the stored
threshold, so a number the user did not type is never stored.
- Allowed Sites: list with remove buttons
- Denied Sites: list with remove buttons
- Allowed Sites: the hostnames remembered as allowed, under any address,
with remove buttons
- Connected Sites: the hostnames of the sites allowed without "Remember my
choice" that are still connected, with remove buttons. Only the background
holds these, in memory, and Settings asks it for them with
`AUTISTMASK_GET_CONNECTED_SITES`
- Denied Sites: the hostnames remembered as denied, under any address, with
remove buttons
- About: project link, license, author, version, release date, and the
commit, which links to the commit in the repository
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
@@ -1594,8 +1612,15 @@ view would leave a wallet one click from deletion.
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
- `[x]` on a wallet → **DeleteWallet**
- Tap wallet name → inline rename field (no screen change)
- `[x]` on a tracked token or a site → removes it in place (no screen
change)
- `[x]` on a tracked token → removes it in place (no screen change)
- `[x]` on an allowed or connected site → disconnects that site, in place:
its hostname is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
without "Remember" from an origin with that hostname, under any address,
and send `accountsChanged` with an empty list to the site's open tabs.
Only the extension's own pages may send either message
- `[x]` on a denied site → forgets the refusal, in place; it connects
nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change)
- "Back" (or Settings gear again) → previous screen (Home)
@@ -1790,7 +1815,8 @@ view would leave a wallet one click from deletion.
- **Transitions**:
- "Allow" / "Deny" → closes popup (returns result to background script; the
choice is persisted to the allowed or denied list when "Remember" is
checked)
checked, and an "Allow" without it is listed under Connected Sites in
**Settings**)
- Popup closed without answering → treated as a denial
#### TxApproval (`approve-tx`)
+28
View File
@@ -45,6 +45,34 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-04: The Send and confirmation screens no longer show an ETH balance, a
token balance or a network fee below 0.000001 as zero
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances
(`src/shared/balances.js`) and the confirmation screen's fee were each cut to
six decimal places by a rule of their own. Balances are now stored exactly,
except that a token declaring more than 18 decimals is stored to 18, the most
the balance check reads. A token holding below 0.000001 is still not listed,
but only for a token the user does not track, so a tracked token's holding
reaches the Send screen and the send is checked against it. The Send screen's
`Current balance`, and the confirmation screen's balance, fee, reserve and
insufficient-balance messages, go through `truncateAmountNeverZero()` in
`src/shared/amountDisplay.js`, the helper the approval screen already used.
The confirmation and approval screens both render the fee through
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in
USD, so the same fee reads the same on both, USD value included.
- 2026-10-04: Settings lists the sites connected without "Remember", and
removing a site there disconnects it
([#406](https://git.eeqj.de/sneak/AutistMask/issues/406)). Such a connection
lives only in the background's in-memory `connectedSites` map, so Settings
never showed it and the user could not end it; `AUTISTMASK_REMOVE_SITE`, sent
on every remove, did nothing. Settings now asks the background for those sites
(`AUTISTMASK_GET_CONNECTED_SITES`) and lists them under Connected Sites.
Removing a site from Allowed Sites or Connected Sites drops its remembered
entry under every address and sends `AUTISTMASK_REMOVE_SITE` with the
hostname; the background deletes every `connectedSites` entry for that
hostname and sends `accountsChanged` with an empty list to its open tabs. Only
the extension's own pages may send either message. Removing a denied site no
longer sends it, since forgetting a refusal ends no connection.
- 2026-10-04: Removing an address or deleting a wallet ends every site
connection approved without "Remember" for the addresses removed
([#245](https://git.eeqj.de/sneak/AutistMask/issues/245)). Such a connection
+41 -1
View File
@@ -1110,6 +1110,24 @@ async function broadcastAccountsChanged() {
}
}
// Tell every open tab of a site Settings removed that it has no account.
async function broadcastSiteRemoved(hostname) {
let tabs;
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) {
if (!tab.url || extractHostname(tab.url) !== hostname) continue;
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
}).catch(() => {});
}
}
// Background balance refresh: every 60 seconds when the popup isn't open.
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
// fresh, so this naturally skips.
@@ -1306,6 +1324,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
"AUTISTMASK_TX_RESPONSE",
"AUTISTMASK_SIGN_RESPONSE",
"AUTISTMASK_ADDRESSES_REMOVED",
"AUTISTMASK_GET_CONNECTED_SITES",
"AUTISTMASK_REMOVE_SITE",
];
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
@@ -1662,8 +1682,28 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return false;
}
// Settings lists the sites connected without "Remember", which only this
// worker knows. The origin is what precedes the key's last colon.
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse(
Object.keys(connectedSites).map((key) =>
extractHostname(key.slice(0, key.lastIndexOf(":"))),
),
);
return false;
}
// Settings removed this site and has already dropped its remembered
// entries. Its connections approved without "Remember" end here, under
// every address, and its open tabs are told it has no account.
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
// Popup already saved state; nothing else needed
for (const key of Object.keys(connectedSites)) {
const origin = key.slice(0, key.lastIndexOf(":"));
if (extractHostname(origin) === msg.hostname) {
delete connectedSites[key];
}
}
broadcastSiteRemoved(msg.hostname);
return false;
}
});
+9
View File
@@ -1064,6 +1064,15 @@
<div id="settings-allowed-sites"></div>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Connected Sites</h3>
<p class="text-xs text-muted mb-2">
Sites you allowed without "Remember my choice".
Switching address disconnects them.
</p>
<div id="settings-connected-sites"></div>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Denied Sites</h3>
<p class="text-xs text-muted mb-2">
+6 -8
View File
@@ -8,6 +8,7 @@ const {
renderAddressHtml,
attachCopyHandlers,
onViewLeave,
formatFee,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks");
@@ -207,7 +208,7 @@ function showPhishingWarning(elementId, isPhishing) {
// and the nonce. The background compares every one of them against the signed
// artifact, so every one of them has to be on the screen — a number that is
// verified but never displayed is verified against nothing the user agreed to.
function showTxFee(approvedTx, ethPrice) {
function showTxFee(approvedTx) {
const network = networkByChainId(approvedTx.chainId);
$("approve-tx-network").textContent = network
? network.name
@@ -215,12 +216,9 @@ function showTxFee(approvedTx, ethPrice) {
const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
const usdStr = formatUsd(
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
);
$("approve-tx-fee").textContent =
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
// Through formatFee(), as the confirmation screen's fee is, so the same
// fee reads the same on both.
$("approve-tx-fee").textContent = formatFee(gasLimit * feePerGas);
let detail =
gasLimit.toString() +
@@ -332,7 +330,7 @@ function showTxApproval(details) {
$("approve-tx-value").textContent =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx, ethPrice);
showTxFee(approvedTx);
// Decode calldata (reuse decoded from above)
const decodedEl = $("approve-tx-decoded");
+21 -25
View File
@@ -15,6 +15,7 @@ const {
attachCopyHandlers,
goBack,
onViewLeave,
formatFee,
} = require("./helpers");
const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
@@ -31,6 +32,9 @@ const {
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The balance lines, the fee reserve and the insufficient-balance messages go
// through it, as the approval screen's amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
FEE_PENDING,
@@ -150,11 +154,17 @@ function show(txInfo) {
$("confirm-balance").textContent =
bal == null
? "unknown (" + symbol + ")"
: valueWithUsd(bal + " " + symbol, balUsd);
: valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
} else {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " ETH",
balUsd,
);
}
// Check for warnings (synchronous local checks)
@@ -249,7 +259,7 @@ function renderValidation(txInfo) {
: "Insufficient " +
symbol +
" balance. You have " +
txInfo.tokenBalance +
truncateAmountNeverZero(txInfo.tokenBalance) +
" " +
symbol +
" but are trying to send " +
@@ -262,7 +272,7 @@ function renderValidation(txInfo) {
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
txInfo.balance +
truncateAmountNeverZero(txInfo.balance || "0") +
" ETH but are trying to send " +
txInfo.amount +
" ETH.",
@@ -305,14 +315,6 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// A fee in wei as an ETH string, truncated to 6 decimal places.
function formatFeeEth(wei) {
const parts = formatEther(wei).split(".");
const dec =
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
}
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
@@ -359,26 +361,20 @@ async function estimateGas(txInfo) {
// flight; a stale fee must not reach the screen or the balance check.
if (pendingTx !== txInfo) return;
const ethPrice = getPrice("ETH");
const usd = (wei) =>
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
// The fee lines go through formatFee(), as the approval screen's
// does, so the same fee reads the same on both.
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = valueWithUsd(
"~" + formatFeeEth(estimateWei),
usd(estimateWei),
);
$("confirm-fee-amount").textContent = "~" + formatFee(estimateWei);
$("confirm-fee-reserve").textContent =
"up to " + formatFeeEth(gasCostWei) + " reserved";
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" ETH reserved";
setVisible("confirm-fee-reserve", true);
} else {
// No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = valueWithUsd(
formatFeeEth(gasCostWei),
usd(gasCostWei),
);
$("confirm-fee-amount").textContent = formatFee(gasCostWei);
setVisible("confirm-fee-reserve", false);
}
feeStatus = FEE_KNOWN;
+16
View File
@@ -12,6 +12,8 @@
// escapeHtml lives in src/shared/html.js, where the escape and the
// reasoning behind it are; it is re-exported below so views keep importing
// it from here.
const { formatEther } = require("ethers");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
@@ -243,6 +245,19 @@ function unknownableAmount(balance) {
return Number.isFinite(n) ? n : null;
}
// A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero(), then its USD value when the
// ETH price is known. The USD value is of the exact fee, not of the truncated
// figure, so it never understates what the fee costs.
function formatFee(wei) {
const eth = formatEther(wei);
const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return (
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
);
}
// One row of the balance list: symbol, quantity, fiat value.
//
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
@@ -610,6 +625,7 @@ module.exports = {
balanceLinesForAddress,
addressHoldsFunds,
unknownableAmount,
formatFee,
addressColor,
addressDotHtml,
escapeHtml,
+8 -2
View File
@@ -16,6 +16,7 @@ const { resolveTokenDecimals } = require("../../shared/approvalAmount");
const { resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
@@ -150,7 +151,9 @@ function updateSendBalance() {
const token = state.selectedToken || $("send-token").value;
if (token === "ETH") {
$("send-balance").textContent =
"Current balance: " + (addr.balance || "0") + " ETH";
"Current balance: " +
truncateAmountNeverZero(addr.balance || "0") +
" ETH";
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -167,7 +170,10 @@ function updateSendBalance() {
$("send-balance").textContent =
bal == null
? "Current balance: unknown (" + symbol + ")"
: "Current balance: " + bal + " " + symbol;
: "Current balance: " +
truncateAmountNeverZero(bal) +
" " +
symbol;
}
}
+51 -23
View File
@@ -29,46 +29,63 @@ const {
GITEA_COMMIT_URL,
} = require("../../shared/buildInfo");
const { notify } = require("../../shared/browserApi");
const { notify, sendMessage } = require("../../shared/browserApi");
let versionClickCount = 0;
let versionClickTimer = null;
function renderSiteList(containerId, siteMap, stateKey) {
// One row per hostname, however many addresses or origins it appears under,
// each with an [x] that hands it to onRemove.
function renderSiteList(containerId, hostnames, onRemove) {
const container = $(containerId);
const hostnames = [...new Set(Object.values(siteMap).flat())];
if (hostnames.length === 0) {
const unique = [...new Set(hostnames)];
if (unique.length === 0) {
container.innerHTML = '<p class="text-xs text-muted">None</p>';
return;
}
let html = "";
hostnames.forEach((hostname) => {
unique.forEach((hostname) => {
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
// A hostname the URL parser produced cannot carry a delimiter, so
// this is escaped for the rule rather than for a known hole — the
// rule being that nothing reaches innerHTML unescaped.
html += `<span>${escapeHtml(hostname)}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${escapeHtml(stateKey)}" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
btn.addEventListener("click", async () => {
const key = btn.dataset.key;
const host = btn.dataset.hostname;
for (const addr of Object.keys(state[key])) {
state[key][addr] = state[key][addr].filter((h) => h !== host);
if (state[key][addr].length === 0) {
delete state[key][addr];
}
}
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE" });
renderSiteList(containerId, state[key], key);
});
btn.addEventListener("click", () => onRemove(btn.dataset.hostname));
});
}
// Drop a hostname from a remembered site list under every address.
function forgetHostname(siteMap, hostname) {
for (const addr of Object.keys(siteMap)) {
siteMap[addr] = siteMap[addr].filter((h) => h !== hostname);
if (siteMap[addr].length === 0) {
delete siteMap[addr];
}
}
}
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
// no longer allowed under any address, and the background ends its
// connections approved without "Remember" and tells its open tabs.
async function removeAllowedSite(hostname) {
forgetHostname(state.allowedSites, hostname);
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", hostname });
await renderSiteLists();
}
// Removing a denied site only forgets the refusal; it connects nothing.
async function removeDeniedSite(hostname) {
forgetHostname(state.deniedSites, hostname);
await saveState();
await renderSiteLists();
}
function renderTrackedTokens() {
const container = $("settings-tracked-tokens");
if (state.trackedTokens.length === 0) {
@@ -202,13 +219,24 @@ function show() {
showView("settings");
}
function renderSiteLists() {
async function renderSiteLists() {
renderSiteList(
"settings-allowed-sites",
state.allowedSites,
"allowedSites",
Object.values(state.allowedSites).flat(),
removeAllowedSite,
);
renderSiteList(
"settings-denied-sites",
Object.values(state.deniedSites).flat(),
removeDeniedSite,
);
// Sites allowed without "Remember" are held only by the background, in
// memory, so it is asked for them.
renderSiteList(
"settings-connected-sites",
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
removeAllowedSite,
);
renderSiteList("settings-denied-sites", state.deniedSites, "deniedSites");
}
function init(ctx) {
+21 -14
View File
@@ -17,6 +17,7 @@ const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount");
const { resolveTokenDecimals } = require("./approvalAmount");
const { SCALE_DECIMALS } = require("./txValidation");
// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
@@ -52,19 +53,14 @@ function requireNetworkId(networkId) {
return net;
}
function formatBalance(wei) {
const eth = formatEther(wei);
const parts = eth.split(".");
if (parts.length === 1) return eth + ".0";
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
return parts[0] + "." + dec;
}
// A token balance as a decimal string, exact except for a token that declares
// more than 18 decimals: that one is cut to 18 places, the most the balance
// check on the confirmation screen reads (SCALE_DECIMALS in txValidation.js).
function formatTokenBalance(raw, decimals) {
const val = formatUnits(raw, decimals);
const parts = val.split(".");
if (parts.length === 1) return val + ".0";
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
const dec = parts[1].slice(0, SCALE_DECIMALS).replace(/0+$/, "") || "0";
return parts[0] + "." + dec;
}
@@ -149,11 +145,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
const scale = known !== null ? known : decimals;
// null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one.
// With a scale, the display filter proper applies: a balance that
// rounds to zero at six places is dust and is not listed. Without
// one there is no such judgement to make, and the row is kept.
const bal = scale === null ? null : formatTokenBalance(raw, scale);
if (bal === "0.0") continue;
// null means the explorer reported no count, which is not the
// same as a count of zero. This gate is not the low-holder
// display filter: it has no user-facing off switch and governs
@@ -172,6 +164,19 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
// Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
// Skip dust: a holding below 0.000001 of a token the user does not
// track. A tracked token keeps it, so the Send and confirmation
// screens show that holding and check the send against it rather
// than against zero. Without a scale there is no such judgement to
// make, and the row is kept.
if (
!isTracked &&
scale !== null &&
raw * 1000000n < 10n ** BigInt(scale)
) {
continue;
}
// Skip tokens spoofing a known symbol from a different address.
// Every row here is an ERC-20 the explorer reported, so it has a
// contract address; the native ETH balance is fetched over RPC in
@@ -221,7 +226,9 @@ async function refreshBalances(
provider
.getBalance(addr.address)
.then((bal) => {
addr.balance = formatBalance(bal);
// Exact, never cut: a cut here stores a small nonzero
// balance as zero.
addr.balance = formatEther(bal);
log.debugf("ETH balance", addr.address, addr.balance);
})
.catch((e) => {
+175 -10
View File
@@ -2101,6 +2101,16 @@ describe("a site connection decided as the popup closes", () => {
});
});
// What a site is told when it asks which account it may use.
async function siteAccounts(bg, origin) {
const { sendResponse } = bg.send(
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
{ origin: origin || FRESH_ORIGIN },
);
await settle();
return sendResponse.mock.calls[0][0];
}
// A site connected without "Remember" is held only in the background's memory,
// keyed to the address it was connected to. Removing that address, or the
// wallet holding it, must end the connection as part of the removal itself.
@@ -2136,16 +2146,6 @@ describe("removing an address ends a site's connection to it", () => {
return bg;
}
// What FRESH_ORIGIN is told when it asks which account it may use.
async function siteAccounts(bg) {
const { sendResponse } = bg.send(
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
{ origin: FRESH_ORIGIN },
);
await settle();
return sendResponse.mock.calls[0][0];
}
test("removing the connected address ends the connection", async () => {
const bg = await connectedBackground();
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
@@ -2192,3 +2192,168 @@ describe("removing an address ends a site's connection to it", () => {
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
});
});
// Settings lists the sites allowed without "Remember", which only the
// background holds, and removing a site there, from either list, disconnects
// it. These drive the real Settings view against the real background and
// click the [x] the user clicks.
describe("removing a site in Settings disconnects it", () => {
// FRESH_ORIGIN on another port, so its hostname is FRESH_ORIGIN's.
const FRESH_OTHER_PORT = "https://fresh.example:8443";
// A site list's container. Its [x] buttons, data attributes and all, are
// read back out of the rows the view wrote into it, so clicking one runs
// the handler the view attached to it.
function fakeSiteList() {
const list = {
innerHTML: "",
buttons: [],
querySelectorAll() {
const tags = list.innerHTML.match(/<button[^>]*>/g) || [];
list.buttons = tags.map((tag) => ({
dataset: Object.fromEntries(
[...tag.matchAll(/data-(\w+)="([^"]*)"/g)].map(
(match) => [match[1], match[2]],
),
),
addEventListener(event, handler) {
this[event] = handler;
},
}));
return list.buttons;
},
};
return list;
}
// The hostnames a site list shows.
function listed(list) {
return [...list.innerHTML.matchAll(/data-hostname="([^"]*)"/g)].map(
(match) => match[1],
);
}
// A site connected the way the user does it, in the approval popup.
async function connect(bg, origin, remember) {
const pending = bg.requestSite(origin);
await settle();
bg.connectApproval(pending.id()).decide(true, remember);
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
}
// Settings, opened over the background's storage and wired to it the way
// the popup is: what Settings sends reaches the background from the
// extension's own page, and the answer comes back.
async function openSettings(bg) {
const lists = {};
const element = (id) => (lists[id] ||= fakeSiteList());
global.document = { getElementById: element };
global.chrome.runtime.sendMessage = (msg, callback) => {
const { sendResponse } = bg.send(msg, bg.fromPopup);
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
};
await require("../src/shared/state").loadState();
await require("../src/popup/views/settings").renderSiteLists();
return {
allowed: element("settings-allowed-sites"),
connected: element("settings-connected-sites"),
// Click the [x] beside a site, and let what it sends run.
remove: async (list, hostname) => {
expect(listed(list)).toContain(hostname);
const button = list.buttons.find(
(b) => b.dataset.hostname === hostname,
);
await button.click();
await settle();
},
};
}
afterEach(() => {
delete global.document;
});
test("Settings lists a site connected without Remember", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
const settings = await openSettings(bg);
expect(listed(settings.connected)).toEqual(["fresh.example"]);
expect(listed(settings.allowed)).toEqual([HOSTNAME]);
});
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
const sentToTabs = [];
global.chrome.tabs = {
query: (q, cb) =>
cb([
{ id: 1, url: FRESH_ORIGIN + "/app" },
{ id: 2, url: ORIGIN + "/app" },
]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push({ tabId, msg });
cb();
},
};
const settings = await openSettings(bg);
await settings.remove(settings.connected, "fresh.example");
expect(await siteAccounts(bg)).toEqual({ result: [] });
expect(sentToTabs).toEqual([
{
tabId: 1,
msg: {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
},
]);
expect(listed(settings.connected)).toEqual([]);
// The other site is untouched.
expect(await siteAccounts(bg, ORIGIN)).toEqual({
result: [signer.address],
});
});
// The same hostname can hold both kinds of connection: one origin allowed
// without Remember, then another, on a different port, allowed with it.
test("removing a remembered site also ends its connection made without Remember", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
await connect(bg, FRESH_OTHER_PORT, true);
const settings = await openSettings(bg);
await settings.remove(settings.allowed, "fresh.example");
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
result: [],
});
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
});
test("a page can neither remove a site nor list the connected ones", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
const page = { url: FRESH_ORIGIN + "/index.html" };
const remove = bg.send(
{ type: "AUTISTMASK_REMOVE_SITE", hostname: "fresh.example" },
page,
);
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
expect(remove.sendResponse).toHaveBeenCalledWith({
error: "Unauthorized sender",
});
expect(list.sendResponse).toHaveBeenCalledWith({
error: "Unauthorized sender",
});
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
});
});
+16 -17
View File
@@ -1414,9 +1414,10 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
// on opposite sides of the reserve while sitting on the same side of the
// estimate.
// The balance the funded fixture serves, and the amounts sent against it.
// The balance the funded fixture serves, as the Send and confirmation screens
// show it, and the amounts sent against it.
const FUNDED_ETH_WEI = 10n ** 18n;
const FUNDED_ETH_TEXT = "1.0";
const FUNDED_ETH_TEXT = "1.0000";
const COMFORTABLE_AMOUNT = "0.1";
const OVER_BALANCE_AMOUNT = "2.0";
@@ -1430,7 +1431,7 @@ const GAP_AMOUNT = formatEther(FUNDED_ETH_WEI - FEE_ESTIMATE_WEI);
// fee test: it covers the expected cost to the wei and falls short of the
// reserve, so the same swap flips this assertion too — through a different
// balance and a different message than the ETH path uses.
const TOKEN_BALANCE_TEXT = "1.5";
const TOKEN_BALANCE_TEXT = "1.5000";
const TOKEN_AMOUNT = "0.25";
const OVER_TOKEN_AMOUNT = "9.0";
const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI;
@@ -1439,16 +1440,15 @@ function toHexWei(wei) {
return "0x" + wei.toString(16);
}
// A fee in wei as the confirmation screen writes it. Deliberately a second
// implementation of formatFeeEth() from src/popup/views/confirmTx.js rather
// than an import of it: that module pulls in the whole popup and cannot be
// required outside a browser, and asserting against an independent rendering
// is stronger than asserting a function equals itself.
// A fee in wei as the confirmation screen writes it: truncated to four decimal
// places (README.md, Display Consistency). Deliberately a second
// implementation rather than an import of src/shared/amountDisplay.js:
// asserting against an independent rendering is stronger than asserting a
// function equals itself. The fixture's fees are above 0.0001 ETH, so the
// nonzero floor never applies here.
function feeEth(wei) {
const parts = formatEther(wei).split(".");
const dec =
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
const [whole, frac = ""] = formatEther(wei).split(".");
return whole + "." + (frac + "0000").slice(0, 4) + " ETH";
}
// What the confirmation screen is showing right now, read out of the DOM in
@@ -1505,11 +1505,10 @@ async function backToAddress(page) {
// Drive the popup to the confirmation screen for one send.
//
// It waits for the send screen to be showing `balance` before filling
// anything in. That figure is the exact number the spend gate compares
// against, so waiting for it — rather than for a refresh to have probably
// landed — is what keeps every assertion below deterministic after a
// fixture change.
// It waits for the send screen to be showing `balance`, the fixture's balance
// as that screen displays it, before filling anything in. Waiting for it —
// rather than for a refresh to have probably landed — is what keeps every
// assertion below deterministic after a fixture change.
async function goToConfirm(page, { token, balance, amount }) {
await backToAddress(page);
await page.click("#btn-send");
+363
View File
@@ -0,0 +1,363 @@
// The balance and fee lines of the Send and confirmation screens, and the fee
// line they must share with the approval screen.
//
// An ETH balance, a token balance or a fee below 0.000001 rendered as zero on
// these screens (https://git.eeqj.de/sneak/AutistMask/issues/343): the stored
// balances and the fee were each cut to six decimal places, a rule of their
// own, while the approval screen showed the same fee through
// src/shared/amountDisplay.js with the nonzero floor. The balances are now
// stored exactly, and the screens show them and the fee through that helper.
//
// Driven through the real refreshBalances(), Send screen, confirmation screen
// and approval screen, with only the node, the explorer and the DOM stubbed: a
// balance written onto state by hand would skip the place the cut happened.
"use strict";
// What the stub node answers. Each test sets what it needs.
const mockNode = {
balanceWei: 0n,
feeData: { maxFeePerGas: 1n, gasPrice: 1n },
};
// The token rows the stub explorer reports for the address.
const mockExplorer = { items: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async getBalance() {
return mockNode.balanceWei;
}
async lookupAddress() {
return null;
}
async getFeeData() {
return mockNode.feeData;
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The explorer's token list, which refreshBalances() also fetches.
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => mockExplorer.items,
})),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The confirmation screen's Etherscan label lookup is the only fetch() these
// screens make; it fails, as it does offline.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// The approval the background hands the approval screen. Set per test.
let approvalDetails = null;
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub(),
runtime: {
connect: () => ({
postMessage() {},
disconnect() {},
onDisconnect: { addListener() {} },
}),
sendMessage(message, callback) {
callback(
message.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: undefined,
);
},
},
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { refreshBalances } = require("../src/shared/balances");
const { state } = require("../src/shared/state");
const { prices, clearPrices } = require("../src/shared/prices");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval");
const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// 0.0000005 ETH, or 0.0000005 of an 18-decimal token.
const HALF_MICRO_ETH = 500000000000n;
// A token the bundled list does not know.
const TOKEN = "0x" + "d".repeat(40);
// The explorer's row for TOKEN, holding `value` base units. With only five
// holders, it is listed only when the user tracks the token.
function tokenRow(value, token = {}) {
return {
value: String(value),
token: {
type: "ERC-20",
address_hash: TOKEN,
symbol: "TOK",
name: "Token",
decimals: "18",
holders_count: "5",
...token,
},
};
}
function text(id) {
return global.document.getElementById(id).textContent;
}
function errors() {
return global.document.getElementById("confirm-errors").innerHTML;
}
// The ETH balance the node reports and the token rows the explorer reports,
// fetched and stored exactly where the popup stores them.
async function refreshWith(balanceWei, tokenItems = []) {
mockNode.balanceWei = balanceWei;
mockExplorer.items = tokenItems;
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0;
state.selectedAddress = 0;
await refreshBalances(
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
state.trackedTokens,
"mainnet",
);
}
// Press Review on the Send screen for a send of `token` ("ETH" or a token
// address), and show the confirmation screen it leads to with its fee estimate
// settled.
async function confirmSend(amount, token = "ETH") {
let txInfo = null;
send.init({ showConfirmTx: (info) => (txInfo = info) });
state.selectedToken = token;
global.document.getElementById("send-to").value = RECIPIENT;
global.document.getElementById("send-amount").value = amount;
await global.document
.getElementById("btn-send-review")
.handlers.get("click")();
confirmTx.show(txInfo);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// The approval screen for a dApp transaction of 21000 gas, the gas the stub
// node estimates for the send above.
async function approveTxWithFeePerGas(maxFeePerGas) {
approvalDetails = {
type: "tx",
hostname: "dapp.example",
approvedFrom: HOLDER,
approvedTx: {
to: RECIPIENT,
value: "0",
data: "0x",
chainId: "0x1",
gasLimit: "21000",
maxFeePerGas: String(maxFeePerGas),
nonce: 0,
},
};
await approval.show("1");
}
beforeEach(() => {
elements.clear();
state.selectedToken = null;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n };
});
describe("an ETH balance below 0.000001 never renders as zero", () => {
test("on the Send screen", async () => {
await refreshWith(HALF_MICRO_ETH);
state.selectedToken = "ETH";
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000005 ETH");
});
test("on the confirmation screen", async () => {
await refreshWith(HALF_MICRO_ETH);
await confirmSend("0.0000001");
expect(text("confirm-balance")).toBe("0.0000005 ETH");
});
test("while a balance above the floor keeps four decimals", async () => {
await refreshWith(1234567890000000000n);
await confirmSend("0.1");
expect(text("confirm-balance")).toBe("1.2345 ETH");
});
});
// A token the user tracks stays on the balance list when the explorer's row is
// dropped, so a holding of it below 0.000001 reached these screens as zero, and
// the send was checked against zero.
describe("a tracked token holding below 0.000001 never renders as zero", () => {
beforeEach(() => {
state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
];
});
test("on the Send screen", async () => {
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
state.selectedToken = TOKEN;
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK");
});
test("on the confirmation screen, which checks the send against it", async () => {
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
await confirmSend("0.0000006", TOKEN);
expect(errors()).toContain(
"You have 0.0000005 TOK but are trying to send 0.0000006 TOK.",
);
});
// Past 18 places the stored balance is cut: the balance check reads 18,
// and refuses a balance string longer than that as no balance at all.
test("with more than 18 decimals, the send is checked against 18 of them", async () => {
state.trackedTokens[0].decimals = 24;
// 1.5 plus one base unit.
const value = 15n * 10n ** 23n + 1n;
await refreshWith(10n ** 18n, [tokenRow(value, { decimals: "24" })]);
await confirmSend("1.5", TOKEN);
expect(text("confirm-balance")).toBe("1.5000 TOK");
expect(errors()).toBe("");
});
test("while an untracked holding below 0.000001 is still not listed", async () => {
state.trackedTokens = [];
await refreshWith(10n ** 18n, [
tokenRow(HALF_MICRO_ETH, { holders_count: "50000" }),
]);
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
});
});
describe("a fee below 0.000001 ETH never renders as zero", () => {
test("when the estimate and the reserve are the same", async () => {
await refreshWith(10n ** 18n);
await confirmSend("0.1");
// 21000 gas at 1 wei is 0.000000000000021 ETH, shown to its first
// significant digit.
expect(text("confirm-fee-amount")).toBe("0.00000000000002 ETH");
});
test("when they differ, on both lines", async () => {
mockNode.feeData = { maxFeePerGas: 2n, gasPrice: 1n };
await refreshWith(10n ** 18n);
await confirmSend("0.1");
expect(text("confirm-fee-amount")).toBe("~0.00000000000002 ETH");
expect(text("confirm-fee-reserve")).toBe(
"up to 0.00000000000004 ETH reserved",
);
});
});
// The confirmation screen shows the reserve alone when the node quotes no
// cheaper estimate, and that reserve is the same gas limit times maximum fee
// per gas that the approval screen calls the max fee. An ETH price is set, as
// it is on mainnet, so the USD value has to match too.
describe("the same fee reads the same on the confirmation and approval screens", () => {
beforeEach(() => {
prices.ETH = 3000;
});
afterEach(() => {
clearPrices();
});
test.each([
// 21000 gas at 1 wei.
["below the floor", 1n, "0.00000000000002 ETH (< $0.01)"],
// 0.001235294117631 ETH, which is $3.71. Pricing the truncated
// 0.0012 instead would read $3.60.
["with more than four decimals", 58823529411n, "0.0012 ETH ($3.71)"],
])("%s", async (_label, feePerGas, expected) => {
mockNode.feeData = { maxFeePerGas: feePerGas, gasPrice: feePerGas };
await refreshWith(10n ** 18n);
await confirmSend("0.1");
expect(text("confirm-fee-amount")).toBe(expected);
await approveTxWithFeePerGas(feePerGas);
expect(text("approve-tx-fee")).toBe(expected);
});
});
+3 -3
View File
@@ -389,7 +389,7 @@ describe("a scale the explorer's own rows disagree about", () => {
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("5.0 NOVEL");
expect(text("confirm-balance")).toBe("5.0000 NOVEL");
expect(errors()).toBe("");
expect(sendDisabled()).toBe(false);
});
@@ -431,7 +431,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
const zero = await render("0.0");
expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0 NOVEL");
expect(zero.balance).toBe("0.0000 NOVEL");
});
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
@@ -443,7 +443,7 @@ describe("the confirmation screen tells an unknown balance from a zero one", ()
expect(unknown.errors).not.toBe(zero.errors);
expect(unknown.errors).toContain("This token&#39;s balance is unknown");
expect(unknown.errors).not.toContain("You have");
expect(zero.errors).toContain("You have 0.0 NOVEL");
expect(zero.errors).toContain("You have 0.0000 NOVEL");
expect(zero.errors).not.toContain("balance is unknown");
});
});