1 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 74 additions and 185 deletions
+19 -30
View File
@@ -884,16 +884,16 @@ 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. the exception only fires where the entire displayed figure would read as zero.
Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. A Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. A
genuine zero reaching this rule renders `0.0000`. The swap screen states a zero genuine zero reaching this rule renders `0.0000`. The ERC-20
in words before it gets here in only two places. Its `Min. received` line reads `approve`/`transfer` amount does exactly that, and so does the swap's `Amount`
`None (no minimum guaranteed)` for any zero minimum. Its `Amount` line reads line for a literal-zero `amountIn` on a V2 or V3 exact-in swap, or a zero
`All available (V4 open delta)` for a V4 exact-in `amountIn` of zero, which V4 `WRAP_ETH` (`0.0000 ETH`). Only two zeros are stated in words before the floor:
treats as the whole open credit rather than a quantity — but only when that V4 the swap's `Min. received` line reads `None (no minimum guaranteed)` for any
step is the one that sets the `Amount` line. A Permit2 permit sets it whenever zero minimum, and its `Amount` line reads `All available (V4 open delta)` for a
one is among the steps, so a swap carrying a permit shows the permit's amount V4 exact-in `amountIn` of zero, which V4 treats as the whole open credit rather
there and never the open-delta wording. Every other zero amount with a known than a quantity. So the guarantee that a zero is never shown as `0.0000` covers
scale renders `0.0000`, the swap's `Amount` line included; the list of the `Min. received` line and the V4 exact-in `Amount`; a V2/V3 or `WRAP_ETH`
amount-slot strings below says where. `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
@@ -950,29 +950,18 @@ 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 screens that carry a figure forward) shows one of a fixed set of strings, not
always a number: always a number:
- A formatted quantity, e.g. `17.1900 USDT`: the token's scale is known. A - A formatted quantity, e.g. `17.1900 USDT`: the token's scale is known and the
figure at or above the floor is truncated to four decimals, one below it is figure is at or above the floor, or below it and extended to its first
extended to its first significant digit, and a genuine zero renders `0.0000`, significant digit. This is `truncateAmountNeverZero()`
e.g. `0.0000 USDT`. That zero reaches the ERC-20 `approve`/`transfer` line for
an amount of zero, and the swap's `Amount` line when the step that sets it
states zero: a Permit2 permit, a V2 or V3 exact-in `amountIn`, or a `WRAP_ETH`
(`0.0000 ETH`). This is `truncateAmountNeverZero()`
(`src/shared/amountDisplay.js`). (`src/shared/amountDisplay.js`).
- `Unlimited`: named rather than formatted, since it needs no scale to describe. - `Unlimited`: an unbounded allowance or permit, which needs no scale to
On the ERC-20 line it is an `approve` of the `uint256` maximum, an unbounded describe — a `uint256`-max ERC-20 `approve` (`src/popup/views/approval.js`) or
allowance (`src/popup/views/approval.js`); `transfer` has no such case. On a a Permit2 amount at the `uint160` max on a swap's `Amount`
swap's `Amount` line it is any input amount at or above the `uint160` maximum, (`src/shared/uniswap.js`).
whichever step set the line (`src/shared/uniswap.js`): a Permit2 permit at
that maximum, which is an unbounded permit, or a V2 or V3 exact-in `amountIn`
or `WRAP_ETH` amount that large. That includes the router's whole-balance
value (`2^255`), which tells the router to use all it holds of the token — a
quantity decided at execution, not an allowance.
- `All available (V4 open delta)`: a V4 exact-in swap whose `amountIn` is zero. - `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 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, and only when the calldata states no quantity at all. Swap `Amount` line only
that V4 step is the one that sets the line. A Permit2 permit sets it instead (`src/shared/uniswap.js`).
whenever one is among the steps, and so does any earlier swap or `WRAP_ETH`
step; the line then shows what that step stated (`src/shared/uniswap.js`).
- `None (no minimum guaranteed)`: a zero minimum — the swap guarantees nothing - `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 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 reaches a `BALANCE_CHECK_ERC20` step: a zero `minBalance`, which once rendered
+11 -23
View File
@@ -143,32 +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
and never persisted ([#398](https://git.eeqj.de/sneak/AutistMask/issues/398)).
It was created once and stored, then announced verbatim to every page on every
load and across restarts, so any site — connected or not — could read it as a
stable cross-site, cross-session identifier for the install, contradicting the
"no tracking" promise. inpage.js now announces a per-load
`crypto.randomUUID()` and the `eip6963Uuid` storage key and the
`AUTISTMASK_PROVIDER_UUID` content-script message are gone. That key was a
standalone storage entry, never part of the versioned `autistmask` profile, so
the state schema is untouched and no existing profile is affected.
- 2026-09-21: `README.md` now documents the approval screen's amount-slot - 2026-09-21: `README.md` now documents the approval screen's amount-slot
vocabulary and no longer contradicts itself vocabulary and no longer contradicts itself
([#369](https://git.eeqj.de/sneak/AutistMask/issues/369)). The stale claim ([#369](https://git.eeqj.de/sneak/AutistMask/issues/369)). The stale claim
that a genuine zero always renders `0.0000` is corrected: a zero with a known that a genuine zero still renders `0.0000` is corrected: it holds for the
scale does, except where the swap screen states it in words first — ERC-20 amount, and also for the swap's `Amount` line on a literal-zero
`Min. received` always, and the `Amount` line only when a V4 exact-in of zero `amountIn` in a V2/V3 exact-in swap or a zero `WRAP_ETH`. Only two zeros are
is the step that sets it. A Permit2 permit sets that line whenever one is stated in words upstream — the swap's `Min. received` (any zero minimum) and
present, so a zero permit, a zero V2/V3 exact-in `amountIn` or a zero its V4 exact-in `Amount` (an `amountIn` of zero, V4's open delta). The
`WRAP_ETH` still reads `0.0000`. The amount-display section now names every amount-display section now names every string a slot can show — a formatted
string a slot can show and when — a formatted quantity (a known-scale zero quantity, `Unlimited`, `All available (V4 open delta)`,
included), `Unlimited` (on a swap also the router's whole-balance value), `None (no minimum guaranteed)`, base units with decimals unknown, and
`All available (V4 open delta)`, `None (no minimum guaranteed)`, base units `Unknown (not named in the calldata)` — and records that a zero `minBalance`
with decimals unknown, and `Unknown (not named in the calldata)` — and records on a `BALANCE_CHECK_ERC20` step now reads `None (no minimum guaranteed)` where
that a zero `minBalance` on a `BALANCE_CHECK_ERC20` step now reads it once read `0.0000`. Docs only; each claim checked against the tree.
`None (no minimum guaranteed)` where it once read `0.0000`. Docs only.
- 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;
+13 -9
View File
@@ -45,7 +45,7 @@
} }
// Listen for responses from the content script // Listen for responses from the content script
window.addEventListener("message", (event) => { window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return; if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_RESPONSE") return; if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
const { id, result, error } = event.data; const { id, result, error } = event.data;
@@ -60,7 +60,7 @@
}); });
// Listen for events pushed from the extension // Listen for events pushed from the extension
window.addEventListener("message", (event) => { window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return; if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return; if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data; const { eventName, data } = event.data;
@@ -204,13 +204,7 @@
"</svg>", "</svg>",
); );
// EIP-6963 asks for one UUIDv4 per provider for the life of the page: one let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
// per page load, shared by every announcement in that load. 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 {
@@ -232,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),
); );
-118
View File
@@ -1,118 +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 asks for one UUIDv4 per page load, shared by every announcement in
// that load. The fix generates it per page load and stores nothing.
//
// The stored UUID reached inpage.js as an AUTISTMASK_PROVIDER_UUID page
// message from the content script, and inpage.js then announced it. Each load
// below is posted the same stored UUID that way, so on the old code both loads
// announce it and the last two tests fail.
//
// 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;
}
}
// What the old content script read out of extension storage and posted to
// every page load.
const STORED_UUID = "11111111-2222-4333-8444-555555555555";
// Evaluate inpage.js once against a fresh stub window, post it STORED_UUID the
// way the old content script did, then dispatch a `requestProvider` event so a
// re-announcement is observed as well as the announcement at load. Returns
// every UUID the load announced, in order.
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({
type: "message",
source: win,
data: { type: "AUTISTMASK_PROVIDER_UUID", uuid: STORED_UUID },
});
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", () => {
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, neither the stored one", () => {
const first = announcedUuids();
const second = announcedUuids();
expect(first).not.toContain(STORED_UUID);
expect(second).not.toContain(STORED_UUID);
expect(second[0]).not.toBe(first[0]);
});
});