fix: never render a nonzero approval amount as zero (closes #322)
formatTxValue() truncates to 4 decimal places, per README.md's Display Consistency rule. With the token's true scale resolved, an amount below 0.0001 still printed as 0.0000: 1 base unit of an 18-decimal token, 500 base units of an 8-decimal one. On the dApp approval screen — and on the wait/success/error screens, which carry that same string forward as txInfo.amount — a real transfer or allowance was therefore stated as nothing. The copy of formatTxValue() 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: 0.000000000000000001 DAI, not 0.0000 DAI. Extra precision was chosen over falling back to base units so the number stays in token units, the same unit as the symbol printed beside it; the base-unit rendering already on this screen means "the scale is unknown", and reusing it for a known scale would blur the two. A genuine zero still renders 0.0000, and an amount at or above the floor is untouched. The 4-decimal rule stays. The separate copy of formatTxValue() in src/shared/transactions.js, which serves the history list, is deliberately not changed: the history detail view already shows exact precision, and balance lists and history are out of scope here. tests/approvalDisplayFloor.test.js drives decodeCalldata() and asserts both the displayed line and the rawValue the confirmation screens carry, at 6, 8 and 18 decimals and across every scale from 0 to 30, plus four cases pinning the unchanged truncation. Against this tree with the source change reverted, 4 of its cases fail — 1 base unit at 18 decimals gives "0.0000 DAI" where "0.000000000000000001 DAI" is expected, 500 base units at 8 decimals gives "0.0000" where "0.000005" is expected. make check green: 42 suites, 844 tests; verify-build 39 cases; check-censored 152 files; eslint and prettier clean in the pinned container.
This commit is contained in:
12
README.md
12
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
|
for scannability, but the detail view must never discard precision — it is the
|
||||||
one place the user can always use to verify exact details.
|
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
|
#### Partial USD totals
|
||||||
|
|
||||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
||||||
|
|||||||
14
TODO.md
14
TODO.md
@@ -44,6 +44,20 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote
|
||||||
the entire state blob, and every extension page — the toolbar popup, a dApp
|
the entire state blob, and every extension page — the toolbar popup, a dApp
|
||||||
|
|||||||
@@ -40,11 +40,22 @@ function approvalAddressHtml(address) {
|
|||||||
return renderAddressHtml(address, { title });
|
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) {
|
function formatTxValue(val) {
|
||||||
const parts = val.split(".");
|
const parts = val.split(".");
|
||||||
if (parts.length === 1) return val + ".0000";
|
if (parts.length === 1) return val + ".0000";
|
||||||
const dec = (parts[1] + "0000").slice(0, 4);
|
const truncated = parts[0] + "." + (parts[1] + "0000").slice(0, 4);
|
||||||
return parts[0] + "." + dec;
|
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
|
// The amount line for a decoded ERC-20 call. With a known scale it is the
|
||||||
|
|||||||
112
tests/approvalDisplayFloor.test.js
Normal file
112
tests/approvalDisplayFloor.test.js
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user