Author SHA1 Message Date
sneak 009ce492c2 fix: every balance is cut to four decimals (closes #757)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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. Send's current balance and
ConfirmTx's balance line kept the nonzero floor, so 0.00004 USDC read
0.0000 in the lists and 0.00004 on Send. All four 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 amounts, fees and allowances a screen
asks the user to approve keep the floor of truncateAmountNeverZero().

Model: opus-5-5
2026-11-11 00:13:45 +00:00
clawbot b87c03be11 fix: build hashes and recovery-phrase entropy come from ethers (closes #758)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
build.js and script/lib/package.js hash with sha256 from ethers, with the
0x prefix stripped so the build receipt and SHA256SUMS keep bare lowercase
hex. generateMnemonic() takes its 16 bytes of entropy from randomBytes in
ethers. The README Crypto policy no longer lists these as exceptions.

Model: opus-5-5
2026-11-11 00:42:18 +01:00
11 changed files with 98 additions and 60 deletions

No files matched your search

+16 -9
View File
@@ -1191,15 +1191,23 @@ These helpers write one kind of value on every screen that shows it:
Most screens show token and ETH amounts with four decimal places. History rows Most screens show token and ETH amounts with four decimal places. History rows
cut amounts to four decimals and never round up (`truncateAmount()` in cut amounts to four decimals and never round up (`truncateAmount()` in
`src/shared/amountDisplay.js`). The balance lists and Home's ETH balance cut `src/shared/amountDisplay.js`). These balances are cut the same way:
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 - 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
(`truncateAmountNeverZero()`). So a nonzero amount never reads as zero there. (`truncateAmountNeverZero()`). So a nonzero amount never reads as zero there.
Those screens are: Those screens are:
- TxApproval; - TxApproval;
- SignApproval's Permit warning; - SignApproval's Permit warning;
- ConfirmTx, and Send's current balance; - ConfirmTx's amount and network fee;
- WaitTx, SuccessTx and ErrorTx. - WaitTx, SuccessTx and ErrorTx.
TransactionDetail shows the exact amount and its base units. TransactionDetail shows the exact amount and its base units.
@@ -1647,11 +1655,10 @@ The development dependencies ship nowhere:
### Crypto policy ### Crypto policy
Hashing, key derivation, signing and encryption go through `ethers` or In the extension, hashing, key derivation, signing and encryption go through
`libsodium-wrappers-sumo`. No primitive is implemented in this repository. A new `ethers` or `libsodium-wrappers-sumo`. A new recovery phrase takes its random
recovery phrase draws its random bytes from the browser's bytes from `ethers`. `build.js` and `script/lib/package.js` also hash files with
`crypto.getRandomValues()`. The build tooling, `build.js` and `script/lib/`, `ethers`. No primitive is implemented in this repository.
hashes files with Node's `crypto`; none of it ships in the extension.
### Content Security Policy ### Content Security Policy
+3 -5
View File
@@ -1,8 +1,8 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const esbuild = require("esbuild"); const esbuild = require("esbuild");
const { sha256 } = require("ethers");
const { resolveVersion } = require("./script/lib/version"); const { resolveVersion } = require("./script/lib/version");
const { const {
BACKGROUND_ENTRY_PREFIX, BACKGROUND_ENTRY_PREFIX,
@@ -314,10 +314,8 @@ function copyIcons(distDir) {
} }
function sha256File(absPath) { function sha256File(absPath) {
return crypto // ethers prefixes the hex with 0x; the receipt holds bare hex.
.createHash("sha256") return sha256(fs.readFileSync(absPath)).slice(2);
.update(fs.readFileSync(absPath))
.digest("hex");
} }
// Write the receipt for the files this build emitted. Deliberately records no // Write the receipt for the files this build emitted. Deliberately records no
+9 -3
View File
@@ -25,9 +25,9 @@
"use strict"; "use strict";
const crypto = require("crypto");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const ethers = require("ethers");
const { readZip, writeZip } = require("./zip"); const { readZip, writeZip } = require("./zip");
const { resolveVersion } = require("./version"); const { resolveVersion } = require("./version");
@@ -60,8 +60,9 @@ function fail(message) {
throw new Error(message); throw new Error(message);
} }
// Lowercase hex without the 0x prefix ethers adds, as sha256sum prints it.
function sha256(buf) { function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex"); return ethers.sha256(buf).slice(2);
} }
// Every regular file under dir, as archive-root-relative forward-slashed // Every regular file under dir, as archive-root-relative forward-slashed
@@ -291,4 +292,9 @@ if (require.main === module) {
} }
} }
module.exports = { checkSelfContained, htmlReferences, manifestReferences }; module.exports = {
checkSelfContained,
htmlReferences,
manifestReferences,
sha256,
};
+7 -4
View File
@@ -43,9 +43,12 @@ const {
transferAmountUnits, transferAmountUnits,
} = require("../../shared/transferAmount"); } = require("../../shared/transferAmount");
const { assertWithinCeilings } = require("../../shared/approvalVerify"); const { assertWithinCeilings } = require("../../shared/approvalVerify");
// The amount, the balance line and the fee reserve go through it, as the // The amount and the fee reserve keep the nonzero floor, as the approval
// approval screen's amounts do. // screen's amounts do. The balance line is cut as the balance lists cut it.
const { truncateAmountNeverZero } = require("../../shared/amountDisplay"); const {
truncateAmount,
truncateAmountNeverZero,
} = require("../../shared/amountDisplay");
const { const {
CODES, CODES,
FEE_PENDING, FEE_PENDING,
@@ -160,7 +163,7 @@ function renderBalance(txInfo) {
const price = getPrice(txInfo.token, txInfo.chainId); const price = getPrice(txInfo.token, txInfo.chainId);
const balUsd = price ? parseFloat(bal) * price : null; const balUsd = price ? parseFloat(bal) * price : null;
$("confirm-balance").textContent = valueWithUsd( $("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " " + symbol, truncateAmount(bal) + " " + symbol,
balUsd, balUsd,
); );
} }
+2 -2
View File
@@ -378,8 +378,8 @@ function formatFee(wei, symbol, chainId) {
// whose scale nothing knows // whose scale nothing knows
// (https://git.eeqj.de/sneak/AutistMask/issues/349), and for a balance no // (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 // refresh has read. There is no quantity to print for it and no fiat value to
// derive from one, and printing 0.0000 for a real holding is the failure this // derive from one. A holding whose amount is unknown is shown as
// whole rule exists to prevent, so the row says so instead. // `quantity unknown` rather than as a zero.
function balanceLine(symbol, balance, price, tokenId) { function balanceLine(symbol, balance, price, tokenId) {
const amount = unknownableAmount(balance); const amount = unknownableAmount(balance);
const qty = amount === null ? "quantity unknown" : truncateAmount(balance); const qty = amount === null ? "quantity unknown" : truncateAmount(balance);
+5 -8
View File
@@ -24,7 +24,7 @@ const { resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders"); const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof"); const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { const {
truncateAmountNeverZero, truncateAmount,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { const {
@@ -239,7 +239,7 @@ function updateSendBalance() {
addr.balance == null addr.balance == null
? "Current balance: unknown (" + nativeCurrency() + ")" ? "Current balance: unknown (" + nativeCurrency() + ")"
: "Current balance: " + : "Current balance: " +
truncateAmountNeverZero(addr.balance) + truncateAmount(addr.balance) +
" " + " " +
nativeCurrency(); nativeCurrency();
} else { } else {
@@ -258,18 +258,15 @@ function updateSendBalance() {
$("send-balance").textContent = $("send-balance").textContent =
bal == null bal == null
? "Current balance: unknown (" + symbol + ")" ? "Current balance: unknown (" + symbol + ")"
: "Current balance: " + : "Current balance: " + truncateAmount(bal) + " " + symbol;
truncateAmountNeverZero(bal) +
" " +
symbol;
} }
} }
// Fill the amount field with the most the selected holding can send: a // 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 // 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 // balance minus the fee reserve the confirmation screen checks against, never
// the rounded balance the screen shows. Where there is nothing to fill in, a // the balance the screen shows, which is cut to four decimals. Where there is
// flash message says why. // nothing to fill in, a flash message says why.
async function fillMaxAmount() { async function fillMaxAmount() {
const addr = currentAddress(); const addr = currentAddress();
if (!addr) return; if (!addr) return;
+2 -5
View File
@@ -1,7 +1,7 @@
// Wallet operations: mnemonic generation, HD derivation, signing. // Wallet operations: mnemonic generation, HD derivation, signing.
// All crypto delegated to ethers.js. // All crypto delegated to ethers.js.
const { Mnemonic, HDNodeWallet, Wallet } = require("ethers"); const { Mnemonic, HDNodeWallet, Wallet, randomBytes } = require("ethers");
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants"); const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
function generateMnemonic() { function generateMnemonic() {
@@ -10,10 +10,7 @@ function generateMnemonic() {
// settings toggle drives, which would let a user of a release build turn // settings toggle drives, which would let a user of a release build turn
// the hardcoded, publicly known test phrase back on for real wallets. // the hardcoded, publicly known test phrase back on for real wallets.
if (DEBUG) return DEBUG_MNEMONIC; if (DEBUG) return DEBUG_MNEMONIC;
const m = Mnemonic.fromEntropy( return Mnemonic.fromEntropy(randomBytes(16)).phrase;
globalThis.crypto.getRandomValues(new Uint8Array(16)),
);
return m.phrase;
} }
// Every extended key (xprv or xpub) entering the app goes through this. // Every extended key (xprv or xpub) entering the app goes through this.
+17 -3
View File
@@ -1,6 +1,6 @@
// The balance lists and Home's ETH line show a balance as the history rows do // The balance lists, Home's ETH line and Send's current balance show a balance
// (README.md, Amounts): cut to four decimal places, never rounded up // as the history rows do (README.md, Amounts): cut to four decimal places,
// (https://git.eeqj.de/sneak/AutistMask/issues/757). // never rounded up (https://git.eeqj.de/sneak/AutistMask/issues/757).
// //
// Driven through the real popup over a stored profile on Ethereum Mainnet. // Driven through the real popup over a stored profile on Ethereum Mainnet.
@@ -75,3 +75,17 @@ describe("Home", () => {
expect(quantity(homeRow(page, USDC.toLowerCase()))).toBe("0.0000"); 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 // The sentence must not assert an amount, because any amount it could
// assert has been rounded: "This address holds 0.0000 ETH." is what the // assert has been cut to four decimals: "This address holds 0.0000 ETH." is
// rounded form produces for an address that holds real money. // what the cut form produces for an address that holds real money.
test("the warning sentence asserts no rounded amount", () => { test("the warning sentence asserts no cut amount", () => {
for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) { for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) {
expect(balanceWarningHtml(addr)).not.toMatch( expect(balanceWarningHtml(addr)).not.toMatch(
/holds [\d.]+ (ETH|USDC)/, /holds [\d.]+ (ETH|USDC)/,
+13 -2
View File
@@ -1,4 +1,5 @@
// The release archives: the zip container, and the self-containment rule. // The release archives: the zip container, the self-containment rule, and the
// digest format in release/SHA256SUMS.
// //
// script/package produces one archive per browser and script/lib/package.js // 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: // decides what goes in it. The trap that rule exists for is real and specific:
@@ -11,12 +12,14 @@
// So the packager resolves every reference in the manifest and in every HTML // 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 // 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 // cases for that, plus the archive format itself — new code, and the thing the
// artifact is made of. // artifact is made of. One more case checks the digest format in
// release/SHA256SUMS.
const { const {
checkSelfContained, checkSelfContained,
htmlReferences, htmlReferences,
manifestReferences, manifestReferences,
sha256,
} = require("../script/lib/package"); } = require("../script/lib/package");
const { readZip, writeZip } = require("../script/lib/zip"); const { readZip, writeZip } = require("../script/lib/zip");
@@ -142,6 +145,14 @@ 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", () => { describe("the zip container", () => {
const files = [ const files = [
{ name: "manifest.json", data: Buffer.from('{"a":1}') }, { name: "manifest.json", data: Buffer.from('{"a":1}') },
+21 -16
View File
@@ -6,8 +6,10 @@
// balances and the fee were each cut to six decimal places, a rule of their // 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 // 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 // showed the same fee through src/shared/amountDisplay.js with the nonzero
// floor. The balances are now stored exactly, every nonzero token holding // floor. The balances are now stored exactly, and every nonzero token holding
// kept, and the screens show them and the fee through that helper. // 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.
// //
// Driven through the real refreshBalances(), Send screen, confirmation screen // Driven through the real refreshBalances(), Send screen, confirmation screen
// and approval screen, with only the node, the explorer and the DOM stubbed: a // and approval screen, with only the node, the explorer and the DOM stubbed: a
@@ -278,21 +280,22 @@ beforeEach(() => {
mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n }; mockNode.feeData = { maxFeePerGas: 1n, gasPrice: 1n };
}); });
describe("an ETH balance below 0.000001 never renders as zero", () => { describe("an ETH balance below 0.0001 reads 0.0000", () => {
test("on the Send screen", async () => { test("on the Send screen", async () => {
await refreshWith(HALF_MICRO_ETH); await refreshWith(HALF_MICRO_ETH);
state.selectedToken = "ETH"; state.selectedToken = "ETH";
send.updateSendBalance(); send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000005 ETH"); expect(text("send-balance")).toBe("Current balance: 0.0000 ETH");
expect(state.wallets[0].addresses[0].balance).toBe("0.0000005");
}); });
test("on the confirmation screen", async () => { test("on the confirmation screen", async () => {
await refreshWith(HALF_MICRO_ETH); await refreshWith(HALF_MICRO_ETH);
await confirmSend("0.0000001"); await confirmSend("0.0000001");
expect(text("confirm-balance")).toBe("0.0000005 ETH"); expect(text("confirm-balance")).toBe("0.0000 ETH");
}); });
test("while a balance above the floor keeps four decimals", async () => { test("while a larger balance is cut to four decimals", async () => {
await refreshWith(1234567890000000000n); await refreshWith(1234567890000000000n);
await confirmSend("0.1"); await confirmSend("0.1");
expect(text("confirm-balance")).toBe("1.2345 ETH"); expect(text("confirm-balance")).toBe("1.2345 ETH");
@@ -302,7 +305,7 @@ describe("an ETH balance below 0.000001 never renders as zero", () => {
// A token the user tracks stays on the balance list when the explorer's row is // 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 // dropped, so a holding of it below 0.000001 reached these screens as zero, and
// the send was checked against zero. // the send was checked against zero.
describe("a tracked token holding below 0.000001 never renders as zero", () => { describe("a tracked token holding below 0.000001 reads 0.0000", () => {
beforeEach(() => { beforeEach(() => {
state.trackedTokens = [ state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 }, { address: TOKEN, symbol: "TOK", name: "Token", decimals: 18 },
@@ -313,13 +316,17 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]); await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
state.selectedToken = TOKEN; state.selectedToken = TOKEN;
send.updateSendBalance(); send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK"); 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");
}); });
test("on the confirmation screen, which checks the send against it", async () => { test("on the confirmation screen, which checks the send against it", async () => {
await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]); await refreshWith(10n ** 18n, [tokenRow(HALF_MICRO_ETH)]);
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe(""); expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);
@@ -347,11 +354,9 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
await refreshWith(10n ** 18n, [tokenRow(1n, { decimals: "24" })]); await refreshWith(10n ** 18n, [tokenRow(1n, { decimals: "24" })]);
state.selectedToken = TOKEN; state.selectedToken = TOKEN;
send.updateSendBalance(); send.updateSendBalance();
expect(text("send-balance")).toBe( expect(text("send-balance")).toBe("Current balance: 0.0000 TOK");
"Current balance: 0.000000000000000000000001 TOK",
);
await confirmSend("0.000000000000000001", TOKEN); await confirmSend("0.000000000000000001", TOKEN);
expect(text("confirm-balance")).toBe("0.000000000000000000000001 TOK"); expect(text("confirm-balance")).toBe("0.0000 TOK");
expect(balanceError()).toContain( expect(balanceError()).toContain(
"Your TOK balance does not cover this amount.", "Your TOK balance does not cover this amount.",
); );
@@ -363,20 +368,20 @@ describe("a tracked token holding below 0.000001 never renders as zero", () => {
// selected while its own screen is open: after sending 2 of a 2.0000003 // 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 // holding, the user is back on that screen, and Send read the missing row as
// zero. // zero.
describe("an untracked token holding below 0.000001 never renders as zero", () => { describe("an untracked token holding below 0.000001 reads 0.0000", () => {
const row = () => tokenRow(HALF_MICRO_ETH, { holders_count: "50000" }); const row = () => tokenRow(HALF_MICRO_ETH, { holders_count: "50000" });
test("on the Send screen", async () => { test("on the Send screen", async () => {
await refreshWith(10n ** 18n, [row()]); await refreshWith(10n ** 18n, [row()]);
state.selectedToken = TOKEN; state.selectedToken = TOKEN;
send.updateSendBalance(); send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 0.0000005 TOK"); expect(text("send-balance")).toBe("Current balance: 0.0000 TOK");
}); });
test("on the confirmation screen, which checks the send against it", async () => { test("on the confirmation screen, which checks the send against it", async () => {
await refreshWith(10n ** 18n, [row()]); await refreshWith(10n ** 18n, [row()]);
await confirmSend("0.0000005", TOKEN); await confirmSend("0.0000005", TOKEN);
expect(text("confirm-balance")).toBe("0.0000005 TOK"); expect(text("confirm-balance")).toBe("0.0000 TOK");
expect(errors()).toBe(""); expect(errors()).toBe("");
expect(balanceError()).toBe(""); expect(balanceError()).toBe("");
await confirmSend("0.0000006", TOKEN); await confirmSend("0.0000006", TOKEN);