All checks were successful
check / check (push) Successful in 54s
script/lint ran `prettier --check .`, byte for byte what script/fmt-check
runs, so make check checked formatting twice and did no static analysis on
a cryptocurrency wallet. Two used-but-not-imported crashes shipped past it.
ESLint is pinned in package.json with @eslint/js recommended as the base and
a flat config in eslint.config.js. no-undef and no-unused-vars are restated
error-level so a future recommended-set change cannot downgrade them.
Globals are declared per tree rather than globally, because a too-wide set
hides the next unimported identifier: browser for the popup and content
scripts, service worker for src/background/ and src/shared/, browser for the
one documented POPUP ONLY module in src/shared/, jest for tests/, node for
build.js, and both for the e2e harnesses, which carry the callbacks they
ship into the page inline.
Two rules new to the recommended set are narrowed, and both would have cost
something to satisfy. no-useless-assignment is off for approval.js and
confirmTx.js only: it flags the `password = null` and `decryptedSecret =
null` wipes at 9 sites there, which are dead by construction — that is what
a best-effort wipe of decrypted key material is — and the rule's fix is to
delete the wipe. It stays on for the rest of the tree, so an ordinary dead
store elsewhere is still an error. preserve-caught-error is off tree-wide:
it would change what the wallet's error paths throw at 3 sites
(src/shared/balances.js 207 and 215, tests/e2e/firefox/run.js 131), and
adopting `{ cause }` is a decision of its own rather than a side effect of
turning a linter on, so new code is not held to it either pending that
decision.
Every remaining violation is fixed: 41 unused bindings and 53 undefined
identifiers. Unused catch bindings became `catch {`, which the repo already
used; the shared init(ctx) view signature keeps its parameter as _ctx in the
three views that do not read it. src/shared/uniswap.js keeps its unused
V2_SWAP_EXACT_OUT decoder behind a scoped disable, because deleting it would
widen the gap it represents rather than close it (#283). driver.js's waitFor
had a plain dead store in its `last` initializer, which the newly scoped
no-useless-assignment catches; the initializer is dropped.
Linting is containerized. script/lint builds the Dockerfile's new lint stage
so the ESLint deciding whether this repo is green is the pinned one and not
whatever the host has; AUTISTMASK_LINT_NATIVE, set only in that image, is
what makes make check inside the CI build lint in place instead of recursing
into docker, and a value set to anything else is now an error rather than a
silent fall-through to the docker path. The check stage takes a COPY --from=
lint dependency so a lint failure fails the whole build early rather than
racing it.
The lint stage roughly doubles the image build, which exposed script/test's
30s cap as marginal rather than a bound: on the first CI run to rebuild the
base stage cold it killed a healthy suite at 30.6s with nothing asserting
false. The cap is a guard against a hung suite, not a wall-clock budget, and
one a healthy suite can trip teaches "just run it again". It stays at 30s on
a host, where the suite runs in about 8s and REPO_POLICIES' figure holds,
and the Dockerfile raises it to 180s through AUTISTMASK_TEST_TIMEOUT for the
in-image run, which also pays a cold jest cache and shares the runner with
the rest of the build. script/test now names a timeout kill as one instead
of reporting it as a test failure, and skips the verbose rerun in that case,
which would only spend the same wall clock to be killed again.
No --fix anywhere in the lint path: make check remains non-mutating.
The README claim that a used-but-not-imported identifier is invisible to
make check, and the same claim in script/test-e2e, are no longer true and
are corrected.
511 lines
18 KiB
JavaScript
511 lines
18 KiB
JavaScript
// Decode Uniswap Universal Router execute() calldata into human-readable
|
|
// swap details. Designed to be extended with other DEX decoders later.
|
|
|
|
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
|
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
|
|
|
const coder = AbiCoder.defaultAbiCoder();
|
|
|
|
const ROUTER_IFACE = new Interface([
|
|
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
|
]);
|
|
|
|
// Universal Router command IDs (lower 5 bits of each command byte)
|
|
const COMMAND_NAMES = {
|
|
0x00: "V3 Swap (Exact In)",
|
|
0x01: "V3 Swap (Exact Out)",
|
|
0x02: "Permit2 Transfer",
|
|
0x03: "Permit2 Permit Batch",
|
|
0x04: "Sweep",
|
|
0x05: "Transfer",
|
|
0x06: "Pay Portion",
|
|
0x08: "V2 Swap (Exact In)",
|
|
0x09: "V2 Swap (Exact Out)",
|
|
0x0a: "Permit2 Permit",
|
|
0x0b: "Wrap ETH",
|
|
0x0c: "Unwrap WETH",
|
|
0x0d: "Permit2 Transfer Batch",
|
|
0x0e: "Balance Check",
|
|
0x10: "V4 Swap",
|
|
0x11: "V3 Position Mgr Permit",
|
|
0x12: "V3 Position Mgr Call",
|
|
0x13: "V4 Initialize Pool",
|
|
0x14: "V4 Position Mgr Call",
|
|
0x21: "Execute Sub-Plan",
|
|
};
|
|
|
|
function formatAmount(raw, decimals) {
|
|
const parts = formatUnits(raw, decimals).split(".");
|
|
if (parts.length === 1) return parts[0] + ".0000";
|
|
const dec = (parts[1] + "0000").slice(0, 4);
|
|
return parts[0] + "." + dec;
|
|
}
|
|
|
|
function tokenInfo(address) {
|
|
if (!address || address === "0x0000000000000000000000000000000000000000") {
|
|
return { symbol: "ETH", decimals: 18, address: null };
|
|
}
|
|
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
|
if (t) return { symbol: t.symbol, decimals: t.decimals, address };
|
|
return { symbol: null, decimals: 18, address };
|
|
}
|
|
|
|
// Decode PERMIT2_PERMIT (command 0x0a) input bytes.
|
|
// ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce),
|
|
// address spender, uint256 sigDeadline), bytes signature
|
|
function decodePermit2(input) {
|
|
try {
|
|
const d = coder.decode(
|
|
[
|
|
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
|
|
"bytes",
|
|
],
|
|
input,
|
|
);
|
|
return { token: d[0][0][0], amount: d[0][0][1], spender: d[0][1] };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes.
|
|
// ABI: (address owner, address token, uint256 minBalance)
|
|
function decodeBalanceCheck(input) {
|
|
try {
|
|
const d = coder.decode(["address", "address", "uint256"], input);
|
|
return { owner: d[0], token: d[1], minBalance: d[2] };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
|
|
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
|
// address[] path, bool payerIsUser)
|
|
function decodeV2SwapExactIn(input) {
|
|
try {
|
|
const d = coder.decode(
|
|
["address", "uint256", "uint256", "address[]", "bool"],
|
|
input,
|
|
);
|
|
return {
|
|
amountIn: d[1],
|
|
amountOutMin: d[2],
|
|
tokenIn: d[3][0],
|
|
tokenOut: d[3][d[3].length - 1],
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
|
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
|
// address[] path, bool payerIsUser)
|
|
//
|
|
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
|
|
// its command name and no token or amount detail. Kept for the fix, which is
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/283.
|
|
// eslint-disable-next-line no-unused-vars
|
|
function decodeV2SwapExactOut(input) {
|
|
try {
|
|
const d = coder.decode(
|
|
["address", "uint256", "uint256", "address[]", "bool"],
|
|
input,
|
|
);
|
|
return {
|
|
amountOut: d[1],
|
|
amountInMax: d[2],
|
|
tokenIn: d[3][0],
|
|
tokenOut: d[3][d[3].length - 1],
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Decode V3 swap path (packed: token(20) + fee(3) + token(20) ...)
|
|
function decodeV3Path(pathHex) {
|
|
const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
|
|
if (hex.length < 40) return null;
|
|
const tokenIn = "0x" + hex.slice(0, 40);
|
|
const tokenOut = "0x" + hex.slice(-40);
|
|
return { tokenIn, tokenOut };
|
|
}
|
|
|
|
// Decode V3_SWAP_EXACT_IN (command 0x00) input bytes.
|
|
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
|
// bytes path, bool payerIsUser)
|
|
function decodeV3SwapExactIn(input) {
|
|
try {
|
|
const d = coder.decode(
|
|
["address", "uint256", "uint256", "bytes", "bool"],
|
|
input,
|
|
);
|
|
const path = decodeV3Path(d[3]);
|
|
if (!path) return null;
|
|
return {
|
|
amountIn: d[1],
|
|
amountOutMin: d[2],
|
|
tokenIn: path.tokenIn,
|
|
tokenOut: path.tokenOut,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Decode WRAP_ETH (command 0x0b) input bytes.
|
|
// ABI: (address recipient, uint256 amount)
|
|
function decodeWrapEth(input) {
|
|
try {
|
|
const d = coder.decode(["address", "uint256"], input);
|
|
return { amount: d[1] };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// V4 inner action IDs
|
|
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
|
|
const V4_SWAP_EXACT_IN = 0x07;
|
|
const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
|
|
const V4_SWAP_EXACT_OUT = 0x09;
|
|
const V4_SETTLE = 0x0b;
|
|
const V4_TAKE = 0x0e;
|
|
|
|
// Decode V4_SWAP (command 0x10) input bytes.
|
|
// The input is ABI-encoded as (bytes actions, bytes[] params).
|
|
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
|
|
// and swap amounts from the swap sub-actions.
|
|
function decodeV4Swap(input) {
|
|
try {
|
|
const d = coder.decode(["bytes", "bytes[]"], input);
|
|
const actions = getBytes(d[0]);
|
|
const params = d[1];
|
|
|
|
let settleToken = null;
|
|
let takeToken = null;
|
|
let amountIn = null;
|
|
let amountOutMin = null;
|
|
|
|
for (let i = 0; i < actions.length; i++) {
|
|
const actionId = actions[i];
|
|
try {
|
|
if (actionId === V4_SETTLE) {
|
|
// SETTLE: (address currency, uint256 maxAmount, bool payerIsUser)
|
|
const s = coder.decode(
|
|
["address", "uint256", "bool"],
|
|
params[i],
|
|
);
|
|
settleToken = s[0];
|
|
} else if (actionId === V4_TAKE) {
|
|
// TAKE: (address currency, address recipient, uint256 amount)
|
|
const t = coder.decode(
|
|
["address", "address", "uint256"],
|
|
params[i],
|
|
);
|
|
takeToken = t[0];
|
|
} else if (
|
|
actionId === V4_SWAP_EXACT_IN ||
|
|
actionId === V4_SWAP_EXACT_IN_SINGLE
|
|
) {
|
|
// Extract amounts from exact-in swap actions
|
|
if (actionId === V4_SWAP_EXACT_IN) {
|
|
// ExactInputParams: (address currencyIn,
|
|
// tuple(address,uint24,int24,address,bytes)[] path,
|
|
// uint128 amountIn, uint128 amountOutMin)
|
|
try {
|
|
const s = coder.decode(
|
|
[
|
|
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
|
],
|
|
params[i],
|
|
);
|
|
if (!settleToken) settleToken = s[0][0];
|
|
const path = s[0][1];
|
|
if (path.length > 0 && !takeToken) {
|
|
takeToken = path[path.length - 1][0];
|
|
}
|
|
if (!amountIn) amountIn = s[0][2];
|
|
if (!amountOutMin) amountOutMin = s[0][3];
|
|
} catch {
|
|
// Fall through — SETTLE/TAKE will provide tokens
|
|
}
|
|
} else {
|
|
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
|
|
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
|
|
try {
|
|
const s = coder.decode(
|
|
[
|
|
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
|
],
|
|
params[i],
|
|
);
|
|
const poolKey = s[0][0];
|
|
const zeroForOne = s[0][1];
|
|
if (!settleToken)
|
|
settleToken = zeroForOne
|
|
? poolKey[0]
|
|
: poolKey[1];
|
|
if (!takeToken)
|
|
takeToken = zeroForOne
|
|
? poolKey[1]
|
|
: poolKey[0];
|
|
if (!amountIn) amountIn = s[0][2];
|
|
if (!amountOutMin) amountOutMin = s[0][3];
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
} else if (
|
|
actionId === V4_SWAP_EXACT_OUT ||
|
|
actionId === V4_SWAP_EXACT_OUT_SINGLE
|
|
) {
|
|
if (actionId === V4_SWAP_EXACT_OUT) {
|
|
try {
|
|
const s = coder.decode(
|
|
[
|
|
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
|
],
|
|
params[i],
|
|
);
|
|
if (!takeToken) takeToken = s[0][0];
|
|
const path = s[0][1];
|
|
if (path.length > 0 && !settleToken) {
|
|
settleToken = path[path.length - 1][0];
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
} else {
|
|
try {
|
|
const s = coder.decode(
|
|
[
|
|
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
|
],
|
|
params[i],
|
|
);
|
|
const poolKey = s[0][0];
|
|
const zeroForOne = s[0][1];
|
|
if (!settleToken)
|
|
settleToken = zeroForOne
|
|
? poolKey[0]
|
|
: poolKey[1];
|
|
if (!takeToken)
|
|
takeToken = zeroForOne
|
|
? poolKey[1]
|
|
: poolKey[0];
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Skip sub-actions we can't decode
|
|
}
|
|
}
|
|
|
|
return {
|
|
tokenIn: settleToken,
|
|
tokenOut: takeToken,
|
|
amountIn,
|
|
amountOutMin,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Try to decode a Universal Router execute() call.
|
|
// Returns { name, description, details } matching the format used by
|
|
// the approval UI, or null if the calldata is not a recognised execute().
|
|
function decode(data, toAddress) {
|
|
try {
|
|
const parsed = ROUTER_IFACE.parseTransaction({ data });
|
|
if (!parsed) return null;
|
|
|
|
const commandsBytes = getBytes(parsed.args[0]);
|
|
const inputs = parsed.args[1];
|
|
const deadline = parsed.args[2];
|
|
|
|
let inputToken = null;
|
|
let inputAmount = null;
|
|
let outputToken = null;
|
|
let minOutput = null;
|
|
let hasUnwrapWeth = false;
|
|
const commandNames = [];
|
|
|
|
for (let i = 0; i < commandsBytes.length; i++) {
|
|
const cmdId = commandsBytes[i] & 0x1f;
|
|
commandNames.push(
|
|
COMMAND_NAMES[cmdId] ||
|
|
"Command 0x" + cmdId.toString(16).padStart(2, "0"),
|
|
);
|
|
|
|
try {
|
|
if (cmdId === 0x0a) {
|
|
const p = decodePermit2(inputs[i]);
|
|
if (p) {
|
|
inputToken = p.token;
|
|
inputAmount = p.amount;
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x0e) {
|
|
const b = decodeBalanceCheck(inputs[i]);
|
|
if (b) {
|
|
outputToken = b.token;
|
|
minOutput = b.minBalance;
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x00) {
|
|
const s = decodeV3SwapExactIn(inputs[i]);
|
|
if (s) {
|
|
if (!inputToken) inputToken = s.tokenIn;
|
|
if (!inputAmount) inputAmount = s.amountIn;
|
|
// Always update output: in multi-step swaps (V3 → V4),
|
|
// the last swap step determines the final output token
|
|
// and minimum received amount.
|
|
outputToken = s.tokenOut;
|
|
minOutput = s.amountOutMin;
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x08) {
|
|
const s = decodeV2SwapExactIn(inputs[i]);
|
|
if (s) {
|
|
if (!inputToken) inputToken = s.tokenIn;
|
|
if (!inputAmount) inputAmount = s.amountIn;
|
|
outputToken = s.tokenOut;
|
|
minOutput = s.amountOutMin;
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x0b) {
|
|
const w = decodeWrapEth(inputs[i]);
|
|
if (w && !inputToken) {
|
|
inputToken =
|
|
"0x0000000000000000000000000000000000000000";
|
|
inputAmount = w.amount;
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x10) {
|
|
const v4 = decodeV4Swap(inputs[i]);
|
|
if (v4) {
|
|
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
|
|
if (!inputAmount && v4.amountIn)
|
|
inputAmount = v4.amountIn;
|
|
// Always update output: last swap step wins
|
|
if (v4.tokenOut) outputToken = v4.tokenOut;
|
|
if (v4.amountOutMin) minOutput = v4.amountOutMin;
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x0c) {
|
|
hasUnwrapWeth = true;
|
|
}
|
|
} catch {
|
|
// Skip commands we can't decode
|
|
}
|
|
}
|
|
|
|
// Resolve token info
|
|
const inInfo = tokenInfo(inputToken);
|
|
const outInfo = hasUnwrapWeth
|
|
? { symbol: "ETH", decimals: 18, address: null }
|
|
: tokenInfo(outputToken);
|
|
|
|
const inSymbol = inInfo.symbol;
|
|
const outSymbol = outInfo.symbol;
|
|
|
|
const name =
|
|
inSymbol && outSymbol
|
|
? "Swap " + inSymbol + " \u2192 " + outSymbol
|
|
: "Uniswap Swap";
|
|
|
|
const details = [];
|
|
|
|
details.push({
|
|
label: "Protocol",
|
|
value: "Uniswap Universal Router",
|
|
address: toAddress,
|
|
});
|
|
|
|
if (inputToken && inInfo.address) {
|
|
const label = inSymbol
|
|
? inSymbol + " (" + inputToken + ")"
|
|
: inputToken;
|
|
details.push({
|
|
label: "Token In",
|
|
value: label,
|
|
address: inputToken,
|
|
isToken: true,
|
|
});
|
|
} else if (inSymbol === "ETH") {
|
|
details.push({ label: "Token In", value: "ETH (native)" });
|
|
}
|
|
|
|
if (inputAmount !== null && inputAmount !== undefined) {
|
|
const maxUint160 = BigInt(
|
|
"0xffffffffffffffffffffffffffffffffffffffff",
|
|
);
|
|
const isUnlimited = inputAmount >= maxUint160;
|
|
const amountRaw = isUnlimited
|
|
? "Unlimited"
|
|
: formatAmount(inputAmount, inInfo.decimals);
|
|
const amountStr = isUnlimited
|
|
? "Unlimited"
|
|
: amountRaw + (inSymbol ? " " + inSymbol : "");
|
|
details.push({
|
|
label: "Amount",
|
|
value: amountStr,
|
|
rawValue: amountRaw,
|
|
});
|
|
}
|
|
|
|
if (outSymbol) {
|
|
if (outInfo.address) {
|
|
const label = outSymbol
|
|
? outSymbol + " (" + outputToken + ")"
|
|
: outputToken;
|
|
details.push({
|
|
label: "Token Out",
|
|
value: label,
|
|
address: outputToken,
|
|
isToken: true,
|
|
});
|
|
} else {
|
|
details.push({ label: "Token Out", value: outSymbol });
|
|
}
|
|
}
|
|
|
|
if (minOutput !== null && minOutput !== undefined) {
|
|
const minStr =
|
|
formatAmount(minOutput, outInfo.decimals) +
|
|
(outSymbol ? " " + outSymbol : "");
|
|
details.push({ label: "Min. received", value: minStr });
|
|
}
|
|
|
|
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
|
|
|
|
const deadlineDate = new Date(Number(deadline) * 1000);
|
|
details.push({
|
|
label: "Deadline",
|
|
value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
|
|
});
|
|
|
|
return {
|
|
name,
|
|
description: "Swap via Uniswap Universal Router",
|
|
details,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = { decode };
|