Compare commits

..
Author SHA1 Message Date
sneak 65d4dd8e0f docs: name every approval-screen amount string, fix stale zero claim (closes #369)
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
check / check (push) Successful in 1m18s
The README's amount-display section claimed a genuine zero always renders
`0.0000`. That is corrected and scoped: it holds for the ERC-20 amount and for
the swap's `Amount` line on a literal-zero `amountIn` in a V2/V3 exact-in swap
or a zero `WRAP_ETH`. Only two zeros are stated in words before the floor — the
swap's `Min. received` (any zero minimum) and its V4 exact-in `Amount` (an
`amountIn` of zero, V4's open delta).

A new list names every string an amount slot can show: a formatted quantity,
`Unlimited`, `All available (V4 open delta)`, `None (no minimum guaranteed)`,
base units with decimals unknown, and `Unknown (not named in the calldata)`. It
also records that a zero `minBalance` on a `BALANCE_CHECK_ERC20` step now reads
the no-minimum wording where it once read `0.0000`. Each claim checked against
the tree. Docs only.

Model: opus-4-8
2026-09-21 23:30:10 +00:00
6 changed files with 100 additions and 129 deletions
+44 -3
View File
@@ -882,9 +882,18 @@ On those screens, when the truncated string would contain no digit from 1 to 9
and the value does, the amount is extended to its first significant digit and the value does, the amount is extended to its first significant digit
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
truncated string, integer part included, so `1.00005` still shows as `1.0000` — truncated string, integer part included, so `1.00005` still shows as `1.0000` —
the exception only fires where the entire displayed figure would read as zero. A the exception only fires where the entire displayed figure would read as zero.
genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999` Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. A
shows as `0.9999`, never rounded up. genuine zero reaching this rule renders `0.0000`. The ERC-20
`approve`/`transfer` amount does exactly that, and so does the swap's `Amount`
line for a literal-zero `amountIn` on a V2 or V3 exact-in swap, or a zero
`WRAP_ETH` (`0.0000 ETH`). Only two zeros are stated in words before the floor:
the swap's `Min. received` line reads `None (no minimum guaranteed)` for any
zero minimum, and its `Amount` line reads `All available (V4 open delta)` for a
V4 exact-in `amountIn` of zero, which V4 treats as the whole open credit rather
than a quantity. So the guarantee that a zero is never shown as `0.0000` covers
the `Min. received` line and the V4 exact-in `Amount`; a V2/V3 or `WRAP_ETH`
`Amount` still renders it (see the list of amount-slot strings below).
The rule and its exception live in `src/shared/amountDisplay.js` as The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and `truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
@@ -936,6 +945,38 @@ and compare against. Reading the stored field directly instead answers `null`
for a bundled or tracked token the explorer merely omitted, which is not a for a bundled or tracked token the explorer merely omitted, which is not a
refusal the wallet has any reason to make. refusal the wallet has any reason to make.
**Every string an amount slot can show:** taken together, the exceptions above
mean an amount line on the dApp approval screen (and the wait/success/error
screens that carry a figure forward) shows one of a fixed set of strings, not
always a number:
- A formatted quantity, e.g. `17.1900 USDT`: the token's scale is known and the
figure is at or above the floor, or below it and extended to its first
significant digit. This is `truncateAmountNeverZero()`
(`src/shared/amountDisplay.js`).
- `Unlimited`: an unbounded allowance or permit, which needs no scale to
describe — a `uint256`-max ERC-20 `approve` (`src/popup/views/approval.js`) or
a Permit2 amount at the `uint160` max on a swap's `Amount`
(`src/shared/uniswap.js`).
- `All available (V4 open delta)`: a V4 exact-in swap whose `amountIn` is zero.
V4 reads that zero as "use the whole open delta", not as a literal zero, so
the calldata states no quantity at all. Swap `Amount` line only
(`src/shared/uniswap.js`).
- `None (no minimum guaranteed)`: a zero minimum — the swap guarantees nothing
back. It is a literal zero slippage floor on a V2/V3/V4 swap, and it also
reaches a `BALANCE_CHECK_ERC20` step: a zero `minBalance`, which once rendered
`0.0000` beside the token symbol, now reads this. Swap `Min. received` line
(`src/shared/uniswap.js`).
- `<amount> base units (decimals unknown)`: the token's scale could not be
resolved, so the base-unit integer is shown with that caveat rather than
formatted (see Unknown token scale above). Reaches both the ERC-20 amount line
and the swap's `Amount` and `Min. received` (`unknownDecimalsAmount()` in
`src/shared/approvalAmount.js`).
- `Unknown (not named in the calldata)`: not an amount but the currency itself —
the `Token In` or `Token Out` line when nothing in the calldata established
which token, shown beside the amount and, like the strings above, a sentence
rather than a value (`src/shared/uniswap.js`).
#### 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 -10
View File
@@ -143,16 +143,20 @@ but the review is broader than any of them.
both routes take, so they behave the same and the button is live for the next both routes take, so they behave the same and the button is live for the next
delete. delete.
- 2026-09-21: The EIP-6963 provider UUID is generated fresh on each page load - 2026-09-21: `README.md` now documents the approval screen's amount-slot
and never persisted ([#398](https://git.eeqj.de/sneak/AutistMask/issues/398)). vocabulary and no longer contradicts itself
It was created once and stored, then announced verbatim to every page on every ([#369](https://git.eeqj.de/sneak/AutistMask/issues/369)). The stale claim
load and across restarts, so any site — connected or not — could read it as a that a genuine zero still renders `0.0000` is corrected: it holds for the
stable cross-site, cross-session identifier for the install, contradicting the ERC-20 amount, and also for the swap's `Amount` line on a literal-zero
"no tracking" promise. inpage.js now announces a per-load `amountIn` in a V2/V3 exact-in swap or a zero `WRAP_ETH`. Only two zeros are
`crypto.randomUUID()` and the `eip6963Uuid` storage key and the stated in words upstream — the swap's `Min. received` (any zero minimum) and
`AUTISTMASK_PROVIDER_UUID` content-script message are gone. That key was a its V4 exact-in `Amount` (an `amountIn` of zero, V4's open delta). The
standalone storage entry, never part of the versioned `autistmask` profile, so amount-display section now names every string a slot can show — a formatted
the state schema is untouched and no existing profile is affected. quantity, `Unlimited`, `All available (V4 open delta)`,
`None (no minimum guaranteed)`, base units with decimals unknown, and
`Unknown (not named in the calldata)` — and records that a zero `minBalance`
on a `BALANCE_CHECK_ERC20` step now reads `None (no minimum guaranteed)` where
it once read `0.0000`. Docs only; each claim checked against the tree.
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the - 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
+26
View File
@@ -5,6 +5,8 @@ const {
hasBrowserNamespace, hasBrowserNamespace,
runtimeApi, runtimeApi,
sendMessage, sendMessage,
storageGet,
storageSet,
} = require("../shared/browserApi"); } = require("../shared/browserApi");
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared // In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
@@ -19,6 +21,30 @@ if (hasBrowserNamespace()) {
(document.head || document.documentElement).appendChild(script); (document.head || document.documentElement).appendChild(script);
} }
// Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in extension storage.
(async function sendProviderUuid() {
let uuid = null;
try {
const items = await storageGet("eip6963Uuid");
uuid = items?.eip6963Uuid;
if (!uuid) {
uuid = crypto.randomUUID();
await storageSet({ eip6963Uuid: uuid });
}
} catch {
// Storage was unavailable or refused the write. The announcement
// still has to go out — a provider that never announces is invisible
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
// page load will not outlive.
if (!uuid) uuid = crypto.randomUUID();
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
})();
// Relay requests from the page to the background script // Relay requests from the page to the background script
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
if (event.source !== window) return; if (event.source !== window) return;
+11 -8
View File
@@ -204,14 +204,7 @@
"</svg>", "</svg>",
); );
// EIP-6963 wants a fresh UUIDv4 per page load — it identifies one let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
// announcement, so a provider can be told apart from another instance of
// itself in the same page. It is generated here and never stored:
// announcing one persisted value to every site, on every load and across
// restarts, turned it into a stable cross-site, cross-session tracking
// identifier any page could read
// (https://git.eeqj.de/sneak/AutistMask/issues/398).
const providerUuid = crypto.randomUUID();
function buildProviderInfo() { function buildProviderInfo() {
return { return {
@@ -233,6 +226,16 @@
); );
} }
// Listen for the persisted UUID from the content script
function onProviderUuid(event) {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_PROVIDER_UUID") return;
window.removeEventListener("message", onProviderUuid);
providerUuid = event.data.uuid;
announceProvider();
}
window.addEventListener("message", onProviderUuid);
window.addEventListener("eip6963:requestProvider", announceProvider); window.addEventListener("eip6963:requestProvider", announceProvider);
announceProvider(); announceProvider();
+5 -5
View File
@@ -372,10 +372,10 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
STEP_TIMEOUT_MS, STEP_TIMEOUT_MS,
); );
// EIP-6963, asked of the provider itself. The announcement carries a // EIP-6963, asked of the provider itself. The announcement carries the
// UUIDv4 inpage.js generates fresh for this page load (nothing persists // uuid src/content/index.js reads out of extension storage — call site 1
// it — see issue #398) and has to name this extension and hand back the // in the issue — and it has to name this extension and hand back the very
// very object on window.ethereum. // object on window.ethereum.
const announced = await d.executeAsync( const announced = await d.executeAsync(
`const done = arguments[arguments.length - 1]; `const done = arguments[arguments.length - 1];
const onAnnounce = (e) => { const onAnnounce = (e) => {
@@ -402,7 +402,7 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
); );
assert( assert(
typeof announced.uuid === "string" && announced.uuid.length === 36, typeof announced.uuid === "string" && announced.uuid.length === 36,
"the announcement carries no provider uuid: " + "the announcement carries no stored provider uuid: " +
JSON.stringify(announced.uuid), JSON.stringify(announced.uuid),
); );
-103
View File
@@ -1,103 +0,0 @@
// The EIP-6963 provider UUID inpage.js announces (src/content/inpage.js).
//
// The bug this pins down (issue #398): the UUID used to be generated once,
// persisted in extension storage, and announced verbatim to every page on
// every load and across browser restarts, so any site — connected or not —
// could read a stable cross-site, cross-session identifier for the install.
// EIP-6963 wants a fresh UUIDv4 per announcement instead. The fix generates
// it per page load and stores nothing.
//
// inpage.js is a bare IIFE injected into the page's JS context, not a module;
// see tests/inpageErrors.test.js for why it is evaluated against a stub window
// rather than imported. Here the stub captures the CustomEvent that carries
// the announcement, so the UUID this file reads is the one a real dApp's
// eip6963:announceProvider listener would see.
const fs = require("fs");
const path = require("path");
const { webcrypto } = require("crypto");
const SOURCE = fs.readFileSync(
path.join(__dirname, "..", "src", "content", "inpage.js"),
"utf8",
);
const loadInto = new Function(
"window",
"self",
"crypto",
"Event",
"CustomEvent",
SOURCE,
);
class StubEvent {
constructor(type) {
this.type = type;
}
}
class StubCustomEvent extends StubEvent {
constructor(type, init) {
super(type);
this.detail = init && init.detail;
}
}
// Evaluate inpage.js once against a fresh stub window and return every UUID it
// announced. A `requestProvider` event is dispatched too, so a re-announcement
// within one load is observed as well as the announcement at load.
function announcedUuids() {
const listeners = {};
const uuids = [];
const win = {
addEventListener(type, fn) {
(listeners[type] || (listeners[type] = [])).push(fn);
},
removeEventListener(type, fn) {
const fns = listeners[type];
if (!fns) return;
const i = fns.indexOf(fn);
if (i !== -1) fns.splice(i, 1);
},
postMessage() {},
dispatchEvent(event) {
if (event.type === "eip6963:announceProvider") {
uuids.push(event.detail.info.uuid);
}
for (const fn of (listeners[event.type] || []).slice()) fn(event);
return true;
},
};
win.window = win;
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
win.dispatchEvent(new StubEvent("eip6963:requestProvider"));
return uuids;
}
const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
describe("the EIP-6963 provider UUID is fresh per page load", () => {
test("a load announces a UUIDv4, unprompted, with nothing delivered", () => {
const uuids = announcedUuids();
expect(uuids.length).toBeGreaterThan(0);
expect(uuids[0]).toMatch(UUID_V4);
});
test("every announcement within one load carries the same UUID", () => {
const uuids = announcedUuids();
expect(uuids.length).toBeGreaterThan(1);
expect(new Set(uuids).size).toBe(1);
});
test("two page loads announce different UUIDs", () => {
const first = announcedUuids()[0];
const second = announcedUuids()[0];
expect(first).toMatch(UUID_V4);
expect(second).toMatch(UUID_V4);
expect(second).not.toBe(first);
});
});