Implement personal_sign and eth_signTypedData_v4 message signing
All checks were successful
check / check (push) Successful in 4s

Replace stub error handlers with full approval flow for personal_sign,
eth_sign, eth_signTypedData_v4, and eth_signTypedData. Uses toolbar
popup only (no fallback window) and keeps sign approvals pending across
popup close/reopen cycles so the user can respond via the toolbar icon.
This commit is contained in:
2026-02-27 14:55:11 +07:00
parent 5af8a7873d
commit 9e45c75d29
8 changed files with 1102 additions and 103 deletions

View File

@@ -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)

View File

@@ -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;

View File

@@ -886,11 +886,7 @@
</div>
<div id="approve-tx-data-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="approve-tx-data"
class="text-xs break-all"
style="max-height: 4rem; overflow-y: auto"
></div>
<div id="approve-tx-data" class="text-xs break-all"></div>
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
@@ -917,6 +913,58 @@
</div>
</div>
<!-- ============ SIGNATURE APPROVAL ============ -->
<div id="view-approve-sign" class="view hidden">
<h2 class="font-bold mb-2">Signature Request</h2>
<p class="mb-2">
<span id="approve-sign-hostname" class="font-bold"></span>
wants you to sign a message.
</p>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Type</div>
<div id="approve-sign-type" class="text-xs font-bold"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div id="approve-sign-from" class="text-xs break-all"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Message</div>
<div
id="approve-sign-message"
class="text-xs break-all"
style="max-height: 12rem; overflow-y: auto"
></div>
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
type="password"
id="approve-sign-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div id="approve-sign-error" class="text-xs hidden mb-2"></div>
<div class="flex justify-between">
<button
id="btn-approve-sign"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Sign
</button>
<button
id="btn-reject-sign"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Reject
</button>
</div>
</div>
<!-- ============ SITE APPROVAL ============ -->
<div id="view-approve-site" class="view hidden">
<h2 class="font-bold mb-2">Connection Request</h2>

View File

@@ -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 += `<div class="mb-2"><div class="text-muted">Domain</div>`;
for (const [key, val] of Object.entries(data.domain)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(String(val))}</div>`;
}
html += `</div>`;
}
if (data.primaryType) {
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
html += `<div class="font-bold">${escapeHtml(data.primaryType)}</div></div>`;
}
if (data.message) {
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
for (const [key, val] of Object.entries(data.message)) {
const display =
typeof val === "object" ? JSON.stringify(val) : String(val);
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display)}</span></div>`;
}
html += `</div>`;
}
return html;
} catch {
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
}
}
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 };

View File

@@ -26,6 +26,7 @@ const VIEWS = [
"transaction",
"approve-site",
"approve-tx",
"approve-sign",
];
function $(id) {

View File

@@ -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",

333
src/shared/uniswap.js Normal file
View File

@@ -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 };

274
tests/uniswap.test.js Normal file
View File

@@ -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);
});
});