Author SHA1 Message Date
sneak fb7c75101f fix: the balance lists and Home cut amounts to four decimals (closes #757)
check / check (push) Successful in 6m58s
e2e / e2e-chrome (push) Successful in 15m18s
e2e / e2e-firefox (push) Successful in 2m34s
The balance lists and Home's ETH line rounded with toFixed(4), so 0.99996
ETH read 1.0000 there and 0.9999 on Send. They now cut the stored decimal
string with truncateAmount(), as the history rows do. balanceLine() takes
the stored balance string instead of a parsed number, and escapes the
quantity cell. The screens that state what is authorized keep the floor
of truncateAmountNeverZero().

Model: opus-5-5
2026-11-10 21:54:45 +00:00
24 changed files with 2099 additions and 358 deletions

No files matched your search

+16 -22
View File
@@ -563,10 +563,9 @@ of a wallet is a block:
"Recent Transactions" lists up to 25 transactions across every address of every
wallet, deduplicated by hash and filtered (see Scam protection). Each row has
four lines:
three lines:
- the ISO datetime and the relative age;
- the direction, on the right;
- the age and the direction;
- the counterparty's colour dot and label, with the amount on the right;
- the counterparty's full address, as plain text.
@@ -1186,28 +1185,21 @@ popup closes.
These helpers write one kind of value on every screen that shows it:
- `formatUsd()` in `src/shared/prices.js`: USD values;
- `transactionTimeHtml()` in `src/popup/views/helpers.js`: transaction times;
- `isoDate()` and `timeAgo()` in `src/popup/views/helpers.js`: transaction
times;
- `renderAddressHtml()` in the same file: most addresses.
Most screens show token and ETH amounts with four decimal places. History rows
cut amounts to four decimals and never round up (`truncateAmount()` in
`src/shared/amountDisplay.js`). These balances are cut the same way:
- the balance lists;
- Home's ETH balance;
- Send's current balance;
- ConfirmTx's balance line.
So a balance below 0.0001 reads `0.0000` there.
The screens that state what is being authorized cut as the history does, and
extend an amount below 0.0001 to its first nonzero digit
`src/shared/amountDisplay.js`). The balance lists and Home's ETH balance cut
them the same way. The screens that state what is being authorized cut as the
history does, and extend an amount below 0.0001 to its first nonzero digit
(`truncateAmountNeverZero()`). So a nonzero amount never reads as zero there.
Those screens are:
- TxApproval;
- SignApproval's Permit warning;
- ConfirmTx's amount and network fee;
- ConfirmTx, and Send's current balance;
- WaitTx, SuccessTx and ErrorTx.
TransactionDetail shows the exact amount and its base units.
@@ -1274,8 +1266,9 @@ A native amount is labelled with the native currency of the transaction's chain:
`src/shared/networks.js`). The balance lists and Send use the active network's.
The decoded swap lines call the native currency `ETH` on both networks.
TransactionDetail and every history row show the time as an ISO datetime and
then a relative age. With "UTC Timestamps" on, the datetime is in UTC.
TransactionDetail shows the time as an ISO datetime and a relative age. A
history row shows the age, with the datetime as its tooltip. With "UTC
Timestamps" on, the datetime is in UTC.
Every nonzero holding is stored exactly. A token holding below 0.000001 is left
out of:
@@ -1654,10 +1647,11 @@ The development dependencies ship nowhere:
### Crypto policy
In the extension, hashing, key derivation, signing and encryption go through
`ethers` or `libsodium-wrappers-sumo`. A new recovery phrase takes its random
bytes from `ethers`. `build.js` and `script/lib/package.js` also hash files with
`ethers`. No primitive is implemented in this repository.
Hashing, key derivation, signing and encryption go through `ethers` or
`libsodium-wrappers-sumo`. No primitive is implemented in this repository. A new
recovery phrase draws its random bytes from the browser's
`crypto.getRandomValues()`. The build tooling, `build.js` and `script/lib/`,
hashes files with Node's `crypto`; none of it ships in the extension.
### Content Security Policy
+1 -2
View File
@@ -99,8 +99,7 @@ contradicts either, the originals govern.
- [ ] Public data (xpubs, addresses, balances) stored unencrypted
- [ ] Private data (recovery phrases, private keys) encrypted at rest
- [ ] Password required for signing, deleting a wallet, and showing a recovery
phrase or private key
- [ ] Password only required for signing operations
- [ ] No secrets in `.env`, credentials, or API keys committed to repo
- [ ] `git add -A` / `git add .` never used
+1938 -6
View File
File diff suppressed because it is too large. Load diff
+5 -3
View File
@@ -1,8 +1,8 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process");
const esbuild = require("esbuild");
const { sha256 } = require("ethers");
const { resolveVersion } = require("./script/lib/version");
const {
BACKGROUND_ENTRY_PREFIX,
@@ -314,8 +314,10 @@ function copyIcons(distDir) {
}
function sha256File(absPath) {
// ethers prefixes the hex with 0x; the receipt holds bare hex.
return sha256(fs.readFileSync(absPath)).slice(2);
return crypto
.createHash("sha256")
.update(fs.readFileSync(absPath))
.digest("hex");
}
// Write the receipt for the files this build emitted. Deliberately records no
+3 -4
View File
@@ -415,10 +415,9 @@ the setting is off, each history list shows one line saying so.
Each row shows:
- the time, as a date and time and as an age;
- the direction, "Sent" or "Received", on the line below. A transaction can also
ask a contract to do something; that is a contract call. A row for a contract
call shows the call's name instead, such as "Approve" or "Swap";
- the age and the direction, "Sent" or "Received". A transaction can also ask a
contract to do something; that is a contract call. A row for a contract call
shows the call's name instead, such as "Approve" or "Swap";
- the amount. A contract call row shows an amount only when the call moved a
token. A swap row shows the amount received, or the amount paid in when no
token came back. A row with no amount shows only a symbol, such as `USDC` or
+2 -7
View File
@@ -135,14 +135,9 @@ module.exports = [
},
},
// Unit tests, the helpers they require, and the test files they hand to
// jest: jest on node.
// Unit tests, and the helpers they require: jest on node.
{
files: [
"tests/**/*.test.js",
"tests/support/**/*.js",
"tests/fixtures/**/*.js",
],
files: ["tests/**/*.test.js", "tests/support/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node, ...globals.jest },
+1 -2
View File
@@ -16,8 +16,7 @@
"jest": {
"setupFiles": [
"<rootDir>/tests/support/navigatorLocks.js"
],
"workerIdleMemoryLimit": "200MB"
]
},
"devDependencies": {
"@eslint/js": "10.0.1",
+3 -9
View File
@@ -25,9 +25,9 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const ethers = require("ethers");
const { readZip, writeZip } = require("./zip");
const { resolveVersion } = require("./version");
@@ -60,9 +60,8 @@ function fail(message) {
throw new Error(message);
}
// Lowercase hex without the 0x prefix ethers adds, as sha256sum prints it.
function sha256(buf) {
return ethers.sha256(buf).slice(2);
return crypto.createHash("sha256").update(buf).digest("hex");
}
// Every regular file under dir, as archive-root-relative forward-slashed
@@ -292,9 +291,4 @@ if (require.main === module) {
}
}
module.exports = {
checkSelfContained,
htmlReferences,
manifestReferences,
sha256,
};
module.exports = { checkSelfContained, htmlReferences, manifestReferences };
+5 -5
View File
@@ -12,7 +12,8 @@ const {
attachCopyHandlers,
goBack,
pushCurrentView,
transactionTimeHtml,
isoDate,
timeAgo,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
@@ -235,11 +236,10 @@ function renderTransactions(txs) {
const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
// The time has a line of its own, so a long time or direction label
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`;
i++;
+5 -5
View File
@@ -18,7 +18,8 @@ const {
attachCopyHandlers,
goBack,
pushCurrentView,
transactionTimeHtml,
isoDate,
timeAgo,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
@@ -328,11 +329,10 @@ function renderTransactions(txs) {
const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
// The time has a line of its own, so a long time or direction label
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`;
i++;
+4 -7
View File
@@ -43,12 +43,9 @@ const {
transferAmountUnits,
} = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The amount and the fee reserve keep the nonzero floor, as the approval
// screen's amounts do. The balance line is cut as the balance lists cut it.
const {
truncateAmount,
truncateAmountNeverZero,
} = require("../../shared/amountDisplay");
// The amount, the balance line and the fee reserve go through it, as the
// approval screen's amounts do.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
const {
CODES,
FEE_PENDING,
@@ -163,7 +160,7 @@ function renderBalance(txInfo) {
const price = getPrice(txInfo.token, txInfo.chainId);
const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmount(bal) + " " + symbol,
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
+7 -14
View File
@@ -378,8 +378,8 @@ function formatFee(wei, symbol, chainId) {
// whose scale nothing knows
// (https://git.eeqj.de/sneak/AutistMask/issues/349), and for a balance no
// refresh has read. There is no quantity to print for it and no fiat value to
// derive from one. A holding whose amount is unknown is shown as
// `quantity unknown` rather than as a zero.
// derive from one, and printing 0.0000 for a real holding is the failure this
// whole rule exists to prevent, so the row says so instead.
function balanceLine(symbol, balance, price, tokenId) {
const amount = unknownableAmount(balance);
const qty = amount === null ? "quantity unknown" : truncateAmount(balance);
@@ -615,8 +615,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen });
}
// The ISO datetime of a transaction's time, in UTC when the UTC Timestamps
// setting is on. Views show it through transactionTimeHtml().
// A transaction's time as every screen shows it (README, Amounts): the ISO
// datetime, in UTC when the UTC Timestamps setting is on, and the relative age.
// Views import these two; they keep no copies.
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
@@ -673,15 +674,6 @@ function timeAgo(timestamp) {
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
// A transaction's time as every screen shows it (README, Amounts): the ISO
// datetime, then the relative age in parentheses. With copyable set, the
// datetime is click-to-copy, and the caller attaches the copy handlers.
function transactionTimeHtml(timestamp, { copyable = false } = {}) {
const iso = isoDate(timestamp);
const isoHtml = copyable ? copyableHtml(iso) : escapeHtml(iso);
return isoHtml + " (" + escapeHtml(timeAgo(timestamp)) + ")";
}
// Shared external-link icon SVG used across all views.
const EXT_ICON =
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
@@ -877,5 +869,6 @@ module.exports = {
reviewedNetworkProvider,
EXT_ICON,
truncateMiddle,
transactionTimeHtml,
isoDate,
timeAgo,
};
+5 -5
View File
@@ -5,7 +5,8 @@ const {
balanceLinesForAddress,
unknownableAmount,
markTrackedTokensNotFetched,
transactionTimeHtml,
isoDate,
timeAgo,
addressDotHtml,
txCounterpartyHtml,
HISTORY_OFF_TEXT,
@@ -149,11 +150,10 @@ function renderHomeTxList(ctx) {
const titleStr = title ? escapeHtml(title) : "";
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
// The time has a line of its own, so a long time or direction label
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `</div>`;
i++;
+8 -5
View File
@@ -24,7 +24,7 @@ const { resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const {
truncateAmount,
truncateAmountNeverZero,
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const {
@@ -239,7 +239,7 @@ function updateSendBalance() {
addr.balance == null
? "Current balance: unknown (" + nativeCurrency() + ")"
: "Current balance: " +
truncateAmount(addr.balance) +
truncateAmountNeverZero(addr.balance) +
" " +
nativeCurrency();
} else {
@@ -258,15 +258,18 @@ function updateSendBalance() {
$("send-balance").textContent =
bal == null
? "Current balance: unknown (" + symbol + ")"
: "Current balance: " + truncateAmount(bal) + " " + symbol;
: "Current balance: " +
truncateAmountNeverZero(bal) +
" " +
symbol;
}
}
// Fill the amount field with the most the selected holding can send: a
// token's whole balance (cut to 18 decimal places), or for ETH the exact
// balance minus the fee reserve the confirmation screen checks against, never
// the balance the screen shows, which is cut to four decimals. Where there is
// nothing to fill in, a flash message says why.
// the rounded balance the screen shows. Where there is nothing to fill in, a
// flash message says why.
async function fillMaxAmount() {
const addr = currentAddress();
if (!addr) return;
+5 -4
View File
@@ -10,7 +10,8 @@ const {
CONTRACT_CREATION_TEXT,
addressDotHtml,
escapeHtml,
transactionTimeHtml,
isoDate,
timeAgo,
renderAddressHtml,
blockieHtml,
attachCopyHandlers,
@@ -203,9 +204,9 @@ function render() {
);
loadFullTxDetails(tx.hash, tx.chainId);
$("tx-detail-time").innerHTML = transactionTimeHtml(tx.timestamp, {
copyable: true,
});
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction");
attachCopyHandlers("view-transaction");
+5 -2
View File
@@ -1,7 +1,7 @@
// Wallet operations: mnemonic generation, HD derivation, signing.
// All crypto delegated to ethers.js.
const { Mnemonic, HDNodeWallet, Wallet, randomBytes } = require("ethers");
const { Mnemonic, HDNodeWallet, Wallet } = require("ethers");
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
function generateMnemonic() {
@@ -10,7 +10,10 @@ function generateMnemonic() {
// settings toggle drives, which would let a user of a release build turn
// the hardcoded, publicly known test phrase back on for real wallets.
if (DEBUG) return DEBUG_MNEMONIC;
return Mnemonic.fromEntropy(randomBytes(16)).phrase;
const m = Mnemonic.fromEntropy(
globalThis.crypto.getRandomValues(new Uint8Array(16)),
);
return m.phrase;
}
// Every extended key (xprv or xpub) entering the app goes through this.
+3 -17
View File
@@ -1,6 +1,6 @@
// The balance lists, Home's ETH line and Send's current balance show a balance
// as the history rows do (README.md, Amounts): cut to four decimal places,
// never rounded up (https://git.eeqj.de/sneak/AutistMask/issues/757).
// The balance lists and Home's ETH line show a balance as the history rows do
// (README.md, Amounts): cut to four decimal places, never rounded up
// (https://git.eeqj.de/sneak/AutistMask/issues/757).
//
// Driven through the real popup over a stored profile on Ethereum Mainnet.
@@ -75,17 +75,3 @@ describe("Home", () => {
expect(quantity(homeRow(page, USDC.toLowerCase()))).toBe("0.0000");
});
});
describe("Send", () => {
test("the current balance reads 0.00004 USDC as 0.0000", async () => {
const page = await bootHolding("0.99996", "0.00004");
await page.click("btn-main-send");
// USDC chosen in the dropdown, as the user does.
const dropdown = page.node("send-token");
dropdown.value = USDC;
for (const fn of dropdown.listeners.change) await fn();
expect(page.visibleViews()).toEqual(["send"]);
expect(page.text("send-balance")).toBe("Current balance: 0.0000 USDC");
});
});
+3 -3
View File
@@ -165,9 +165,9 @@ describe("the balance warning on the removal confirmation", () => {
});
// The sentence must not assert an amount, because any amount it could
// assert has been cut to four decimals: "This address holds 0.0000 ETH." is
// what the cut form produces for an address that holds real money.
test("the warning sentence asserts no cut amount", () => {
// assert has been rounded: "This address holds 0.0000 ETH." is what the
// rounded form produces for an address that holds real money.
test("the warning sentence asserts no rounded amount", () => {
for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) {
expect(balanceWarningHtml(addr)).not.toMatch(
/holds [\d.]+ (ETH|USDC)/,
-65
View File
@@ -951,71 +951,6 @@ test("tap-to-copy on the transaction detail screen copies the address (#151)", a
);
});
// ------------------------------------------ history row time (#760)
// A Home history row shows the ISO datetime and then the relative age, on a
// line of its own. Measured at the popup's size: the time line is one line
// high, and no line of a row is wider than the row or the popup.
test("a Home history row shows the datetime and the age, one line high, without overflow (#760)", async (env) => {
await goHome(env.page);
env.routeOpts.seedNativeTransfer = true;
const page = await openPopup(env.ctx, env.popupUrl);
try {
await page.setViewportSize(POPUP_VIEWPORT);
await visible(page, "#view-main");
await page.waitForFunction(
() =>
document.querySelectorAll("#home-tx-list .home-tx-row")
.length === 2,
null,
{ timeout: 30000 },
);
const rows = await page.evaluate(() => {
const popupRight = document.body.getBoundingClientRect().right;
return [
...document.querySelectorAll("#home-tx-list .home-tx-row"),
].map((row) => {
const time = row.firstElementChild;
return {
time: time.textContent,
timeHeight: time.getBoundingClientRect().height,
lineHeight: parseFloat(getComputedStyle(time).lineHeight),
overflowingLines: [...row.children].filter(
(line) =>
line.scrollWidth > line.clientWidth ||
line.getBoundingClientRect().right > popupRight,
).length,
};
});
});
console.log("# Home history rows: " + JSON.stringify(rows));
for (const row of rows) {
assert(
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(Z|[+-]\d\d:\d\d) \(\d+ \w+ ago\)$/.test(
row.time,
),
"the time line is not the ISO datetime and then the age: " +
JSON.stringify(row.time),
);
assert(
row.timeHeight === row.lineHeight,
"the time line is " +
row.timeHeight +
"px high, not one line of " +
row.lineHeight +
"px",
);
assert(
row.overflowingLines === 0,
row.overflowingLines + " line(s) of a row overflow it",
);
}
} finally {
env.routeOpts.seedNativeTransfer = false;
await page.close();
}
});
// ------------------------- the last of the #150 and #151 items (#295)
//
// Add Token's confirm button, TransactionDetail opened from the token screen
-7
View File
@@ -1,7 +0,0 @@
// A test file that starts an interval and never clears it.
// tests/jestRunner.test.js runs script/lib/jest.js on this file alone. The
// name does not end in .test.js, so the normal run never picks it up.
test("starts an interval and never clears it", () => {
setInterval(() => {}, 1000);
});
-29
View File
@@ -1,29 +0,0 @@
// script/lib/jest.js, which package.json's test scripts run, fails a run in
// which jest force-stopped a worker process because a test left an open
// handle.
const { spawnSync } = require("child_process");
const path = require("path");
const root = path.join(__dirname, "..");
test("a test that leaves an interval running fails the run", () => {
// The memory limit in package.json makes jest run even this one file in a
// worker process. The interval keeps that worker from exiting, so jest
// force-stops it after the run.
const result = spawnSync(
process.execPath,
[
"script/lib/jest.js",
"--maxWorkers=1",
"--testMatch",
"<rootDir>/tests/fixtures/leavesIntervalRunning.js",
],
{ cwd: root, encoding: "utf8", timeout: 20000 },
);
expect(result.signal).toBeNull();
expect(result.status).toBe(1);
expect(result.stderr).toContain("1 passed");
expect(result.stderr).toContain("jest force-stopped a worker process");
});
+2 -13
View File
@@ -1,5 +1,4 @@
// The release archives: the zip container, the self-containment rule, and the
// digest format in release/SHA256SUMS.
// The release archives: the zip container, and the self-containment rule.
//
// script/package produces one archive per browser and script/lib/package.js
// decides what goes in it. The trap that rule exists for is real and specific:
@@ -12,14 +11,12 @@
// So the packager resolves every reference in the manifest and in every HTML
// document, and fails on any that leaves the extension root. These are the
// cases for that, plus the archive format itself — new code, and the thing the
// artifact is made of. One more case checks the digest format in
// release/SHA256SUMS.
// artifact is made of.
const {
checkSelfContained,
htmlReferences,
manifestReferences,
sha256,
} = require("../script/lib/package");
const { readZip, writeZip } = require("../script/lib/zip");
@@ -145,14 +142,6 @@ describe("archive self-containment", () => {
});
});
// release/SHA256SUMS is checked with `sha256sum -c`, which needs the digest
// as bare lowercase hex. The expected value is the FIPS 180-2 digest of "abc".
test("sha256 prints the digest as sha256sum does", () => {
expect(sha256(Buffer.from("abc"))).toBe(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
);
});
describe("the zip container", () => {
const files = [
{ name: "manifest.json", data: Buffer.from('{"a":1}') },
+16 -21
View File
@@ -6,10 +6,8 @@
// balances and the fee were each cut to six decimal places, a rule of their
// own, and a token holding cut to zero was dropped, while the approval screen
// showed the same fee through src/shared/amountDisplay.js with the nonzero
// floor. The balances are now stored exactly, and every nonzero token holding
// is kept. The fee lines show the fee through that helper with the floor. The
// balance lines cut a balance to four decimals, as the balance lists do, so a
// balance below 0.0001 reads 0.0000.
// floor. The balances are now stored exactly, every nonzero token holding
// kept, 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
@@ -280,22 +278,21 @@ beforeEach(() => {
mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n };
});
describe("an ETH balance below 0.0001 reads 0.0000", () => {
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.0000 ETH");
expect(state.wallets[0].addresses[0].balance).toBe("0.0000005");
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.0000 ETH");
expect(text("confirm-balance")).toBe("0.0000005 ETH");
});
test("while a larger balance is cut to four decimals", async () => {
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");
@@ -305,7 +302,7 @@ describe("an ETH balance below 0.0001 reads 0.0000", () => {
// 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 reads 0.0000", () => {
describe("a tracked token holding below 0.000001 never renders as zero", () => {
beforeEach(() => {
state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
@@ -316,17 +313,13 @@ describe("a tracked token holding below 0.000001 reads 0.0000", () => {
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
state.selectedToken = TOKEN;
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000 TOK");
const stored = state.wallets[0].addresses[0].tokenBalances.find(
(t) => t.address === TOKEN,
);
expect(stored.balance).toBe("0.0000005");
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.0000 TOK");
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
@@ -354,9 +347,11 @@ describe("a tracked token holding below 0.000001 reads 0.0000", () => {
await refreshWith(10n ** 18n, [tokenRow(1n, { decimals: "24" })]);
state.selectedToken = TOKEN;
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000 TOK");
expect(text("send-balance")).toBe(
"Current balance: 0.000000000000000000000001 TOK",
);
await confirmSend("0.000000000000000001", TOKEN);
expect(text("confirm-balance")).toBe("0.0000 TOK");
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK");
expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.",
);
@@ -368,20 +363,20 @@ describe("a tracked token holding below 0.000001 reads 0.0000", () => {
// selected while its own screen is open: after sending 2 of a 2.0000003
// holding, the user is back on that screen, and Send read the missing row as
// zero.
describe("an untracked token holding below 0.000001 reads 0.0000", () => {
describe("an untracked token holding below 0.000001 never renders as zero", () => {
const row = () => tokenRow(HALF_MICRO_ETH, { holders_count: "50000" });
test("on the Send screen", async () => {
await refreshWith(10n ** 18n, [row()]);
state.selectedToken = TOKEN;
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000 TOK");
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, [row()]);
await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000 TOK");
expect(text("confirm-balance")).toBe("0.0000005 TOK");
expect(errors()).toBe("");
expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN);
+62 -101
View File
@@ -1,14 +1,15 @@
// A transaction's time shows the ISO datetime and the relative age on every
// screen that shows one (README, Amounts). Home, AddressDetail and AddressToken
// used to show only the age, with the datetime as a tooltip
// (https://git.eeqj.de/sneak/AutistMask/issues/760).
// A transaction's time is written by isoDate() and timeAgo() in
// src/popup/views/helpers.js on every screen that shows one (README, Amounts;
// https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail and
// AddressToken used to define their own copies, so a fix to the shared pair
// would not have reached them.
//
// The views run against a minimal DOM stub, as in
// tests/historyAfterNetworkSwitch.test.js, and over
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
// The pair is replaced before the views are loaded, because a view takes it
// when it loads. A view that writes the time with a copy of its own shows the
// real time instead of the replacement.
//
// Driven against a minimal DOM stub in the shape
// tests/contractCreation.test.js uses.
jest.mock("../src/shared/log", () => ({
log: {
@@ -25,78 +26,33 @@ jest.mock("../src/shared/log", () => ({
}));
// The history lists ask the explorer for their transactions and resolve ENS
// names for them; here the explorer answers with mockHistory() and no name
// names for them; here the explorer answers with mockHistory and no name
// resolves.
let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => mockHistory(),
fetchRecentTransactions: async () => mockHistory,
}));
jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(),
}));
const { state, loadState } = require("../src/shared/state");
const home = require("../src/popup/views/home");
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const helpers = require("../src/popup/views/helpers");
jest.spyOn(helpers, "isoDate").mockReturnValue("SHARED-ISO-DATE");
jest.spyOn(helpers, "timeAgo").mockReturnValue("SHARED-TIME-AGO");
const { state } = require("../src/shared/state");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const transactionDetail = require("../src/popup/views/transactionDetail");
const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TIMESTAMP = 1790000000;
// TIMESTAMP as shown with "UTC Timestamps" on, three hours after it.
const SHOWN_TIME = "2026-09-21T14:13:20Z (3 hours ago)";
// A profile on Ethereum Mainnet with one address, "Use Blockscout" and "UTC
// Timestamps" on.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 1,
addresses: [
{ address: FROM, balance: "1.0", tokenBalances: [] },
],
},
],
activeAddress: FROM,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
utcTimestamps: true,
};
}
// A transfer of ETH from FROM, as Blockscout answers it.
function mockHistory() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 1,
from: FROM,
to: RECIPIENT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: TIMESTAMP,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
chainId: "0x1",
},
];
}
function makeElement(id) {
const el = {
@@ -134,7 +90,6 @@ function makeDocument() {
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
@@ -143,58 +98,64 @@ function node(id) {
return globalThis.document.getElementById(id);
}
// Lets every promise that is not held open settle.
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
// A transaction FROM sent, as the history lists hold it.
function historyTx() {
return {
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
from: FROM,
to: RECIPIENT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
};
}
beforeEach(async () => {
const storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
jest.spyOn(Date, "now").mockReturnValue((TIMESTAMP + 3 * 3600) * 1000);
await loadState();
state.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: FROM, balance: "0.0000" }],
},
];
state.trackedTokens = [];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
const ctx = { pageClosed: new AbortController().signal };
addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
jest.restoreAllMocks();
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
// The history is Blockscout's.
state.useBlockscout = true;
});
describe.each([
[
"Home",
"home-tx-list",
() => home.render({ pageClosed: new AbortController().signal }),
],
["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("a transaction history row on %s", (_name, listId, open) => {
test("shows the ISO datetime and then the relative age", async () => {
test("shows the time written by the shared isoDate() and timeAgo()", async () => {
mockHistory = [historyTx()];
open();
// The list is drawn once the history has been fetched.
for (let i = 0; i < 10; i++) await settle();
await new Promise((resolve) => setTimeout(resolve, 0));
const html = node(listId).innerHTML;
expect(html).toContain(">" + SHOWN_TIME + "<");
expect(html).not.toContain("title=");
expect(html).toContain('title="SHARED-ISO-DATE"');
expect(html).toContain(">SHARED-TIME-AGO<");
});
});
test("the transaction detail view shows the ISO datetime, copyable, and then the relative age", () => {
transactionDetail.show(mockHistory()[0]);
test("the transaction detail view shows the time written by the shared isoDate() and timeAgo()", () => {
transactionDetail.show(historyTx());
const html = node("tx-detail-time").innerHTML;
expect(html).toContain('data-copy="2026-09-21T14:13:20Z"');
expect(html).toContain(">2026-09-21T14:13:20Z</span> (3 hours ago)");
expect(html).toContain("SHARED-ISO-DATE");
expect(html).toContain("(SHARED-TIME-AGO)");
});