2 Commits
Author SHA1 Message Date
sneak 95bdc19b9b docs: name every approval-screen amount string, fix stale zero claim (closes #369)
check / check (push) Successful in 1m43s
e2e / e2e-chrome (push) Successful in 2m7s
e2e / e2e-firefox (push) Successful in 52s
The README said a genuine zero always renders `0.0000`, beside a
`Min. received` example that now reads `None (no minimum guaranteed)`.
It now says which zeros are stated in words first: any zero
`Min. received`, and a zero V4 exact-in `amountIn` only when that step
sets the `Amount` line, since a Permit2 permit sets it whenever present.

A new list names every string an amount slot can show and when,
including a known-scale zero as `0.0000` and `Unlimited` for the
router's whole-balance value. It also records that a zero `minBalance`
on a `BALANCE_CHECK_ERC20` step now reads the no-minimum wording.
Docs only.

Model: opus-5-5
2026-10-03 23:35:54 +00:00
clawbot add11e57de security: announce a fresh EIP-6963 provider UUID per page load (closes #398)
check / check (push) Successful in 1m4s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 33s
The provider UUID was generated once, kept in extension storage and
announced to every page on every load, so any site could read it as a
stable identifier for the install across sites and browser restarts.

inpage.js now generates one UUID per page load, shared by every
announcement in that load, and stores nothing. The eip6963Uuid storage
key and the AUTISTMASK_PROVIDER_UUID content-script message are removed.
The key was never part of the versioned autistmask profile, so the state
schema is untouched. Two inpage.js message listeners lose the leftover
name onUuid.

The test posts each load the same stored UUID the way the old content
script did, and asserts that no load announces it and that two loads
announce different UUIDs.

Model: opus-5-5
2026-10-03 16:26:39 +02:00
6 changed files with 185 additions and 74 deletions
+30 -19
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` —
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
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).
genuine zero reaching this rule renders `0.0000`. The swap screen states a zero
in words before it gets here in only two places. Its `Min. received` line reads
`None (no minimum guaranteed)` for any zero minimum. 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 — but only when that V4
step is the one that sets the `Amount` line. A Permit2 permit sets it whenever
one is among the steps, so a swap carrying a permit shows the permit's amount
there and never the open-delta wording. Every other zero amount with a known
scale renders `0.0000`, the swap's `Amount` line included; the list of
amount-slot strings below says where.
The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
@@ -950,18 +950,29 @@ 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()`
- A formatted quantity, e.g. `17.1900 USDT`: the token's scale is known. A
figure at or above the floor is truncated to four decimals, one below it is
extended to its first significant digit, and a genuine zero renders `0.0000`,
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`).
- `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`).
- `Unlimited`: named rather than formatted, since it needs no scale to describe.
On the ERC-20 line it is an `approve` of the `uint256` maximum, an unbounded
allowance (`src/popup/views/approval.js`); `transfer` has no such case. On a
swap's `Amount` line it is any input amount at or above the `uint160` maximum,
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.
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`).
the calldata states no quantity at all. Swap `Amount` line only, and only when
that V4 step is the one that sets the line. A Permit2 permit sets it instead
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
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
+23 -11
View File
@@ -143,20 +143,32 @@ 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
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
vocabulary and no longer contradicts itself
([#369](https://git.eeqj.de/sneak/AutistMask/issues/369)). The stale claim
that a genuine zero still renders `0.0000` is corrected: it holds for the
ERC-20 amount, and also 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 upstream — the swap's `Min. received` (any zero minimum) and
its V4 exact-in `Amount` (an `amountIn` of zero, V4's open delta). The
amount-display section now names every string a 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)` — 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.
that a genuine zero always renders `0.0000` is corrected: a zero with a known
scale does, except where the swap screen states it in words first —
`Min. received` always, and the `Amount` line only when a V4 exact-in of zero
is the step that sets it. A Permit2 permit sets that line whenever one is
present, so a zero permit, a zero V2/V3 exact-in `amountIn` or a zero
`WRAP_ETH` still reads `0.0000`. The amount-display section now names every
string a slot can show and when — a formatted quantity (a known-scale zero
included), `Unlimited` (on a swap also the router's whole-balance value),
`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.
- 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
-26
View File
@@ -5,8 +5,6 @@ const {
hasBrowserNamespace,
runtimeApi,
sendMessage,
storageGet,
storageSet,
} = require("../shared/browserApi");
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
@@ -21,30 +19,6 @@ if (hasBrowserNamespace()) {
(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
window.addEventListener("message", (event) => {
if (event.source !== window) return;
+9 -13
View File
@@ -45,7 +45,7 @@
}
// Listen for responses from the content script
window.addEventListener("message", function onUuid(event) {
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
const { id, result, error } = event.data;
@@ -60,7 +60,7 @@
});
// Listen for events pushed from the extension
window.addEventListener("message", function onUuid(event) {
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data;
@@ -204,7 +204,13 @@
"</svg>",
);
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
// EIP-6963 asks for one UUIDv4 per provider for the life of the page: one
// 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() {
return {
@@ -226,16 +232,6 @@
);
}
// 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);
announceProvider();
+5 -5
View File
@@ -372,10 +372,10 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
STEP_TIMEOUT_MS,
);
// EIP-6963, asked of the provider itself. The announcement carries the
// uuid src/content/index.js reads out of extension storage — call site 1
// in the issue — and it has to name this extension and hand back the very
// object on window.ethereum.
// EIP-6963, asked of the provider itself. The announcement carries a
// UUIDv4 inpage.js generates fresh for this page load (nothing persists
// it — see issue #398) and has to name this extension and hand back the
// very object on window.ethereum.
const announced = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const onAnnounce = (e) => {
@@ -402,7 +402,7 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
);
assert(
typeof announced.uuid === "string" && announced.uuid.length === 36,
"the announcement carries no stored provider uuid: " +
"the announcement carries no provider uuid: " +
JSON.stringify(announced.uuid),
);
+118
View File
@@ -0,0 +1,118 @@
// 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]);
});
});