diff --git a/README.md b/README.md
index 0a173f5..39c083d 100644
--- a/README.md
+++ b/README.md
@@ -437,20 +437,41 @@ transitions.
#### TxApproval
- **When**: A connected website requests a transaction via
- `eth_sendTransaction`. Opened in a separate popup by the background script.
+ `eth_sendTransaction`. Opened via the toolbar popup by the background script.
- **Elements**:
- "Transaction Request" heading
- Site hostname (bold) + "wants to send a transaction"
+ - Decoded action (if calldata is recognized): action name, token details,
+ amounts, steps, deadline (see Transaction Decoding)
- From: color dot + full address + etherscan link
- - To: color dot + full address + etherscan link (or "contract creation")
+ - To/Contract: color dot + full address + etherscan link (or "contract
+ creation"), token symbol label if known
- Value: amount in ETH (4 decimal places)
- - Data: raw transaction data (shown if present)
+ - Raw data: full calldata displayed inline (shown if present)
- Password input
- "Confirm" / "Reject" buttons
- **Transitions**:
- "Confirm" (with password) → closes popup (returns result to background)
- "Reject" → closes popup (returns rejection to background)
+#### SignApproval
+
+- **When**: A connected website requests a message signature via
+ `personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened via the toolbar
+ popup by the background script.
+- **Elements**:
+ - "Signature Request" heading
+ - Site hostname (bold) + "wants you to sign a message"
+ - Type: "Personal message" or "Typed data (EIP-712)"
+ - From: color dot + full address + etherscan link
+ - Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
+ message fields (EIP-712 typed data)
+ - Password input
+ - "Sign" / "Reject" buttons
+- **Transitions**:
+ - "Sign" (with password) → signs locally → closes popup (returns signature)
+ - "Reject" → closes popup (returns rejection to background)
+
### External Services
AutistMask is not a fully self-contained offline tool. It necessarily
@@ -578,13 +599,16 @@ project owner.
- View ERC-20 token balances (user adds token by contract address)
- Send ETH to an address
- Send ERC-20 tokens to an address
-- Receive ETH/tokens (display address, copy to clipboard)
+- Receive ETH/tokens (display address, copy to clipboard, QR code)
- Connect to web3 sites (EIP-1193 `eth_requestAccounts`)
-- Sign transactions requested by connected sites
+- Sign transactions requested by connected sites (`eth_sendTransaction`)
- Sign messages (`personal_sign`, `eth_sign`)
-- Lock/unlock with password
-- Configurable RPC endpoint
-- Future: USD value display (and other fiat currencies)
+- Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`)
+- Human-readable transaction decoding (ERC-20, Uniswap Universal Router)
+- ETH/USD and token/USD price display
+- Configurable RPC endpoint and Blockscout API
+- Address poisoning protection (spam token filtering, dust filtering, fraud
+ contract blocklist)
### Address Poisoning and Fake Token Transfer Attacks
@@ -672,6 +696,32 @@ indexes it as a real token transfer.
designed as a sharp tool — users who understand the risks can configure the
wallet to show everything unfiltered, unix-style.
+#### Transaction Decoding
+
+When a dApp asks the user to approve a transaction, AutistMask attempts to
+decode the calldata into a human-readable summary. This is purely a display
+convenience to help the user understand what they are signing — it is not
+endorsement, special treatment, or partnership with any protocol.
+
+AutistMask is a generic web3 wallet. It treats all dApps, protocols, and
+contracts equally. No contract gets special handling, priority, or integration
+beyond what is needed to show the user a legible confirmation screen. Our
+commitment is to the user, not to any service, site, or contract.
+
+Decoded transaction summaries are best-effort. If decoding fails, the raw
+calldata is displayed in full. The decoders live in self-contained modules under
+`src/shared/` (e.g. `uniswap.js`) so they can be added for common contracts
+without polluting wallet-specific code. Contributions of decoders for other
+widely-used contracts are welcome.
+
+Currently supported:
+
+- **ERC-20**: `approve()` and `transfer()` calls — shows token symbol, spender
+ or recipient, and amount.
+- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g.
+ "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
+ Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks.
+
### Non-Goals
- Token swaps (use a DEX in the browser)
diff --git a/src/background/index.js b/src/background/index.js
index 16f97e6..4d81256 100644
--- a/src/background/index.js
+++ b/src/background/index.js
@@ -6,6 +6,7 @@ const {
ETHEREUM_MAINNET_CHAIN_ID,
DEFAULT_RPC_URL,
} = require("../shared/constants");
+const { getBytes } = require("ethers");
const { state, loadState, saveState } = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch } = require("../shared/log");
@@ -148,6 +149,7 @@ function requestApproval(origin, hostname) {
}
// Open a tx-approval popup and return a promise that resolves with txHash or error.
+// Uses the toolbar popup only — no fallback window.
function requestTxApproval(origin, hostname, txParams) {
return new Promise((resolve) => {
const id = nextApprovalId++;
@@ -159,41 +161,70 @@ function requestTxApproval(origin, hostname, txParams) {
type: "tx",
};
- if (actionApi && typeof actionApi.openPopup === "function") {
+ if (actionApi && typeof actionApi.setPopup === "function") {
actionApi.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
+ }
+ if (actionApi && typeof actionApi.openPopup === "function") {
try {
const result = actionApi.openPopup();
if (result && typeof result.catch === "function") {
- result.catch(() => openApprovalWindow(id));
+ result.catch(() => {});
}
} catch {
- openApprovalWindow(id);
+ // openPopup unsupported — user clicks toolbar icon
}
- } else {
- openApprovalWindow(id);
}
});
}
-// Detect when an approval popup (browser-action) closes without a response
+// Open a sign-approval popup and return a promise that resolves with { signature } or { error }.
+// Uses the toolbar popup only — no fallback window. If openPopup() fails the
+// popup URL is still set, so the user can click the toolbar icon to respond.
+function requestSignApproval(origin, hostname, signParams) {
+ return new Promise((resolve) => {
+ const id = nextApprovalId++;
+ pendingApprovals[id] = {
+ origin,
+ hostname,
+ signParams,
+ resolve,
+ type: "sign",
+ };
+
+ if (actionApi && typeof actionApi.setPopup === "function") {
+ actionApi.setPopup({
+ popup: "src/popup/index.html?approval=" + id,
+ });
+ }
+ if (actionApi && typeof actionApi.openPopup === "function") {
+ try {
+ const result = actionApi.openPopup();
+ if (result && typeof result.catch === "function") {
+ result.catch(() => {});
+ }
+ } catch {
+ // openPopup unsupported — user clicks toolbar icon
+ }
+ }
+ });
+}
+
+// Detect when an approval popup (browser-action) closes without a response.
+// TX and sign approvals are NOT auto-rejected on disconnect because toolbar
+// popups naturally close on focus loss and the user can reopen them.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = parseInt(port.name.split(":")[1], 10);
port.onDisconnect.addListener(() => {
const approval = pendingApprovals[id];
if (approval) {
- if (approval.type === "tx") {
- approval.resolve({
- error: {
- code: 4001,
- message: "User rejected the request.",
- },
- });
- } else {
- approval.resolve({ approved: false, remember: false });
+ if (approval.type === "tx" || approval.type === "sign") {
+ // Keep pending — user can reopen the toolbar popup
+ return;
}
+ approval.resolve({ approved: false, remember: false });
delete pendingApprovals[id];
}
resetPopupUrl();
@@ -390,18 +421,59 @@ async function handleRpc(method, params, origin) {
}
if (method === "personal_sign" || method === "eth_sign") {
- return {
- error: { message: "Signing not yet implemented in AutistMask." },
- };
+ const s = await getState();
+ const activeAddress = await getActiveAddress();
+ if (!activeAddress)
+ return { error: { message: "No accounts available" } };
+
+ const hostname = extractHostname(origin);
+ const allowed = s.allowedSites[activeAddress] || [];
+ if (
+ !allowed.includes(hostname) &&
+ !connectedSites[origin + ":" + activeAddress]
+ ) {
+ return { error: { code: 4100, message: "Unauthorized" } };
+ }
+
+ // personal_sign: params[0]=message, params[1]=address
+ // eth_sign: params[0]=address, params[1]=message
+ const signParams =
+ method === "personal_sign"
+ ? { method, message: params[0], from: params[1] }
+ : { method, message: params[1], from: params[0] };
+
+ const decision = await requestSignApproval(
+ origin,
+ hostname,
+ signParams,
+ );
+ if (decision.error) return { error: decision.error };
+ return { result: decision.signature };
}
if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
- return {
- error: {
- message:
- "Typed data signing not yet implemented in AutistMask.",
- },
- };
+ const s = await getState();
+ const activeAddress = await getActiveAddress();
+ if (!activeAddress)
+ return { error: { message: "No accounts available" } };
+
+ const hostname = extractHostname(origin);
+ const allowed = s.allowedSites[activeAddress] || [];
+ if (
+ !allowed.includes(hostname) &&
+ !connectedSites[origin + ":" + activeAddress]
+ ) {
+ return { error: { code: 4100, message: "Unauthorized" } };
+ }
+
+ const signParams = { method, typedData: params[1], from: params[0] };
+ const decision = await requestSignApproval(
+ origin,
+ hostname,
+ signParams,
+ );
+ if (decision.error) return { error: decision.error };
+ return { result: decision.signature };
}
if (method === "eth_sendTransaction") {
@@ -446,7 +518,13 @@ async function broadcastAccountsChanged() {
}
// Reject and close any pending approval popups so they don't hang
for (const [id, approval] of Object.entries(pendingApprovals)) {
- approval.resolve({ approved: false, remember: false });
+ if (approval.type === "tx" || approval.type === "sign") {
+ approval.resolve({
+ error: { code: 4001, message: "User rejected the request." },
+ });
+ } else {
+ approval.resolve({ approved: false, remember: false });
+ }
if (approval.windowId) {
windowsApi.remove(approval.windowId, () => {
if (runtime.lastError) {
@@ -514,7 +592,7 @@ if (windowsApi && windowsApi.onRemoved) {
windowsApi.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId === windowId) {
- if (approval.type === "tx") {
+ if (approval.type === "tx" || approval.type === "sign") {
approval.resolve({
error: {
code: 4001,
@@ -550,6 +628,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
resp.type = "tx";
resp.txParams = approval.txParams;
}
+ if (approval.type === "sign") {
+ resp.type = "sign";
+ resp.signParams = approval.signParams;
+ }
sendResponse(resp);
} else {
sendResponse(null);
@@ -624,6 +706,76 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return true;
}
+ if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
+ const approval = pendingApprovals[msg.id];
+ if (!approval) return false;
+ delete pendingApprovals[msg.id];
+ resetPopupUrl();
+
+ if (!msg.approved) {
+ approval.resolve({
+ error: { code: 4001, message: "User rejected the request." },
+ });
+ return true;
+ }
+
+ (async () => {
+ try {
+ await loadState();
+ const activeAddress = await getActiveAddress();
+ let wallet, addrIndex;
+ for (const w of state.wallets) {
+ for (let i = 0; i < w.addresses.length; i++) {
+ if (w.addresses[i].address === activeAddress) {
+ wallet = w;
+ addrIndex = i;
+ break;
+ }
+ }
+ if (wallet) break;
+ }
+ if (!wallet) throw new Error("Wallet not found");
+ const decrypted = await decryptWithPassword(
+ wallet.encryptedSecret,
+ msg.password,
+ );
+ const signer = getSignerForAddress(
+ wallet,
+ addrIndex,
+ decrypted,
+ );
+
+ const sp = approval.signParams;
+ let signature;
+
+ if (sp.method === "personal_sign" || sp.method === "eth_sign") {
+ signature = await signer.signMessage(getBytes(sp.message));
+ } else {
+ // eth_signTypedData_v4 / eth_signTypedData
+ const typedData = JSON.parse(sp.typedData);
+ const { domain, types, message } = typedData;
+ // ethers handles EIP712Domain internally
+ delete types.EIP712Domain;
+ signature = await signer.signTypedData(
+ domain,
+ types,
+ message,
+ );
+ }
+
+ approval.resolve({ signature });
+ sendResponse({ signature });
+ } catch (e) {
+ const errMsg = e.shortMessage || e.message;
+ approval.resolve({
+ error: { message: errMsg },
+ });
+ sendResponse({ error: errMsg });
+ }
+ })();
+ return true;
+ }
+
if (msg.type === "AUTISTMASK_ACTIVE_CHANGED") {
broadcastAccountsChanged();
return false;
diff --git a/src/popup/index.html b/src/popup/index.html
index 534881f..848ce08 100644
--- a/src/popup/index.html
+++ b/src/popup/index.html
@@ -886,11 +886,7 @@
Connection Request
diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js
index c9732f9..509557f 100644
--- a/src/popup/views/approval.js
+++ b/src/popup/views/approval.js
@@ -1,9 +1,10 @@
const { $, addressDotHtml, escapeHtml, showView } = require("./helpers");
const { state, saveState } = require("../../shared/state");
-const { formatEther, formatUnits, Interface } = require("ethers");
+const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const txStatus = require("./txStatus");
+const uniswap = require("../../shared/uniswap");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -40,81 +41,90 @@ function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
-// Try to decode calldata using the ERC-20 ABI.
+// Try to decode calldata using known ABIs.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) {
if (!data || data === "0x" || data.length < 10) return null;
+ // Try ERC-20 (approve / transfer)
try {
const parsed = erc20Iface.parseTransaction({ data });
- if (!parsed) return null;
+ if (parsed) {
+ const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
+ const tokenSymbol = token ? token.symbol : null;
+ const tokenDecimals = token ? token.decimals : 18;
+ const contractLabel = tokenSymbol
+ ? tokenSymbol + " (" + toAddress + ")"
+ : toAddress;
- const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
- const tokenSymbol = token ? token.symbol : null;
- const tokenDecimals = token ? token.decimals : 18;
- const contractLabel = tokenSymbol
- ? tokenSymbol + " (" + toAddress + ")"
- : toAddress;
+ if (parsed.name === "approve") {
+ const spender = parsed.args[0];
+ const rawAmount = parsed.args[1];
+ const maxUint = BigInt(
+ "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ );
+ const isUnlimited = rawAmount === maxUint;
+ const amountStr = isUnlimited
+ ? "Unlimited"
+ : formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
+ (tokenSymbol ? " " + tokenSymbol : "");
- if (parsed.name === "approve") {
- const spender = parsed.args[0];
- const rawAmount = parsed.args[1];
- const maxUint = BigInt(
- "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
- );
- const isUnlimited = rawAmount === maxUint;
- const amountStr = isUnlimited
- ? "Unlimited"
- : formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
- (tokenSymbol ? " " + tokenSymbol : "");
+ return {
+ name: "Token Approval",
+ description: tokenSymbol
+ ? "Approve spending of your " + tokenSymbol
+ : "Approve spending of an ERC-20 token",
+ details: [
+ {
+ label: "Token",
+ value: contractLabel,
+ address: toAddress,
+ isToken: true,
+ },
+ {
+ label: "Spender",
+ value: spender,
+ address: spender,
+ },
+ { label: "Amount", value: amountStr },
+ ],
+ };
+ }
- return {
- name: "Token Approval",
- description: tokenSymbol
- ? "Approve spending of your " + tokenSymbol
- : "Approve spending of an ERC-20 token",
- details: [
- {
- label: "Token",
- value: contractLabel,
- address: toAddress,
- isToken: true,
- },
- { label: "Spender", value: spender, address: spender },
- { label: "Amount", value: amountStr },
- ],
- };
+ if (parsed.name === "transfer") {
+ const to = parsed.args[0];
+ const rawAmount = parsed.args[1];
+ const amountStr =
+ formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
+ (tokenSymbol ? " " + tokenSymbol : "");
+
+ return {
+ name: "Token Transfer",
+ description: tokenSymbol
+ ? "Transfer " + tokenSymbol
+ : "Transfer ERC-20 token",
+ details: [
+ {
+ label: "Token",
+ value: contractLabel,
+ address: toAddress,
+ isToken: true,
+ },
+ { label: "Recipient", value: to, address: to },
+ { label: "Amount", value: amountStr },
+ ],
+ };
+ }
}
-
- if (parsed.name === "transfer") {
- const to = parsed.args[0];
- const rawAmount = parsed.args[1];
- const amountStr =
- formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
- (tokenSymbol ? " " + tokenSymbol : "");
-
- return {
- name: "Token Transfer",
- description: tokenSymbol
- ? "Transfer " + tokenSymbol
- : "Transfer ERC-20 token",
- details: [
- {
- label: "Token",
- value: contractLabel,
- address: toAddress,
- isToken: true,
- },
- { label: "Recipient", value: to, address: to },
- { label: "Amount", value: amountStr },
- ],
- };
- }
-
- return null;
} catch {
- return null;
+ // Not ERC-20 — fall through
}
+
+ // Try Uniswap Universal Router
+ const routerResult = uniswap.decode(data, toAddress);
+ if (routerResult) return routerResult;
+
+ return null;
}
function showTxApproval(details) {
@@ -212,6 +222,86 @@ function showTxApproval(details) {
showView("approve-tx");
}
+function decodeHexMessage(hex) {
+ try {
+ const bytes = Uint8Array.from(
+ hex
+ .slice(2)
+ .match(/.{1,2}/g)
+ .map((b) => parseInt(b, 16)),
+ );
+ return toUtf8String(bytes);
+ } catch {
+ return null;
+ }
+}
+
+function formatTypedDataHtml(jsonStr) {
+ try {
+ const data = JSON.parse(jsonStr);
+ let html = "";
+
+ if (data.domain) {
+ html += `
Domain
`;
+ for (const [key, val] of Object.entries(data.domain)) {
+ html += `
${escapeHtml(key)}: ${escapeHtml(String(val))}
`;
+ }
+ html += `
`;
+ }
+
+ if (data.primaryType) {
+ html += `
Primary type
`;
+ html += `
${escapeHtml(data.primaryType)}
`;
+ }
+
+ if (data.message) {
+ html += `
Message
`;
+ for (const [key, val] of Object.entries(data.message)) {
+ const display =
+ typeof val === "object" ? JSON.stringify(val) : String(val);
+ html += `
${escapeHtml(key)}: ${escapeHtml(display)}
`;
+ }
+ html += `
`;
+ }
+
+ return html;
+ } catch {
+ return `
${escapeHtml(jsonStr)}
`;
+ }
+}
+
+function showSignApproval(details) {
+ const sp = details.signParams;
+
+ $("approve-sign-hostname").textContent = details.hostname;
+ $("approve-sign-from").innerHTML = approvalAddressHtml(sp.from);
+
+ const isTyped =
+ sp.method === "eth_signTypedData_v4" ||
+ sp.method === "eth_signTypedData";
+ $("approve-sign-type").textContent = isTyped
+ ? "Typed data (EIP-712)"
+ : "Personal message";
+
+ if (isTyped) {
+ $("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
+ } else {
+ const decoded = decodeHexMessage(sp.message);
+ if (decoded !== null) {
+ $("approve-sign-message").textContent = decoded;
+ } else {
+ $("approve-sign-message").textContent = sp.message;
+ }
+ }
+
+ $("approve-sign-password").value = "";
+ $("approve-sign-error").classList.add("hidden");
+ $("btn-approve-sign").disabled = false;
+ $("btn-approve-sign").classList.remove("text-muted");
+
+ showView("approve-sign");
+}
+
function show(id) {
approvalId = id;
runtime.connect({ name: "approval:" + id });
@@ -224,6 +314,10 @@ function show(id) {
showTxApproval(details);
return;
}
+ if (details.type === "sign") {
+ showSignApproval(details);
+ return;
+ }
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress,
@@ -301,6 +395,48 @@ function init(ctx) {
});
window.close();
});
+
+ $("btn-approve-sign").addEventListener("click", () => {
+ const password = $("approve-sign-password").value;
+ if (!password) {
+ $("approve-sign-error").textContent = "Please enter your password.";
+ $("approve-sign-error").classList.remove("hidden");
+ return;
+ }
+ $("approve-sign-error").classList.add("hidden");
+ $("btn-approve-sign").disabled = true;
+ $("btn-approve-sign").classList.add("text-muted");
+
+ runtime.sendMessage(
+ {
+ type: "AUTISTMASK_SIGN_RESPONSE",
+ id: approvalId,
+ approved: true,
+ password: password,
+ },
+ (response) => {
+ if (response && response.signature) {
+ window.close();
+ } else {
+ const msg =
+ (response && response.error) || "Signing failed.";
+ $("approve-sign-error").textContent = msg;
+ $("approve-sign-error").classList.remove("hidden");
+ $("btn-approve-sign").disabled = false;
+ $("btn-approve-sign").classList.remove("text-muted");
+ }
+ },
+ );
+ });
+
+ $("btn-reject-sign").addEventListener("click", () => {
+ runtime.sendMessage({
+ type: "AUTISTMASK_SIGN_RESPONSE",
+ id: approvalId,
+ approved: false,
+ });
+ window.close();
+ });
}
module.exports = { init, show };
diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js
index 82f92d4..db0aae8 100644
--- a/src/popup/views/helpers.js
+++ b/src/popup/views/helpers.js
@@ -26,6 +26,7 @@ const VIEWS = [
"transaction",
"approve-site",
"approve-tx",
+ "approve-sign",
];
function $(id) {
diff --git a/src/shared/tokenList.js b/src/shared/tokenList.js
index 1f79286..cef94bb 100644
--- a/src/shared/tokenList.js
+++ b/src/shared/tokenList.js
@@ -10,6 +10,11 @@ const { debugFetch } = require("./log");
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
const TOKENS = [
+ {
+ address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
+ symbol: "WETH",
+ decimals: 18,
+ },
{
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
symbol: "USDT",
diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js
new file mode 100644
index 0000000..1f7f041
--- /dev/null
+++ b/src/shared/uniswap.js
@@ -0,0 +1,333 @@
+// 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)
+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;
+ }
+}
+
+// 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 (!outputToken) outputToken = s.tokenOut;
+ if (!inputAmount) inputAmount = s.amountIn;
+ if (!minOutput) minOutput = s.amountOutMin;
+ }
+ }
+
+ if (cmdId === 0x08) {
+ const s = decodeV2SwapExactIn(inputs[i]);
+ if (s) {
+ if (!inputToken) inputToken = s.tokenIn;
+ if (!outputToken) outputToken = s.tokenOut;
+ if (!inputAmount) inputAmount = s.amountIn;
+ if (!minOutput) minOutput = s.amountOutMin;
+ }
+ }
+
+ if (cmdId === 0x0b) {
+ const w = decodeWrapEth(inputs[i]);
+ if (w && !inputToken) {
+ inputToken =
+ "0x0000000000000000000000000000000000000000";
+ inputAmount = w.amount;
+ }
+ }
+
+ 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 amountStr =
+ inputAmount >= maxUint160
+ ? "Unlimited"
+ : formatAmount(inputAmount, inInfo.decimals) +
+ (inSymbol ? " " + inSymbol : "");
+ details.push({ label: "Amount", value: amountStr });
+ }
+
+ 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 };
diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js
new file mode 100644
index 0000000..b0bddec
--- /dev/null
+++ b/tests/uniswap.test.js
@@ -0,0 +1,274 @@
+const { AbiCoder, Interface, solidityPacked } = require("ethers");
+const uniswap = require("../src/shared/uniswap");
+
+const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
+const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
+const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
+const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
+
+// AutistMask's first-ever swap, 2026-02-27.
+// Swapped USDT for ETH via Uniswap V4 Universal Router.
+// https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77
+const FIRST_SWAP_CALLDATA =
+ "0x3593564c" +
+ "000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0" +
+ "0000000000000000000000000000000000000000000000000000000069a1550f00000000000000000000000000000000000000000000000000000000000000020a10000000000000000000000000000000000000000000000000000000000000" +
+ "0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
+ "0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff" +
+ "0000000000000000000000000000000000000000000000000000000069c8daf6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" +
+ "0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041" +
+ "230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
+ "00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" +
+ "070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" +
+ "00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" +
+ "0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e7" +
+ "0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064" +
+ "0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0" +
+ "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
+ "dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
+ "66133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
+
+const coder = AbiCoder.defaultAbiCoder();
+const routerIface = new Interface([
+ "function execute(bytes commands, bytes[] inputs, uint256 deadline)",
+]);
+
+// Helper: build a minimal execute() calldata from commands + inputs
+function buildExecute(commands, inputs, deadline) {
+ return routerIface.encodeFunctionData("execute", [
+ commands,
+ inputs,
+ deadline,
+ ]);
+}
+
+// Helper: encode a PERMIT2_PERMIT input (command 0x0a)
+function encodePermit2(token, amount, spender) {
+ return coder.encode(
+ [
+ "tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
+ "bytes",
+ ],
+ [[[token, amount, 0, 0], spender, 9999999999], "0x1234"],
+ );
+}
+
+// Helper: encode a BALANCE_CHECK_ERC20 input (command 0x0e)
+function encodeBalanceCheck(owner, token, minBalance) {
+ return coder.encode(
+ ["address", "address", "uint256"],
+ [owner, token, minBalance],
+ );
+}
+
+// Helper: encode a WRAP_ETH input (command 0x0b)
+function encodeWrapEth(recipient, amount) {
+ return coder.encode(["address", "uint256"], [recipient, amount]);
+}
+
+// Helper: encode a V2_SWAP_EXACT_IN input (command 0x08)
+function encodeV2SwapExactIn(recipient, amountIn, amountOutMin, pathAddrs) {
+ return coder.encode(
+ ["address", "uint256", "uint256", "address[]", "bool"],
+ [recipient, amountIn, amountOutMin, pathAddrs, true],
+ );
+}
+
+// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
+function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
+ // V3 path: token(20) + fee(3) + token(20) ...
+ let pathHex = pathTokens[0].slice(2).toLowerCase();
+ for (let i = 1; i < pathTokens.length; i++) {
+ pathHex += "000bb8"; // fee 3000 = 0x000bb8
+ pathHex += pathTokens[i].slice(2).toLowerCase();
+ }
+ return coder.encode(
+ ["address", "uint256", "uint256", "bytes", "bool"],
+ [recipient, amountIn, amountOutMin, "0x" + pathHex, true],
+ );
+}
+
+// Helper: encode a V4_SWAP input (command 0x10) — just a passthrough blob
+function encodeV4Swap(actions, params) {
+ return coder.encode(["bytes", "bytes[]"], [actions, params]);
+}
+
+describe("uniswap decoder", () => {
+ test("returns null for non-execute calldata", () => {
+ expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull();
+ expect(uniswap.decode("0xdeadbeef", ROUTER_ADDR)).toBeNull();
+ expect(uniswap.decode(null, ROUTER_ADDR)).toBeNull();
+ });
+
+ test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => {
+ const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR);
+
+ expect(result).not.toBeNull();
+ expect(result.name).toBe("Swap USDT \u2192 ETH");
+ expect(result.description).toContain("Uniswap");
+
+ const labels = result.details.map((d) => d.label);
+ expect(labels).toContain("Protocol");
+ expect(labels).toContain("Token In");
+ expect(labels).toContain("Steps");
+ expect(labels).toContain("Deadline");
+
+ const tokenIn = result.details.find((d) => d.label === "Token In");
+ expect(tokenIn.value).toContain("USDT");
+ expect(tokenIn.address.toLowerCase()).toBe(USDT_ADDR.toLowerCase());
+
+ const steps = result.details.find((d) => d.label === "Steps");
+ expect(steps.value).toContain("Permit2 Permit");
+ expect(steps.value).toContain("V4 Swap");
+ });
+
+ test("decodes V2_SWAP_EXACT_IN with known tokens", () => {
+ const data = buildExecute(
+ "0x08", // V2_SWAP_EXACT_IN
+ [
+ encodeV2SwapExactIn(
+ USER_ADDR,
+ 1000000n, // 1 USDT (6 decimals)
+ 500000000000000n, // 0.0005 ETH
+ [USDT_ADDR, WETH_ADDR],
+ ),
+ ],
+ 9999999999n,
+ );
+
+ const result = uniswap.decode(data, ROUTER_ADDR);
+ expect(result).not.toBeNull();
+ expect(result.name).toBe("Swap USDT \u2192 WETH");
+
+ const amount = result.details.find((d) => d.label === "Amount");
+ expect(amount.value).toBe("1.0000 USDT");
+
+ const minOut = result.details.find((d) => d.label === "Min. received");
+ expect(minOut.value).toContain("WETH");
+ });
+
+ test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
+ const data = buildExecute(
+ "0x00", // V3_SWAP_EXACT_IN
+ [
+ encodeV3SwapExactIn(
+ USER_ADDR,
+ 2000000n, // 2 USDT
+ 1000000000000000n, // 0.001 ETH
+ [USDT_ADDR, WETH_ADDR],
+ ),
+ ],
+ 9999999999n,
+ );
+
+ const result = uniswap.decode(data, ROUTER_ADDR);
+ expect(result).not.toBeNull();
+ expect(result.name).toBe("Swap USDT \u2192 WETH");
+ });
+
+ test("decodes WRAP_ETH as ETH input", () => {
+ const data = buildExecute(
+ "0x0b", // WRAP_ETH
+ [encodeWrapEth(ROUTER_ADDR, 1000000000000000000n)],
+ 9999999999n,
+ );
+
+ const result = uniswap.decode(data, ROUTER_ADDR);
+ expect(result).not.toBeNull();
+
+ const tokenIn = result.details.find((d) => d.label === "Token In");
+ expect(tokenIn.value).toBe("ETH (native)");
+
+ const amount = result.details.find((d) => d.label === "Amount");
+ expect(amount.value).toContain("1.0000");
+ expect(amount.value).toContain("ETH");
+ });
+
+ test("decodes UNWRAP_WETH as ETH output", () => {
+ const data = buildExecute(
+ solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
+ [
+ encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
+ USDT_ADDR,
+ WETH_ADDR,
+ ]),
+ encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding
+ ],
+ 9999999999n,
+ );
+
+ const result = uniswap.decode(data, ROUTER_ADDR);
+ expect(result).not.toBeNull();
+ // UNWRAP_WETH means output is native ETH
+ expect(result.name).toBe("Swap USDT \u2192 ETH");
+ });
+
+ test("decodes BALANCE_CHECK_ERC20 for min output", () => {
+ const data = buildExecute(
+ solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
+ [
+ encodeWrapEth(ROUTER_ADDR, 1000000000000000000n),
+ encodeBalanceCheck(USER_ADDR, USDT_ADDR, 2000000n),
+ ],
+ 9999999999n,
+ );
+
+ const result = uniswap.decode(data, ROUTER_ADDR);
+ expect(result).not.toBeNull();
+
+ const minOut = result.details.find((d) => d.label === "Min. received");
+ expect(minOut).toBeDefined();
+ expect(minOut.value).toContain("2.0000");
+ expect(minOut.value).toContain("USDT");
+ });
+
+ test("shows command names in steps", () => {
+ const data = buildExecute(
+ solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x10, 0x0c]),
+ [
+ encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR),
+ encodeV4Swap("0x07", ["0x"]),
+ encodeWrapEth(USER_ADDR, 0n), // reusing for UNWRAP_WETH
+ ],
+ 9999999999n,
+ );
+
+ const result = uniswap.decode(data, ROUTER_ADDR);
+ expect(result).not.toBeNull();
+
+ const steps = result.details.find((d) => d.label === "Steps");
+ expect(steps.value).toBe(
+ "Permit2 Permit \u2192 V4 Swap \u2192 Unwrap WETH",
+ );
+ });
+
+ test("formats permit amount when not unlimited", () => {
+ const data = buildExecute(
+ "0x0a",
+ [encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR)],
+ 9999999999n,
+ );
+
+ const result = uniswap.decode(data, ROUTER_ADDR);
+ expect(result).not.toBeNull();
+
+ const amount = result.details.find((d) => d.label === "Amount");
+ expect(amount.value).toBe("5.0000 USDT");
+ });
+
+ test("handles unknown tokens gracefully", () => {
+ const fakeToken = "0x1111111111111111111111111111111111111111";
+ const data = buildExecute(
+ "0x0a",
+ [encodePermit2(fakeToken, 1000000000000000000n, ROUTER_ADDR)],
+ 9999999999n,
+ );
+
+ const result = uniswap.decode(data, ROUTER_ADDR);
+ expect(result).not.toBeNull();
+ expect(result.name).toBe("Uniswap Swap");
+
+ const tokenIn = result.details.find((d) => d.label === "Token In");
+ expect(tokenIn.value).toContain(fakeToken);
+ });
+});