Compare commits

..
Author SHA1 Message Date
clawbot f1a13a50a6 fix: the defective xprv wallet notice names the keys that still reach its addresses (closes #255)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The notice told the user that these addresses are reachable by software
that repeats the same derivation, while the same wallet blocks Export
Private Key, so there was no way to take that route from here. The
second paragraph now names private key export among the disabled
controls and says the addresses can be reached outside this wallet with
the extended private key the user imported, if they kept a copy of it,
or with the master extended private key or recovery phrase it came
from. No behaviour changes. A test checks the new wording.

Model: opus-5-5
2026-10-14 11:48:46 +02:00
6 changed files with 48 additions and 264 deletions
+9 -15
View File
@@ -1110,16 +1110,14 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for reads `Unknown (not named in the calldata)` when the decoder found no token for
that side, or `Unknown (could not be decoded)` instead when a V4 swap action in that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
the transaction could not be read (see Transaction Decoding below). An is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
`UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH, on `Min. received` figure then reads in ETH. Otherwise `Token Out` and
mainnet or Sepolia, or when no step set the output side; a WETH `Min. received` `Min. received` keep the output side's own token and figure, whether the swap
figure then reads in ETH. Otherwise `Token Out` and `Min. received` keep the was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
output side's own token and figure, whether the swap was paid in ETH or in a unwraps the WETH it did not spend shows USDC. The token permission warning on
token: a V2 exact-out swap that buys USDC and then unwraps the WETH it did not the signature screen has its own amount wording, including `Unknown`; the
spend shows USDC. The token permission warning on the signature screen has its SignApproval section below describes it.
own amount wording, including `Unknown`; the SignApproval section below
describes it.
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g. The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00 `2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
@@ -2692,11 +2690,7 @@ Currently supported:
or recipient, and amount. or recipient, and amount.
- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g. - **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g.
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline. "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks.
action is read only in the parameter shape deployed on mainnet and Sepolia,
not in the newer one on `v4-periphery` `main`, which adds a `minHopPriceX36`
field. An action in any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`.
### Non-Goals Forever ### Non-Goals Forever
+16 -58
View File
@@ -53,11 +53,6 @@ function formatAmount(raw, decimals) {
// on a scale — not as a token name, and not as a quantity. // on a scale — not as a token name, and not as a quantity.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// The same refusal when a V4 swap action could not be read (see
// decodeExactly()). That action names both of its currencies, so saying the
// calldata did not name one would be false.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// Explicit presence, never truthiness. Every gate in this file that guards a // Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an // decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent // amount of 0n is, and a gate that cannot tell a genuine zero from an absent
@@ -300,24 +295,6 @@ function v4ExactInAmount(raw) {
return raw === 0n ? OPEN_DELTA : raw; return raw === 0n ? OPEN_DELTA : raw;
} }
// The V4 swap actions are read only in the parameter structs V4Router has
// deployed on mainnet and Sepolia. v4-periphery `main` has since added a
// `minHopPriceX36` field to all four (`src/interfaces/IV4Router.sol`), and
// reading a shape nobody has deployed would be guessing. ethers alone would
// read the newer structs anyway: it takes whatever word sits where a
// deployed field would be, ignores bytes left over, and holds back an error
// in a field until that field is used. The newer multi-hop exact-in struct
// then yields the offset of its hop-price array as `amountIn`. So a decode
// counts only when encoding the result again gives back the same bytes;
// otherwise this throws, and decodeV4Swap() marks the step as not decoded.
function decodeExactly(types, data) {
const decoded = coder.decode(types, data);
if (coder.encode(types, decoded) !== data) {
throw new Error("Not in the deployed V4 parameter shape.");
}
return decoded;
}
// Decode V4_SWAP (command 0x10) input bytes. // Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params). // The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions, // We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
@@ -332,10 +309,6 @@ function decodeV4Swap(input) {
let takeToken = null; let takeToken = null;
let amountIn = null; let amountIn = null;
let amountOutMin = null; let amountOutMin = null;
// Set when a swap action's parameters are not in the deployed shape
// (see decodeExactly()), so nothing was read from that action. SETTLE
// and TAKE may still name both currencies.
let swapUndecoded = false;
for (let i = 0; i < actions.length; i++) { for (let i = 0; i < actions.length; i++) {
const actionId = actions[i]; const actionId = actions[i];
@@ -364,7 +337,7 @@ function decodeV4Swap(input) {
// tuple(address,uint24,int24,address,bytes)[] path, // tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin) // uint128 amountIn, uint128 amountOutMin)
try { try {
const s = decodeExactly( const s = coder.decode(
[ [
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
], ],
@@ -382,13 +355,13 @@ function decodeV4Swap(input) {
amountOutMin = s[0][3]; amountOutMin = s[0][3];
} }
} catch { } catch {
swapUndecoded = true; // Fall through — SETTLE/TAKE will provide tokens
} }
} else { } else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey, // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData) // bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try { try {
const s = decodeExactly( const s = coder.decode(
[ [
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
], ],
@@ -411,7 +384,7 @@ function decodeV4Swap(input) {
amountOutMin = s[0][3]; amountOutMin = s[0][3];
} }
} catch { } catch {
swapUndecoded = true; // Fall through
} }
} }
} else if ( } else if (
@@ -420,7 +393,7 @@ function decodeV4Swap(input) {
) { ) {
if (actionId === V4_SWAP_EXACT_OUT) { if (actionId === V4_SWAP_EXACT_OUT) {
try { try {
const s = decodeExactly( const s = coder.decode(
[ [
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
], ],
@@ -432,11 +405,11 @@ function decodeV4Swap(input) {
settleToken = path[path.length - 1][0]; settleToken = path[path.length - 1][0];
} }
} catch { } catch {
swapUndecoded = true; // Fall through
} }
} else { } else {
try { try {
const s = decodeExactly( const s = coder.decode(
[ [
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
], ],
@@ -453,7 +426,7 @@ function decodeV4Swap(input) {
? poolKey[1] ? poolKey[1]
: poolKey[0]; : poolKey[0];
} catch { } catch {
swapUndecoded = true; // Fall through
} }
} }
} }
@@ -467,7 +440,6 @@ function decodeV4Swap(input) {
tokenOut: takeToken, tokenOut: takeToken,
amountIn, amountIn,
amountOutMin, amountOutMin,
swapUndecoded,
}; };
} catch { } catch {
return null; return null;
@@ -493,7 +465,6 @@ function decode(data, toAddress, sources) {
let minOutput = null; let minOutput = null;
let hasUnwrapWeth = false; let hasUnwrapWeth = false;
let hasV2ExactOut = false; let hasV2ExactOut = false;
let swapUndecoded = false;
const commandNames = []; const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come // THE INVARIANT: an amount and the token it is counted in always come
@@ -597,12 +568,6 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x10) { if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
if (v4) { if (v4) {
// Without this, a swap action that went unread would
// look like one that had nothing to show.
if (v4.swapUndecoded) {
swapUndecoded = true;
commandNames[i] += " (could not be decoded)";
}
setInputOnce(v4.tokenIn, v4.amountIn); setInputOnce(v4.tokenIn, v4.amountIn);
// Always update output: last swap step wins. A step // Always update output: last swap step wins. A step
// that carries the Min. received figure but decoded no // that carries the Min. received figure but decoded no
@@ -626,13 +591,12 @@ function decode(data, toAddress, sources) {
} }
} }
// Resolve token info. A null token on either side means no step that // Resolve token info. A null token on either side means the calldata
// was read named a currency for it; tokenInfo() refuses rather than // named no currency for it; tokenInfo() refuses rather than calling it
// calling it ETH. UNWRAP_WETH turns WETH into ETH, so it makes the // ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
// output ETH when the output side is WETH, or when no step set the // when the output side is WETH, or when no step set the output side.
// output side. Any other output keeps its own token and figure: a swap // Any other output keeps its own token and figure: a swap that buys
// that buys USDC and then unwraps the WETH it did not spend receives // USDC and then unwraps the WETH it did not spend receives USDC.
// USDC.
const outputIsWeth = const outputIsWeth =
present(outputToken) && present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase()); WETH_ADDRESSES.includes(outputToken.toLowerCase());
@@ -676,10 +640,7 @@ function decode(data, toAddress, sources) {
// rather than going missing or naming a token by default. Same // rather than going missing or naming a token by default. Same
// wording as the Token Out refusal below: the two sides of this // wording as the Token Out refusal below: the two sides of this
// screen must not describe the same condition in two ways. // screen must not describe the same condition in two ways.
details.push({ details.push({ label: "Token In", value: UNNAMED_CURRENCY });
label: "Token In",
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
});
} }
if (present(inputAmount)) { if (present(inputAmount)) {
@@ -742,10 +703,7 @@ function decode(data, toAddress, sources) {
// rather than going missing or naming a token by default, and a // rather than going missing or naming a token by default, and a
// Min. received figure below it is never attached to a token the // Min. received figure below it is never attached to a token the
// calldata did not state. // calldata did not state.
details.push({ details.push({ label: "Token Out", value: UNNAMED_CURRENCY });
label: "Token Out",
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
});
} }
if (present(minOutput)) { if (present(minOutput)) {
+7 -5
View File
@@ -36,11 +36,13 @@ const DEFECTS = {
"derivation path beneath that key instead of from a master " + "derivation path beneath that key instead of from a master " +
"key, so the addresses listed here are not the ones that key " + "key, so the addresses listed here are not the ones that key " +
"produces under the standard path.", "produces under the standard path.",
"Signing and sending are disabled for this wallet. The addresses " + "Signing, sending and private key export are disabled for this " +
"do descend from the extended private key you imported, so " + "wallet. Anything these addresses hold can be reached outside " +
"anything they hold is still reachable by software that " + "this wallet with the extended private key you imported, if " +
"repeats the same non-standard derivation. Check them in a " + "you kept a copy of it, or with the master extended private " +
"block explorer before deciding what to do.", "key or recovery phrase it came from, in software that " +
"repeats the same non-standard derivation. Check the " +
"addresses in a block explorer before deciding what to do.",
"To see the addresses this key produces under the standard path, " + "To see the addresses this key produces under the standard path, " +
"import the master extended private key, or the recovery " + "import the master extended private key, or the recovery " +
"phrase it came from, as a new wallet. Nothing here has been " + "phrase it came from, as a new wallet. Nothing here has been " +
+3 -181
View File
@@ -163,110 +163,6 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
); );
} }
// Each V4 swap action's parameters in two shapes: the struct V4Router has
// deployed on mainnet and Sepolia, and the one v4-periphery `main` has since
// moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC.
const POOL_KEY = "tuple(address,address,uint24,int24,address)";
const PATH_KEY = "tuple(address,uint24,int24,address,bytes)";
const WETH_USDC_POOL = [WETH_ADDR, USDC_ADDR, 100, 1, ZERO_ADDR];
const ONE_WETH = 1000000000000000000n;
const V4_SWAP_ACTION_SHAPES = [
{
action: "SWAP_EXACT_IN_SINGLE",
id: 0x06,
deployed: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, "0x"]],
),
newer: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, 0n, "0x"]],
),
},
{
action: "SWAP_EXACT_IN",
id: 0x07,
deployed: coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
WETH_ADDR,
[[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]],
ONE_WETH,
990000n,
],
],
),
newer: coder.encode(
[`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`],
[
[
WETH_ADDR,
[[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]],
[],
ONE_WETH,
990000n,
],
],
),
},
{
action: "SWAP_EXACT_OUT_SINGLE",
id: 0x08,
deployed: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[WETH_USDC_POOL, true, 990000n, ONE_WETH, "0x"]],
),
newer: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
[[WETH_USDC_POOL, true, 990000n, ONE_WETH, 0n, "0x"]],
),
},
{
action: "SWAP_EXACT_OUT",
id: 0x09,
deployed: coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
USDC_ADDR,
[[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]],
990000n,
ONE_WETH,
],
],
),
newer: coder.encode(
[`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`],
[
[
USDC_ADDR,
[[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]],
[],
990000n,
ONE_WETH,
],
],
),
},
];
// Helper: execute() with one V4_SWAP step holding the swap action, then
// SETTLE of WETH and TAKE of USDC.
function v4SwapCalldata(actionId, swapParams) {
return buildExecute(
"0x10",
[
encodeV4Swap(new Uint8Array([actionId, V4_SETTLE_ID, V4_TAKE_ID]), [
swapParams,
encodeV4Settle(WETH_ADDR),
encodeV4Take(USDC_ADDR),
]),
],
9999999999n,
);
}
function detail(result, label) { function detail(result, label) {
return result.details.find((d) => d.label === label); return result.details.find((d) => d.label === label);
} }
@@ -307,83 +203,11 @@ describe("uniswap decoder", () => {
result.details.find((d) => d.label === "Min. received").value, result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH"); ).toBe("0.0002 ETH");
// Real mainnet calldata is in the deployed shape, so its V4 step is
// not said to be undecoded.
const steps = result.details.find((d) => d.label === "Steps"); const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toBe("Permit2 Permit → V4 Swap"); expect(steps.value).toContain("Permit2 Permit");
expect(steps.value).toContain("V4 Swap");
}); });
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the deployed shape is read",
({ id, deployed }) => {
const result = uniswap.decode(
v4SwapCalldata(id, deployed),
ROUTER_ADDR,
);
expect(detail(result, "Steps").value).toBe("V4 Swap");
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/358 — before it, all four
// newer structs decoded as the deployed ones without an error, and the
// newer SWAP_EXACT_IN showed the offset of its hop-price array as Amount
// ("0.0000000000000004 WETH") and its amountIn as Min. received.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer periphery shape is shown as not decoded",
({ id, newer }) => {
const result = uniswap.decode(
v4SwapCalldata(id, newer),
ROUTER_ADDR,
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
// SETTLE and TAKE keep their own shape and still name both sides.
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
},
);
// The decoder does not read SETTLE_ALL or TAKE_ALL, so here only the
// unread swap action names the currencies. The token lines must not say
// the calldata did not name them.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, says both tokens could not be decoded",
({ id, newer }) => {
const data = buildExecute(
"0x10",
[
// 0x0c is SETTLE_ALL (currency, maxAmount), 0x0f is
// TAKE_ALL (currency, minAmount).
encodeV4Swap(new Uint8Array([id, 0x0c, 0x0f]), [
newer,
coder.encode(
["address", "uint256"],
[WETH_ADDR, ONE_WETH],
),
coder.encode(
["address", "uint256"],
[USDC_ADDR, 990000n],
),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
},
);
test("decodes V2_SWAP_EXACT_IN with known tokens", () => { test("decodes V2_SWAP_EXACT_IN with known tokens", () => {
const data = buildExecute( const data = buildExecute(
"0x08", // V2_SWAP_EXACT_IN "0x08", // V2_SWAP_EXACT_IN
@@ -724,11 +548,9 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
// The V4 swap action's parameters are empty, so that step is said to
// be undecoded.
const steps = result.details.find((d) => d.label === "Steps"); const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toBe( expect(steps.value).toBe(
"Permit2 Permit \u2192 V4 Swap (could not be decoded) \u2192 Unwrap WETH", "Permit2 Permit \u2192 V4 Swap \u2192 Unwrap WETH",
); );
}); });
+1 -5
View File
@@ -97,11 +97,7 @@ describe("an execute() whose input currency never decoded", () => {
]); ]);
test("says the input token is unknown instead of naming ETH", () => { test("says the input token is unknown instead of naming ETH", () => {
// The swap action that would name it could not be read, so the line expect(detail(data(), "Token In").value).toBe(REFUSAL);
// says that rather than REFUSAL's "not named in the calldata".
expect(detail(data(), "Token In").value).toBe(
"Unknown (could not be decoded)",
);
}); });
test("keeps a Token In line, so the screen never omits what is paid", () => { test("keeps a Token In line, so the screen never omits what is paid", () => {
+12
View File
@@ -153,6 +153,18 @@ describe("the explanatory copy", () => {
expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i); expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i);
}); });
// The wallet holds the imported key but will not export it, so the copy
// must name every key the user may hold that reaches the addresses
// outside it.
test("it names the keys that reach the addresses outside this wallet", () => {
const body = defect.paragraphs.join(" ");
expect(body).toMatch(/private key export are disabled/);
expect(body).toMatch(
/with the extended private key you imported, if you kept a copy of it, or with the master extended private key or recovery phrase it came from, in software that repeats the same non-standard derivation/,
);
});
test("it says the wallet is not deleted and what the user can do", () => { test("it says the wallet is not deleted and what the user can do", () => {
const body = defect.paragraphs.join(" "); const body = defect.paragraphs.join(" ");