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:
66
README.md
66
README.md
@@ -437,20 +437,41 @@ transitions.
|
|||||||
#### TxApproval
|
#### TxApproval
|
||||||
|
|
||||||
- **When**: A connected website requests a transaction via
|
- **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**:
|
- **Elements**:
|
||||||
- "Transaction Request" heading
|
- "Transaction Request" heading
|
||||||
- Site hostname (bold) + "wants to send a transaction"
|
- 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
|
- 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)
|
- 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
|
- Password input
|
||||||
- "Confirm" / "Reject" buttons
|
- "Confirm" / "Reject" buttons
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Confirm" (with password) → closes popup (returns result to background)
|
- "Confirm" (with password) → closes popup (returns result to background)
|
||||||
- "Reject" → closes popup (returns rejection 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
|
### External Services
|
||||||
|
|
||||||
AutistMask is not a fully self-contained offline tool. It necessarily
|
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)
|
- View ERC-20 token balances (user adds token by contract address)
|
||||||
- Send ETH to an address
|
- Send ETH to an address
|
||||||
- Send ERC-20 tokens 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`)
|
- 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`)
|
- Sign messages (`personal_sign`, `eth_sign`)
|
||||||
- Lock/unlock with password
|
- Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`)
|
||||||
- Configurable RPC endpoint
|
- Human-readable transaction decoding (ERC-20, Uniswap Universal Router)
|
||||||
- Future: USD value display (and other fiat currencies)
|
- 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
|
### 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
|
designed as a sharp tool — users who understand the risks can configure the
|
||||||
wallet to show everything unfiltered, unix-style.
|
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
|
### Non-Goals
|
||||||
|
|
||||||
- Token swaps (use a DEX in the browser)
|
- Token swaps (use a DEX in the browser)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const {
|
|||||||
ETHEREUM_MAINNET_CHAIN_ID,
|
ETHEREUM_MAINNET_CHAIN_ID,
|
||||||
DEFAULT_RPC_URL,
|
DEFAULT_RPC_URL,
|
||||||
} = require("../shared/constants");
|
} = require("../shared/constants");
|
||||||
|
const { getBytes } = require("ethers");
|
||||||
const { state, loadState, saveState } = require("../shared/state");
|
const { state, loadState, saveState } = require("../shared/state");
|
||||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||||
const { debugFetch } = require("../shared/log");
|
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.
|
// 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) {
|
function requestTxApproval(origin, hostname, txParams) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = nextApprovalId++;
|
const id = nextApprovalId++;
|
||||||
@@ -159,41 +161,70 @@ function requestTxApproval(origin, hostname, txParams) {
|
|||||||
type: "tx",
|
type: "tx",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (actionApi && typeof actionApi.openPopup === "function") {
|
if (actionApi && typeof actionApi.setPopup === "function") {
|
||||||
actionApi.setPopup({
|
actionApi.setPopup({
|
||||||
popup: "src/popup/index.html?approval=" + id,
|
popup: "src/popup/index.html?approval=" + id,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
if (actionApi && typeof actionApi.openPopup === "function") {
|
||||||
try {
|
try {
|
||||||
const result = actionApi.openPopup();
|
const result = actionApi.openPopup();
|
||||||
if (result && typeof result.catch === "function") {
|
if (result && typeof result.catch === "function") {
|
||||||
result.catch(() => openApprovalWindow(id));
|
result.catch(() => {});
|
||||||
}
|
}
|
||||||
} 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) => {
|
runtime.onConnect.addListener((port) => {
|
||||||
if (port.name.startsWith("approval:")) {
|
if (port.name.startsWith("approval:")) {
|
||||||
const id = parseInt(port.name.split(":")[1], 10);
|
const id = parseInt(port.name.split(":")[1], 10);
|
||||||
port.onDisconnect.addListener(() => {
|
port.onDisconnect.addListener(() => {
|
||||||
const approval = pendingApprovals[id];
|
const approval = pendingApprovals[id];
|
||||||
if (approval) {
|
if (approval) {
|
||||||
if (approval.type === "tx") {
|
if (approval.type === "tx" || approval.type === "sign") {
|
||||||
approval.resolve({
|
// Keep pending — user can reopen the toolbar popup
|
||||||
error: {
|
return;
|
||||||
code: 4001,
|
|
||||||
message: "User rejected the request.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
approval.resolve({ approved: false, remember: false });
|
|
||||||
}
|
}
|
||||||
|
approval.resolve({ approved: false, remember: false });
|
||||||
delete pendingApprovals[id];
|
delete pendingApprovals[id];
|
||||||
}
|
}
|
||||||
resetPopupUrl();
|
resetPopupUrl();
|
||||||
@@ -390,18 +421,59 @@ async function handleRpc(method, params, origin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "personal_sign" || method === "eth_sign") {
|
if (method === "personal_sign" || method === "eth_sign") {
|
||||||
return {
|
const s = await getState();
|
||||||
error: { message: "Signing not yet implemented in AutistMask." },
|
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") {
|
if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
|
||||||
return {
|
const s = await getState();
|
||||||
error: {
|
const activeAddress = await getActiveAddress();
|
||||||
message:
|
if (!activeAddress)
|
||||||
"Typed data signing not yet implemented in AutistMask.",
|
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") {
|
if (method === "eth_sendTransaction") {
|
||||||
@@ -446,7 +518,13 @@ async function broadcastAccountsChanged() {
|
|||||||
}
|
}
|
||||||
// Reject and close any pending approval popups so they don't hang
|
// Reject and close any pending approval popups so they don't hang
|
||||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||||
|
if (approval.type === "tx" || approval.type === "sign") {
|
||||||
|
approval.resolve({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
approval.resolve({ approved: false, remember: false });
|
approval.resolve({ approved: false, remember: false });
|
||||||
|
}
|
||||||
if (approval.windowId) {
|
if (approval.windowId) {
|
||||||
windowsApi.remove(approval.windowId, () => {
|
windowsApi.remove(approval.windowId, () => {
|
||||||
if (runtime.lastError) {
|
if (runtime.lastError) {
|
||||||
@@ -514,7 +592,7 @@ if (windowsApi && windowsApi.onRemoved) {
|
|||||||
windowsApi.onRemoved.addListener((windowId) => {
|
windowsApi.onRemoved.addListener((windowId) => {
|
||||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||||
if (approval.windowId === windowId) {
|
if (approval.windowId === windowId) {
|
||||||
if (approval.type === "tx") {
|
if (approval.type === "tx" || approval.type === "sign") {
|
||||||
approval.resolve({
|
approval.resolve({
|
||||||
error: {
|
error: {
|
||||||
code: 4001,
|
code: 4001,
|
||||||
@@ -550,6 +628,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
resp.type = "tx";
|
resp.type = "tx";
|
||||||
resp.txParams = approval.txParams;
|
resp.txParams = approval.txParams;
|
||||||
}
|
}
|
||||||
|
if (approval.type === "sign") {
|
||||||
|
resp.type = "sign";
|
||||||
|
resp.signParams = approval.signParams;
|
||||||
|
}
|
||||||
sendResponse(resp);
|
sendResponse(resp);
|
||||||
} else {
|
} else {
|
||||||
sendResponse(null);
|
sendResponse(null);
|
||||||
@@ -624,6 +706,76 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return true;
|
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") {
|
if (msg.type === "AUTISTMASK_ACTIVE_CHANGED") {
|
||||||
broadcastAccountsChanged();
|
broadcastAccountsChanged();
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -886,11 +886,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="approve-tx-data-section" class="mb-3 hidden">
|
<div id="approve-tx-data-section" class="mb-3 hidden">
|
||||||
<div class="text-xs text-muted mb-1">Raw data</div>
|
<div class="text-xs text-muted mb-1">Raw data</div>
|
||||||
<div
|
<div id="approve-tx-data" class="text-xs break-all"></div>
|
||||||
id="approve-tx-data"
|
|
||||||
class="text-xs break-all"
|
|
||||||
style="max-height: 4rem; overflow-y: auto"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
@@ -917,6 +913,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ============ -->
|
<!-- ============ SITE APPROVAL ============ -->
|
||||||
<div id="view-approve-site" class="view hidden">
|
<div id="view-approve-site" class="view hidden">
|
||||||
<h2 class="font-bold mb-2">Connection Request</h2>
|
<h2 class="font-bold mb-2">Connection Request</h2>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
const { $, addressDotHtml, escapeHtml, showView } = require("./helpers");
|
const { $, addressDotHtml, escapeHtml, showView } = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
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 { ERC20_ABI } = require("../../shared/constants");
|
||||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
|
const uniswap = require("../../shared/uniswap");
|
||||||
|
|
||||||
const runtime =
|
const runtime =
|
||||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||||||
@@ -40,15 +41,15 @@ function etherscanTokenLink(address) {
|
|||||||
return `https://etherscan.io/token/${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.
|
// Returns { name, description, details } or null.
|
||||||
function decodeCalldata(data, toAddress) {
|
function decodeCalldata(data, toAddress) {
|
||||||
if (!data || data === "0x" || data.length < 10) return null;
|
if (!data || data === "0x" || data.length < 10) return null;
|
||||||
|
|
||||||
|
// Try ERC-20 (approve / transfer)
|
||||||
try {
|
try {
|
||||||
const parsed = erc20Iface.parseTransaction({ data });
|
const parsed = erc20Iface.parseTransaction({ data });
|
||||||
if (!parsed) return null;
|
if (parsed) {
|
||||||
|
|
||||||
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
|
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
|
||||||
const tokenSymbol = token ? token.symbol : null;
|
const tokenSymbol = token ? token.symbol : null;
|
||||||
const tokenDecimals = token ? token.decimals : 18;
|
const tokenDecimals = token ? token.decimals : 18;
|
||||||
@@ -80,7 +81,11 @@ function decodeCalldata(data, toAddress) {
|
|||||||
address: toAddress,
|
address: toAddress,
|
||||||
isToken: true,
|
isToken: true,
|
||||||
},
|
},
|
||||||
{ label: "Spender", value: spender, address: spender },
|
{
|
||||||
|
label: "Spender",
|
||||||
|
value: spender,
|
||||||
|
address: spender,
|
||||||
|
},
|
||||||
{ label: "Amount", value: amountStr },
|
{ label: "Amount", value: amountStr },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -110,11 +115,16 @@ function decodeCalldata(data, toAddress) {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not ERC-20 — fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Uniswap Universal Router
|
||||||
|
const routerResult = uniswap.decode(data, toAddress);
|
||||||
|
if (routerResult) return routerResult;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTxApproval(details) {
|
function showTxApproval(details) {
|
||||||
@@ -212,6 +222,86 @@ function showTxApproval(details) {
|
|||||||
showView("approve-tx");
|
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) {
|
function show(id) {
|
||||||
approvalId = id;
|
approvalId = id;
|
||||||
runtime.connect({ name: "approval:" + id });
|
runtime.connect({ name: "approval:" + id });
|
||||||
@@ -224,6 +314,10 @@ function show(id) {
|
|||||||
showTxApproval(details);
|
showTxApproval(details);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (details.type === "sign") {
|
||||||
|
showSignApproval(details);
|
||||||
|
return;
|
||||||
|
}
|
||||||
$("approve-hostname").textContent = details.hostname;
|
$("approve-hostname").textContent = details.hostname;
|
||||||
$("approve-address").innerHTML = approvalAddressHtml(
|
$("approve-address").innerHTML = approvalAddressHtml(
|
||||||
state.activeAddress,
|
state.activeAddress,
|
||||||
@@ -301,6 +395,48 @@ function init(ctx) {
|
|||||||
});
|
});
|
||||||
window.close();
|
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 };
|
module.exports = { init, show };
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const VIEWS = [
|
|||||||
"transaction",
|
"transaction",
|
||||||
"approve-site",
|
"approve-site",
|
||||||
"approve-tx",
|
"approve-tx",
|
||||||
|
"approve-sign",
|
||||||
];
|
];
|
||||||
|
|
||||||
function $(id) {
|
function $(id) {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ const { debugFetch } = require("./log");
|
|||||||
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
|
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
|
||||||
|
|
||||||
const TOKENS = [
|
const TOKENS = [
|
||||||
|
{
|
||||||
|
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
||||||
|
symbol: "WETH",
|
||||||
|
decimals: 18,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||||
symbol: "USDT",
|
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 };
|
||||||
274
tests/uniswap.test.js
Normal file
274
tests/uniswap.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user