diff --git a/README.md b/README.md index c7cfe85..4b14efe 100644 --- a/README.md +++ b/README.md @@ -689,6 +689,18 @@ Both are click-copyable. Truncating to 4 decimals in summary views is acceptable for scannability, but the detail view must never discard precision — it is the one place the user can always use to verify exact details. +**Specific Exception — nonzero floor on the approval screens:** A nonzero amount +must never render as zero. Truncating to 4 decimals does exactly that to an +amount below 0.0001 — 1 base unit of an 18-decimal token, 500 base units of an +8-decimal one — and on the dApp approval screen and the wait/success/error +screens that carry its amount forward, a real transfer or allowance then reads +as "nothing is being moved". On those screens (`formatTxValue()` in +`src/popup/views/approval.js`), when the first 4 decimals would all be zero and +the value is not, the amount is extended to its first significant digit instead: +`0.000000000000000001 DAI`, not `0.0000 DAI`. It stays in token units, the same +unit as the symbol beside it. The 4-decimal rule is unchanged everywhere else, +including for amounts at or above the floor on these same screens. + #### Partial USD totals Prices are fetched for the top 25 tokens only, so an address can hold assets the diff --git a/TODO.md b/TODO.md index 5398877..3034793 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,20 @@ but the review is broader than any of them. # Completed Steps +- 2026-08-23: An amount below the 4-decimal display floor no longer reads as + zero on the approval screens + ([#322](https://git.eeqj.de/sneak/AutistMask/issues/322)). With the token's + true scale resolved, `formatTxValue()` still truncated a small amount to + `0.0000` — 1 base unit of an 18-decimal token, 500 of an 8-decimal one — so a + real transfer or allowance was stated as nothing on the one screen whose job + is to say what is being authorized. Its copy in `src/popup/views/approval.js` + now extends to the first significant digit when the first 4 decimals would all + be zero and the value is not, keeping the amount in token units rather than + switching to base units mid-line. The 4-decimal rule is otherwise unchanged, + the copy in `src/shared/transactions.js` that serves the history list is + untouched, and `README.md`'s Display Consistency section records the + exception. + - 2026-08-20: A second extension page can no longer silently delete a wallet ([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote the entire state blob, and every extension page — the toolbar popup, a dApp diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index f353bdd..03be075 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -40,11 +40,22 @@ function approvalAddressHtml(address) { return renderAddressHtml(address, { title }); } +// Four decimal places, per the display rule in README.md, with one exception: +// a nonzero amount must never render as zero. Truncation is a scannability +// decision for summary views, and on the screens that state what is being +// authorized it cannot be allowed to produce a figure reading as "nothing is +// being moved" — 1 base unit of an 18-decimal token is not 0.0000. When the +// first four decimals are all zero and the value is not, the string is +// extended to the first significant digit instead: still token units, so the +// number stays in the same unit as the symbol beside it. function formatTxValue(val) { const parts = val.split("."); if (parts.length === 1) return val + ".0000"; - const dec = (parts[1] + "0000").slice(0, 4); - return parts[0] + "." + dec; + const truncated = parts[0] + "." + (parts[1] + "0000").slice(0, 4); + if (/[1-9]/.test(truncated)) return truncated; + const sig = parts[1].search(/[1-9]/); + if (sig === -1) return truncated; + return parts[0] + "." + parts[1].slice(0, sig + 1); } // The amount line for a decoded ERC-20 call. With a known scale it is the diff --git a/tests/approvalDisplayFloor.test.js b/tests/approvalDisplayFloor.test.js new file mode 100644 index 0000000..174f1f2 --- /dev/null +++ b/tests/approvalDisplayFloor.test.js @@ -0,0 +1,112 @@ +// The floor of the approval screen's amount line. +// +// Amounts are truncated to four decimal places for scannability (README.md, +// Display Consistency). With the token's true scale resolved, that truncation +// can still take a real amount below the floor and print it as `0.0000`: one +// base unit of an 18-decimal token, or a few hundred of an 8-decimal one. On +// the one screen whose job is to state what is being authorized, a nonzero +// transfer or allowance then reads as nothing. +// +// The invariant asserted here is narrow: a nonzero amount never renders as +// zero. The four-decimal rule itself is unchanged, and the string the +// confirmation screens carry as `txInfo.amount` is the same one, so it is +// asserted on `rawValue` alongside the displayed line. + +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const { Interface } = require("ethers"); +const { ERC20_ABI } = require("../src/shared/constants"); +const { state } = require("../src/shared/state"); +const { decodeCalldata } = require("../src/popup/views/approval"); + +const iface = new Interface(ERC20_ABI); + +// Bundled tokens, so the scale and the symbol both come from the list. +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // 6 decimals +const WBT = "0x925206b8a707096Ed26ae47C84747fE0bb734F59"; // 8 decimals +const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; // 18 decimals +// Outside the list, so the scale comes from what the user tracks and the line +// carries no symbol. +const NOVEL = "0xE2E0000000000000000000000000000000000E2e"; + +const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; +const SPENDER = "0x1111111111111111111111111111111111111111"; + +function transferData(amount) { + return iface.encodeFunctionData("transfer", [RECIPIENT, amount]); +} + +function approveData(amount) { + return iface.encodeFunctionData("approve", [SPENDER, amount]); +} + +// The Amount detail as the approval screen renders it: `value` is the line on +// the screen, `rawValue` is what is carried to the wait/success/error screens. +function amount(data, token) { + const decoded = decodeCalldata(data, token); + return decoded.details.find((d) => d.label === "Amount"); +} + +beforeEach(() => { + state.trackedTokens = []; + state.wallets = []; +}); + +describe("a nonzero amount never renders as zero", () => { + test("500 base units of a 6-decimal token", () => { + const detail = amount(transferData(500n), USDC); + expect(detail.value).toBe("0.0005 USDC"); + expect(detail.rawValue).toBe("0.0005"); + }); + + test("1 base unit of an 18-decimal token", () => { + const detail = amount(transferData(1n), DAI); + expect(detail.value).toBe("0.000000000000000001 DAI"); + expect(detail.rawValue).toBe("0.000000000000000001"); + }); + + test("500 base units of an 8-decimal token", () => { + expect(amount(transferData(500n), WBT).rawValue).toBe("0.000005"); + }); + + test("an allowance below the floor is not rendered as zero either", () => { + expect(amount(approveData(1n), DAI).value).toBe( + "0.000000000000000001 DAI", + ); + }); + + // The floor holds at any scale, not only the three above: for every + // decimals a token can declare, one base unit has to show a digit. + test("one base unit shows a significant digit at every scale", () => { + for (let decimals = 0; decimals <= 30; decimals++) { + state.trackedTokens = [{ address: NOVEL, decimals }]; + expect(amount(transferData(1n), NOVEL).rawValue).toMatch(/[1-9]/); + } + }); +}); + +describe("the four-decimal rule is otherwise unchanged", () => { + test("a whole amount keeps exactly four decimals", () => { + expect(amount(transferData(5000000000n), USDC).rawValue).toBe( + "5000.0000", + ); + }); + + test("precision beyond four decimals is still truncated", () => { + expect(amount(transferData(1234567890123456789n), DAI).rawValue).toBe( + "1.2345", + ); + }); + + test("an amount at the floor is not extended", () => { + expect(amount(transferData(100000000000000n), DAI).rawValue).toBe( + "0.0001", + ); + }); + + test("a genuine zero still renders as zero", () => { + expect(amount(transferData(0n), DAI).rawValue).toBe("0.0000"); + }); +});