Implement personal_sign and eth_signTypedData_v4 message signing
All checks were successful
check / check (push) Successful in 4s
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -26,6 +26,7 @@ const VIEWS = [
|
||||
"transaction",
|
||||
"approve-site",
|
||||
"approve-tx",
|
||||
"approve-sign",
|
||||
];
|
||||
|
||||
function $(id) {
|
||||
|
||||
@@ -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
333
src/shared/uniswap.js
Normal 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 };
|
||||
Reference in New Issue
Block a user