Compare commits

..

3 Commits

Author SHA1 Message Date
4977871121 fix: rework wallet deletion per review feedback
All checks were successful
check / check (push) Successful in 22s
- Remove all red/danger styling, use standard monochrome colors
- Add wallet picker dropdown so user can select which wallet to delete
- Fix encryptedSecret field name (was wallet.encrypted)
- Populate dropdown when settings view opens
- Confirmation modal uses standard border styling
2026-02-27 12:46:26 -08:00
d5849c831b fix: rework wallet deletion per review feedback
All checks were successful
check / check (push) Successful in 21s
- Remove all red/danger styling, use standard monochrome colors
- Add wallet picker dropdown instead of relying on selectedWallet
- Fix encryptedSecret field name (was wallet.encrypted)
- Populate dropdown when settings view opens
- Confirmation modal uses standard border styling
2026-02-27 12:44:40 -08:00
user
6e4dcb2e4f feat: add wallet deletion from settings (closes #13)
All checks were successful
check / check (push) Successful in 22s
2026-02-27 12:11:46 -08:00
28 changed files with 624 additions and 1657 deletions

1
.gitignore vendored
View File

@@ -25,4 +25,3 @@ dist/
# Yarn
.yarn-integrity
package-lock.json

View File

@@ -93,13 +93,11 @@ function resetPopupUrl() {
}
}
// Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals.
// Fallback: open approval in a separate window (used when openPopup is unavailable)
function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 360;
const popupHeight = 600;
const popupWidth = 400;
const popupHeight = 500;
windowsApi.getLastFocused((currentWin) => {
const opts = {
@@ -150,9 +148,7 @@ function requestApproval(origin, hostname) {
}
// Open a tx-approval popup and return a promise that resolves with txHash or error.
// Uses windows.create() directly because tx approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
// Uses the toolbar popup only — no fallback window.
function requestTxApproval(origin, hostname, txParams) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
@@ -164,14 +160,27 @@ function requestTxApproval(origin, hostname, txParams) {
type: "tx",
};
openApprovalWindow(id);
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
}
}
});
}
// Open a sign-approval popup and return a promise that resolves with { signature } or { error }.
// Uses windows.create() directly because sign approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
// 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 = crypto.randomUUID();
@@ -183,14 +192,27 @@ function requestSignApproval(origin, hostname, signParams) {
type: "sign",
};
openApprovalWindow(id);
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 now use windows.create() and are handled by the
// windowsApi.onRemoved listener below, but we still handle site-connection
// approval disconnects here.
// 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 = port.name.split(":")[1];

View File

@@ -13,26 +13,6 @@ if (typeof browser !== "undefined") {
(document.head || document.documentElement).appendChild(script);
}
// Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in chrome.storage.local.
(function sendProviderUuid() {
const storage =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
storage.get("eip6963Uuid", (items) => {
let uuid = items?.eip6963Uuid;
if (!uuid) {
uuid = crypto.randomUUID();
storage.set({ eip6963Uuid: uuid });
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
});
})();
// Relay requests from the page to the background script
window.addEventListener("message", (event) => {
if (event.source !== window) return;

View File

@@ -9,7 +9,7 @@
const pending = {};
// Listen for responses from the content script
window.addEventListener("message", function onUuid(event) {
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
const { id, result, error } = event.data;
@@ -24,7 +24,7 @@
});
// Listen for events pushed from the extension
window.addEventListener("message", function onUuid(event) {
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data;
@@ -134,7 +134,7 @@
// Some dApps (wagmi) check this to confirm MetaMask-like behavior
_metamask: {
isUnlocked() {
return Promise.resolve(provider.selectedAddress !== null);
return Promise.resolve(true);
},
},
};
@@ -155,38 +155,21 @@
"</svg>",
);
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
function buildProviderInfo() {
return {
uuid: providerUuid,
name: "AutistMask",
icon: ICON_SVG,
rdns: "berlin.sneak.autistmask",
};
}
const providerInfo = {
uuid: "f3c5b2a1-8d4e-4f6a-9c7b-1e2d3a4b5c6d",
name: "AutistMask",
icon: ICON_SVG,
rdns: "berlin.sneak.autistmask",
};
function announceProvider() {
window.dispatchEvent(
new CustomEvent("eip6963:announceProvider", {
detail: Object.freeze({
info: buildProviderInfo(),
provider,
}),
detail: Object.freeze({ info: providerInfo, provider }),
}),
);
}
// Listen for the persisted UUID from the content script
function onProviderUuid(event) {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_PROVIDER_UUID") return;
window.removeEventListener("message", onProviderUuid);
providerUuid = event.data.uuid;
announceProvider();
}
window.addEventListener("message", onProviderUuid);
window.addEventListener("eip6963:requestProvider", announceProvider);
announceProvider();
})();

View File

@@ -104,10 +104,6 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div
id="add-wallet-error"
class="text-xs min-h-[1.25rem] mb-2"
></div>
<button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -166,10 +162,6 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div
id="import-key-error"
class="text-xs min-h-[1.25rem] mb-2"
></div>
<button
id="btn-import-key-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -313,26 +305,6 @@
>
+ Token
</button>
<div class="relative">
<button
id="btn-more-menu"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
aria-label="More actions"
>
&middot;&middot;&middot;
</button>
<div
id="more-menu-dropdown"
class="hidden absolute right-0 top-full mt-1 border border-border bg-bg z-50 whitespace-nowrap py-1"
>
<button
id="btn-export-privkey"
class="block w-full text-left px-4 py-1.5 text-xs font-light text-muted hover:bg-hover hover:text-fg cursor-pointer"
>
Export Private Key
</button>
</div>
</div>
</div>
<!-- transactions -->
@@ -346,60 +318,6 @@
</div>
</div>
<!-- ============ EXPORT PRIVATE KEY VIEW ============ -->
<div id="view-export-privkey" class="view hidden">
<button
id="btn-export-privkey-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<div
id="export-privkey-jazzicon"
class="flex justify-center mt-1 mb-3"
></div>
<h2 class="font-bold mb-1">Export Private Key</h2>
<p class="text-xs mb-1" id="export-privkey-title"></p>
<p class="text-xs mb-3">
<span id="export-privkey-dot"></span>
<span
id="export-privkey-address"
class="cursor-pointer"
title="Click to copy"
></span>
</p>
<p class="text-xs mb-3 text-muted">
Warning: anyone with this private key can access and
transfer all funds from this address. Never share it.
</p>
<div
id="export-privkey-error"
class="text-xs min-h-[1.25rem] mb-2"
></div>
<div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="export-privkey-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue"
/>
<button
id="btn-export-privkey-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
>
Reveal
</button>
</div>
<div id="export-privkey-result" class="hidden">
<div
id="export-privkey-value"
class="bg-danger-well rounded p-2 font-mono text-xs break-all cursor-pointer mb-1"
title="Click to copy"
></div>
</div>
</div>
<!-- ============ ADDRESS-TOKEN DETAIL VIEW ============ -->
<div id="view-address-token" class="view hidden">
<button
@@ -440,6 +358,12 @@
</div>
</div>
<!-- token contract details (ERC-20 only) -->
<div
id="address-token-contract-info"
class="border-b border-border-light pb-2 mb-2 text-xs hidden"
></div>
<!-- actions -->
<div class="flex gap-2 mb-3">
<button
@@ -456,12 +380,6 @@
</button>
</div>
<!-- token contract details (ERC-20 only) -->
<div
id="address-token-contract-info"
class="bg-hover rounded-md mx-1 p-3 mb-3 text-xs hidden"
></div>
<!-- token-filtered transactions -->
<div class="mt-3">
<div class="border-b border-border pb-1 mb-1">
@@ -504,11 +422,6 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Address (0x...) or ENS name"
/>
<div
id="send-to-error"
class="text-xs"
style="min-height: 1.25rem; color: #cc0000"
></div>
</div>
<div class="mb-2">
<div class="flex justify-between mb-1">
@@ -618,7 +531,6 @@
<!-- ============ TX SUCCESS ============ -->
<div id="view-success-tx" class="view hidden">
<h2 class="font-bold mb-2">Transaction Confirmed</h2>
<div id="success-tx-decoded" class="mb-3 hidden text-xs"></div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="success-tx-summary" class="font-bold"></div>
@@ -724,10 +636,9 @@
<div class="flex justify-center mb-3">
<canvas id="receive-qr"></canvas>
</div>
<div
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
>
<span id="receive-address-block" class="select-all"></span>
<div class="border border-border p-2 break-all mb-3 text-xs">
<span id="receive-dot"></span>
<span id="receive-address" class="select-all"></span>
<span id="receive-etherscan-link"></span>
</div>
<button
@@ -930,41 +841,56 @@
</p>
<div id="settings-denied-sites"></div>
</div>
</div>
<!-- ============ DELETE WALLET CONFIRM ============ -->
<div id="view-delete-wallet-confirm" class="view hidden">
<button
id="btn-delete-wallet-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Delete Wallet</h2>
<p class="text-xs mb-3">
Deleting
<strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your recovery phrase.
</p>
<div class="border-t border-border-light pt-3 mt-3">
<h3 class="font-bold mb-2">Delete wallet</h3>
<label class="block text-xs text-muted mb-1"
>Select wallet to delete:</label
>
<select
id="delete-wallet-select"
class="border border-border p-1 w-full text-sm bg-bg text-fg mb-2"
></select>
<button
id="btn-delete-wallet"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Delete...
</button>
</div>
<div
id="delete-wallet-error"
class="text-xs min-h-[1.25rem] mb-2"
></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
id="delete-wallet-confirm"
class="hidden border border-border border-dashed p-3 mt-2 mb-3"
>
<h3 class="font-bold mb-1">Confirm Deletion</h3>
<p class="text-xs text-muted mb-2">
Delete <strong id="delete-wallet-name"></strong>? This
is permanent. Any funds will be unrecoverable without
your recovery phrase.
</p>
<p class="text-xs mb-2">Enter your password to confirm:</p>
<input
type="password"
id="delete-wallet-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to confirm"
class="border border-border p-1 w-full text-sm bg-bg text-fg mb-2"
placeholder="Password"
/>
<div class="flex gap-2">
<button
id="btn-delete-wallet-confirm"
class="border border-border text-fg px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Delete
</button>
<button
id="btn-delete-wallet-cancel"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Cancel
</button>
</div>
</div>
<button
id="btn-delete-wallet-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Confirm Delete
</button>
</div>
<!-- ============ SETTINGS: ADD TOKEN ============ -->
@@ -1042,13 +968,7 @@
>
&lt; Back
</button>
<h2 id="tx-detail-heading" class="font-bold mb-2">
Transaction
</h2>
<div id="tx-detail-type-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Type</div>
<div id="tx-detail-type" class="text-xs font-bold"></div>
</div>
<h2 class="font-bold mb-2">Transaction</h2>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Status</div>
<div id="tx-detail-status" class="text-xs"></div>
@@ -1073,33 +993,10 @@
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
</div>
<div id="tx-detail-calldata-section" class="mb-4 hidden">
<div
id="tx-detail-calldata-well"
class="mb-3 border border-border border-dashed p-2"
>
<div class="text-xs text-muted mb-1">Action</div>
<div
id="tx-detail-calldata-action"
class="text-xs font-bold mb-2"
></div>
<div
id="tx-detail-calldata-details"
class="text-xs"
></div>
</div>
</div>
<div class="mb-4">
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></div>
</div>
<div id="tx-detail-rawdata-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="tx-detail-rawdata"
class="text-xs break-all font-mono border border-border border-dashed p-2"
></div>
</div>
</div>
<!-- ============ TRANSACTION APPROVAL ============ -->
@@ -1147,10 +1044,7 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div
id="approve-tx-error"
class="text-xs min-h-[1.25rem] mb-2"
></div>
<div id="approve-tx-error" class="text-xs hidden mb-2"></div>
<div class="flex justify-between">
<button
id="btn-approve-tx"
@@ -1213,10 +1107,7 @@
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 min-h-[1.25rem] mb-2"
></div>
<div id="approve-sign-error" class="text-xs hidden mb-2"></div>
<div class="flex justify-between">
<button
id="btn-approve-sign"

View File

@@ -189,7 +189,7 @@ async function init() {
const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval");
if (approvalId) {
approval.show(approvalId);
approval.show(parseInt(approvalId, 10));
showView("approve-site");
return;
}

View File

@@ -11,7 +11,6 @@
--color-border-light: #cccccc;
--color-hover: #eeeeee;
--color-well: #f5f5f5;
--color-danger-well: #fef2f2;
--color-section: #dddddd;
}

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash, showError, hideError } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const {
generateMnemonic,
hdWalletFromMnemonic,
@@ -13,7 +13,6 @@ function show() {
$("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").classList.add("hidden");
hideError("add-wallet-error");
showView("add-wallet");
}
@@ -26,16 +25,14 @@ function init(ctx) {
$("btn-add-wallet-confirm").addEventListener("click", async () => {
const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) {
showError(
"add-wallet-error",
showFlash(
"Enter a recovery phrase or press the die to generate one.",
);
return;
}
const words = mnemonic.split(/\s+/);
if (words.length !== 12 && words.length !== 24) {
showError(
"add-wallet-error",
showFlash(
"Recovery phrase must be 12 or 24 words. You entered " +
words.length +
".",
@@ -43,27 +40,21 @@ function init(ctx) {
return;
}
if (!isValidMnemonic(mnemonic)) {
showError(
"add-wallet-error",
"Invalid recovery phrase. Check for typos.",
);
showFlash("Invalid recovery phrase. Check for typos.");
return;
}
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showError("add-wallet-error", "Please choose a password.");
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showError(
"add-wallet-error",
"Password must be at least 12 characters.",
);
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
showError("add-wallet-error", "Passwords do not match.");
showFlash("Passwords do not match.");
return;
}
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
@@ -75,8 +66,7 @@ function init(ctx) {
firstAddress.toLowerCase(),
);
if (duplicate) {
showError(
"add-wallet-error",
showFlash(
"This recovery phrase is already added (" +
duplicate.name +
").",

View File

@@ -2,11 +2,8 @@ const {
$,
showView,
showFlash,
showError,
hideError,
balanceLinesForAddress,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
} = require("./helpers");
@@ -17,15 +14,9 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const {
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { updateSendBalance, renderSendTokenSelect } = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
let ctx;
@@ -159,11 +150,11 @@ async function loadTransactions(address) {
loadedTxs = txs;
// Collect ALL unique addresses (from + to) for ENS resolution so
// that reverse lookups work for every displayed address, not just
// the ones that were originally entered as ENS names.
// Collect unique counterparty addresses for ENS resolution.
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
...new Set(
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
];
if (counterparties.length > 0) {
try {
@@ -194,23 +185,14 @@ function renderTransactions(txs) {
let html = "";
let i = 0;
for (const tx of txs) {
// For swap transactions, show the user's own labelled wallet
// address instead of the contract address (see issue #55).
const counterparty =
tx.direction === "contract" && tx.directionLabel === "Swap"
? tx.from
: tx.direction === "sent" || tx.direction === "contract"
? tx.to
: tx.from;
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
@@ -265,7 +247,6 @@ function init(_ctx) {
$("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden");
updateSendBalance();
resetSendValidation();
showView("send");
});
@@ -274,99 +255,6 @@ function init(_ctx) {
});
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
// More menu dropdown
const moreBtn = $("btn-more-menu");
const moreDropdown = $("more-menu-dropdown");
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = !moreDropdown.classList.toggle("hidden");
moreBtn.classList.toggle("bg-fg", isOpen);
moreBtn.classList.toggle("text-bg", isOpen);
});
document.addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
});
moreDropdown.addEventListener("click", (e) => {
e.stopPropagation();
});
$("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
const blockieEl = $("export-privkey-jazzicon");
blockieEl.innerHTML = "";
const bImg = document.createElement("img");
bImg.src = makeBlockie(addr.address);
bImg.width = 48;
bImg.height = 48;
bImg.style.imageRendering = "pixelated";
bImg.style.borderRadius = "50%";
blockieEl.appendChild(bImg);
$("export-privkey-title").textContent =
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
$("export-privkey-dot").innerHTML = addressDotHtml(addr.address);
$("export-privkey-address").textContent = addr.address;
$("export-privkey-address").dataset.full = addr.address;
$("export-privkey-password").value = "";
hideError("export-privkey-error");
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-result").classList.add("hidden");
$("export-privkey-value").textContent = "";
showView("export-privkey");
});
$("btn-export-privkey-confirm").addEventListener("click", async () => {
const password = $("export-privkey-password").value;
if (!password) {
showError("export-privkey-error", "Password is required.");
return;
}
const wallet = state.wallets[state.selectedWallet];
try {
const secret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
secret,
);
const privateKey = signer.privateKey;
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = privateKey;
$("export-privkey-result").classList.remove("hidden");
hideError("export-privkey-error");
} catch {
showError("export-privkey-error", "Wrong password.");
}
});
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (key) {
navigator.clipboard.writeText(key);
showFlash("Copied!");
}
});
$("export-privkey-address").addEventListener("click", () => {
const full = $("export-privkey-address").dataset.full;
if (full) {
navigator.clipboard.writeText(full);
showFlash("Copied!");
}
});
$("btn-export-privkey-back").addEventListener("click", () => {
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
show();
});
}
module.exports = { init, show };

View File

@@ -6,13 +6,11 @@ const {
showView,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
balanceLine,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const {
formatUsd,
getPrice,
@@ -23,11 +21,7 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const {
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { updateSendBalance, renderSendTokenSelect } = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
@@ -91,7 +85,6 @@ function show() {
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = "ETH";
amount = parseFloat(addr.balance || "0");
@@ -100,11 +93,7 @@ function show() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
);
symbol = tb ? tb.symbol : "?";
amount = tb ? parseFloat(tb.balance || "0") : 0;
price = getPrice(symbol);
}
@@ -147,49 +136,36 @@ function show() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
const tracked = (state.trackedTokens || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
const rawName =
(tb && tb.name) ||
(tracked && tracked.name) ||
(knownToken && knownToken.name) ||
null;
const rawSymbol =
(tb && tb.symbol) ||
(tracked && tracked.symbol) ||
(knownToken && knownToken.symbol) ||
null;
const tokenName = rawName ? escapeHtml(rawName) : null;
const tokenSymbol = rawSymbol ? escapeHtml(rawSymbol) : null;
const tokenDecimals =
tb && tb.decimals != null
? tb.decimals
: tracked && tracked.decimals != null
? tracked.decimals
: knownToken && knownToken.decimals != null
? knownToken.decimals
: null;
const tokenName = tb && tb.name ? escapeHtml(tb.name) : null;
const tokenSymbol = tb && tb.symbol ? escapeHtml(tb.symbol) : null;
const tokenDecimals = tb && tb.decimals != null ? tb.decimals : null;
const tokenHolders = tb && tb.holders != null ? tb.holders : null;
const dot = addressDotHtml(tokenId);
const tokenLink = `https://etherscan.io/token/${escapeHtml(tokenId)}`;
const projectUrl = knownToken && knownToken.url ? knownToken.url : null;
let infoHtml = `<div class="font-bold mb-2">Contract Address</div>`;
infoHtml +=
`<div class="flex items-center mb-2">${dot}` +
let infoHtml =
`<div class="font-bold mb-1">Token Contract</div>` +
`<div class="flex items-center mb-1">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" id="address-token-contract-copy" data-copy="${escapeHtml(tokenId)}">${escapeHtml(tokenId)}</span>` +
`<a href="${tokenLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
const details = [];
if (tokenName)
infoHtml += `<div class="mb-1"><span class="text-muted">Name:</span> ${tokenName}</div>`;
details.push(`<span class="text-muted">Name:</span> ${tokenName}`);
if (tokenSymbol)
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
details.push(
`<span class="text-muted">Symbol:</span> ${tokenSymbol}`,
);
if (tokenDecimals != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
details.push(
`<span class="text-muted">Decimals:</span> ${tokenDecimals}`,
);
if (tokenHolders != null)
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
if (projectUrl)
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
details.push(
`<span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}`,
);
if (details.length > 0) {
infoHtml += `<div class="flex flex-wrap gap-x-3 gap-y-1">${details.join("")}</div>`;
}
contractInfo.innerHTML = infoHtml;
contractInfo.classList.remove("hidden");
} else {
@@ -242,10 +218,11 @@ async function loadTransactions(address, tokenId) {
loadedTxs = txs;
// Collect ALL unique addresses for ENS resolution so reverse
// lookups work for every displayed address.
// Collect unique counterparty addresses for ENS resolution
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
...new Set(
txs.map((tx) => (tx.direction === "sent" ? tx.to : tx.from)),
),
];
if (counterparties.length > 0) {
try {
@@ -278,14 +255,12 @@ function renderTransactions(txs) {
for (const tx of txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const displayAddr = ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
@@ -376,7 +351,6 @@ function init(_ctx) {
});
}
updateSendBalance();
resetSendValidation();
showView("send");
});

View File

@@ -1,12 +1,4 @@
const {
$,
addressDotHtml,
addressTitle,
escapeHtml,
showView,
showError,
hideError,
} = require("./helpers");
const { $, addressDotHtml, escapeHtml, showView } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
const { ERC20_ABI } = require("../../shared/constants");
@@ -30,15 +22,7 @@ function approvalAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(address, state.wallets);
let html = "";
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
html += `<div class="break-all">${escapeHtml(address)}${extLink}</div>`;
} else {
html += `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
return html;
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
function formatTxValue(val) {
@@ -80,12 +64,10 @@ function decodeCalldata(data, toAddress) {
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
const amountRaw = isUnlimited
? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals));
const amountStr = isUnlimited
? "Unlimited"
: amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
: formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
(tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Approval",
@@ -104,11 +86,7 @@ function decodeCalldata(data, toAddress) {
value: spender,
address: spender,
},
{
label: "Amount",
value: amountStr,
rawValue: amountRaw,
},
{ label: "Amount", value: amountStr },
],
};
}
@@ -116,11 +94,9 @@ function decodeCalldata(data, toAddress) {
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amountRaw = formatTxValue(
formatUnits(rawAmount, tokenDecimals),
);
const amountStr =
amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
formatTxValue(formatUnits(rawAmount, tokenDecimals)) +
(tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Transfer",
@@ -135,11 +111,7 @@ function decodeCalldata(data, toAddress) {
isToken: true,
},
{ label: "Recipient", value: to, address: to },
{
label: "Amount",
value: amountStr,
rawValue: amountRaw,
},
{ label: "Amount", value: amountStr },
],
};
}
@@ -177,7 +149,7 @@ function showTxApproval(details) {
pendingTxDetails.to = d.address;
}
if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value;
pendingTxDetails.amount = d.value;
}
}
if (token) {
@@ -186,15 +158,6 @@ function showTxApproval(details) {
}
}
// Carry decoded calldata info through to success/error views
if (decoded) {
pendingTxDetails.decoded = {
name: decoded.name,
description: decoded.description,
details: decoded.details,
};
}
$("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(state.activeAddress);
@@ -344,7 +307,7 @@ function showSignApproval(details) {
}
$("approve-sign-password").value = "";
hideError("approve-sign-error");
$("approve-sign-error").classList.add("hidden");
$("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted");
@@ -409,10 +372,11 @@ function init(ctx) {
$("btn-approve-tx").addEventListener("click", () => {
const password = $("approve-tx-password").value;
if (!password) {
showError("approve-tx-error", "Please enter your password.");
$("approve-tx-error").textContent = "Please enter your password.";
$("approve-tx-error").classList.remove("hidden");
return;
}
hideError("approve-tx-error");
$("approve-tx-error").classList.add("hidden");
$("btn-approve-tx").disabled = true;
$("btn-approve-tx").classList.add("text-muted");
@@ -448,10 +412,11 @@ function init(ctx) {
$("btn-approve-sign").addEventListener("click", () => {
const password = $("approve-sign-password").value;
if (!password) {
showError("approve-sign-error", "Please enter your password.");
$("approve-sign-error").textContent = "Please enter your password.";
$("approve-sign-error").classList.remove("hidden");
return;
}
hideError("approve-sign-error");
$("approve-sign-error").classList.add("hidden");
$("btn-approve-sign").disabled = true;
$("btn-approve-sign").classList.add("text-muted");
@@ -469,7 +434,8 @@ function init(ctx) {
} else {
const msg =
(response && response.error) || "Signing failed.";
showError("approve-sign-error", msg);
$("approve-sign-error").textContent = msg;
$("approve-sign-error").classList.remove("hidden");
$("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted");
}
@@ -487,4 +453,4 @@ function init(ctx) {
});
}
module.exports = { init, show, decodeCalldata };
module.exports = { init, show };

View File

@@ -14,7 +14,6 @@ const {
showError,
hideError,
showView,
showFlash,
addressTitle,
addressDotHtml,
escapeHtml,
@@ -96,22 +95,11 @@ function show(txInfo) {
// Token contract section (ERC-20 only)
const tokenSection = $("confirm-token-section");
if (isErc20) {
const dot = addressDotHtml(txInfo.token);
const link = etherscanTokenLink(txInfo.token);
$("confirm-token-contract").innerHTML =
`<div class="flex items-center">${dot}` +
`<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(txInfo.token)}">${escapeHtml(txInfo.token)}</span>` +
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>` +
`</div>`;
escapeHtml(txInfo.token) +
` <a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
tokenSection.classList.remove("hidden");
// Attach click-to-copy on the contract address
const copyEl = tokenSection.querySelector("[data-copy]");
if (copyEl) {
copyEl.onclick = () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
};
}
} else {
tokenSection.classList.add("hidden");
}

View File

@@ -1,87 +0,0 @@
const { $, showView, showFlash, showError, hideError } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
let deleteWalletIndex = null;
let ctx = null;
function show(walletIdx) {
deleteWalletIndex = walletIdx;
const wallet = state.wallets[walletIdx];
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
$("delete-wallet-password").value = "";
hideError("delete-wallet-error");
showView("delete-wallet-confirm");
}
function init(_ctx) {
ctx = _ctx;
$("btn-delete-wallet-back").addEventListener("click", () => {
deleteWalletIndex = null;
ctx.showSettingsView();
});
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value;
if (!pw) {
showError("delete-wallet-error", "Please enter your password.");
return;
}
if (deleteWalletIndex === null) {
showError(
"delete-wallet-error",
"No wallet selected for deletion.",
);
return;
}
const walletIdx = deleteWalletIndex;
const wallet = state.wallets[walletIdx];
// Verify password against the wallet's encrypted data
try {
await decryptWithPassword(wallet.encryptedSecret, pw);
} catch (_e) {
showError("delete-wallet-error", "Wrong password.");
return;
}
// Collect addresses to clean up from allowedSites/deniedSites
const addresses = (wallet.addresses || []).map((a) => a.address);
// Remove wallet
state.wallets.splice(walletIdx, 1);
// Clean up site permissions for deleted addresses
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
deleteWalletIndex = null;
if (state.wallets.length === 0) {
// No wallets left — reset selection and show welcome
state.selectedWallet = null;
state.selectedAddress = null;
state.activeAddress = null;
await saveState();
showView("welcome");
} else {
// Switch to first wallet if deleted wallet was active
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress =
state.wallets[0].addresses[0]?.address || null;
await saveState();
ctx.renderWalletList();
ctx.showSettingsView();
showFlash("Wallet deleted.");
}
});
}
module.exports = { init, show };

View File

@@ -25,13 +25,11 @@ const VIEWS = [
"receive",
"add-token",
"settings",
"delete-wallet-confirm",
"settings-addtoken",
"transaction",
"approve-site",
"approve-tx",
"approve-sign",
"export-privkey",
];
function $(id) {

View File

@@ -6,16 +6,11 @@ const {
isoDate,
timeAgo,
addressDotHtml,
addressTitle,
escapeHtml,
truncateMiddle,
} = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state");
const {
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
} = require("./send");
const { updateSendBalance, renderSendTokenSelect } = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet");
const {
formatUsd,
@@ -107,22 +102,13 @@ function renderHomeTxList(ctx) {
let html = "";
let i = 0;
for (const tx of homeTxs) {
// For swap transactions, show the user's own labelled wallet
// address (the one that initiated the swap) instead of the
// contract address which is not useful in the list view.
const counterparty =
tx.direction === "contract" && tx.directionLabel === "Swap"
? tx.from
: tx.direction === "sent" || tx.direction === "contract"
? tx.to
: tx.from;
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const dirLabel = tx.directionLabel;
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
const title = addressTitle(counterparty, state.wallets);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
const displayAddr = truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
@@ -392,7 +378,6 @@ function init(ctx) {
$("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr);
updateSendBalance();
resetSendValidation();
showView("send");
});

View File

@@ -1,4 +1,4 @@
const { $, showView, showError, hideError } = require("./helpers");
const { $, showView, showFlash } = require("./helpers");
const { addressFromPrivateKey } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
@@ -7,7 +7,6 @@ function show() {
$("import-private-key").value = "";
$("import-key-password").value = "";
$("import-key-password-confirm").value = "";
hideError("import-key-error");
showView("import-key");
}
@@ -15,31 +14,28 @@ function init(ctx) {
$("btn-import-key-confirm").addEventListener("click", async () => {
const key = $("import-private-key").value.trim();
if (!key) {
showError("import-key-error", "Please enter your private key.");
showFlash("Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
showError("import-key-error", "Invalid private key.");
showFlash("Invalid private key.");
return;
}
const pw = $("import-key-password").value;
const pw2 = $("import-key-password-confirm").value;
if (!pw) {
showError("import-key-error", "Please choose a password.");
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showError(
"import-key-error",
"Password must be at least 12 characters.",
);
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
showError("import-key-error", "Passwords do not match.");
showFlash("Passwords do not match.");
return;
}
const encrypted = await encryptWithPassword(key, pw);

View File

@@ -1,10 +1,4 @@
const {
$,
showView,
showFlash,
formatAddressHtml,
addressTitle,
} = require("./helpers");
const { $, showView, showFlash, addressDotHtml } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
const QRCode = require("qrcode");
@@ -18,12 +12,8 @@ const EXT_ICON =
function show() {
const addr = currentAddress();
const address = addr ? addr.address : "";
const title = address ? addressTitle(address, state.wallets) : null;
const ensName = addr ? addr.ensName || null : null;
$("receive-address-block").innerHTML = address
? formatAddressHtml(address, ensName, null, title)
: "";
$("receive-address-block").dataset.full = address;
$("receive-dot").innerHTML = address ? addressDotHtml(address) : "";
$("receive-address").textContent = address;
const link = address ? `https://etherscan.io/address/${address}` : "";
$("receive-etherscan-link").innerHTML = link
? `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`
@@ -60,16 +50,8 @@ function show() {
}
function init(ctx) {
$("receive-address-block").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
}
});
$("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full;
const addr = $("receive-address").textContent;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");

View File

@@ -1,117 +1,10 @@
// Send view: collect To, Amount, Token. Then go to confirmation.
const {
$,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
} = require("./helpers");
const { $, showFlash, addressDotHtml, escapeHtml } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
/**
* Validate a destination address string.
* Returns { valid: true } or { valid: false, error: "..." }.
*/
function validateToAddress(value) {
const v = value.trim();
if (!v) return { valid: false, error: "" };
// ENS names: contains a dot and doesn't start with 0x
if (v.includes(".") && !v.startsWith("0x")) {
// Basic ENS format check: at least one label before and after dot
if (/^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$/.test(v)) {
return { valid: true };
}
return {
valid: false,
error: "Please enter a valid ENS name.",
};
}
// Must look like an Ethereum address
if (!/^0x[0-9a-fA-F]{40}$/.test(v)) {
return {
valid: false,
error: "Please enter a valid Ethereum address.",
};
}
// Reject zero address
if (v.toLowerCase() === ZERO_ADDRESS) {
return {
valid: false,
error: "Sending to the zero address is not allowed.",
};
}
// EIP-55 checksum validation: all-lowercase is ok, otherwise must match checksum
if (v !== v.toLowerCase()) {
try {
const checksummed = getAddress(v);
if (checksummed !== v) {
return {
valid: false,
error: "Address checksum is invalid. Please double-check the address.",
};
}
} catch {
return {
valid: false,
error: "Address checksum is invalid. Please double-check the address.",
};
}
}
// Warn if sending to own address
const addr = currentAddress();
if (addr && v.toLowerCase() === addr.address.toLowerCase()) {
// Allow but will warn — we return valid with a warning
return {
valid: true,
warning: "This is your own address. Are you sure?",
};
}
return { valid: true };
}
function updateToValidation() {
const input = $("send-to");
const errorEl = $("send-to-error");
const btn = $("btn-send-review");
const value = input.value.trim();
if (!value) {
errorEl.textContent = "";
btn.disabled = true;
btn.classList.add("opacity-50");
return;
}
const result = validateToAddress(value);
if (!result.valid) {
errorEl.textContent = result.error;
errorEl.style.color = "#cc0000";
btn.disabled = true;
btn.classList.add("opacity-50");
} else if (result.warning) {
errorEl.textContent = result.warning;
errorEl.style.color = "#b8860b";
btn.disabled = false;
btn.classList.remove("opacity-50");
} else {
errorEl.textContent = "";
btn.disabled = false;
btn.classList.remove("opacity-50");
}
}
const { KNOWN_SYMBOLS } = require("../../shared/tokenList");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -151,15 +44,8 @@ function updateSendBalance() {
const dot = addressDotHtml(addr.address);
const link = `https://etherscan.io/address/${addr.address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(addr.address, state.wallets);
let fromHtml = "";
if (title) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
if (addr.ensName) {
fromHtml += `<div>${escapeHtml(addr.ensName)}</div>`;
}
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else if (addr.ensName) {
if (addr.ensName) {
fromHtml += `<div class="flex items-center font-bold">${dot}${escapeHtml(addr.ensName)}</div>`;
fromHtml += `<div class="break-all">${escapeHtml(addr.address)}${extLink}</div>`;
} else {
@@ -174,11 +60,7 @@ function updateSendBalance() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const symbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
const symbol = tb ? tb.symbol : "?";
const bal = tb ? tb.balance || "0" : "0";
$("send-balance").textContent =
"Current balance: " + bal + " " + symbol;
@@ -189,13 +71,6 @@ function init(_ctx) {
ctx = _ctx;
$("send-token").addEventListener("change", updateSendBalance);
// Initial state: disable review button until address is entered
$("btn-send-review").disabled = true;
$("btn-send-review").classList.add("opacity-50");
// Validate address on input
$("send-to").addEventListener("input", updateToValidation);
$("btn-send-review").addEventListener("click", async () => {
const to = $("send-to").value.trim();
const amount = $("send-amount").value.trim();
@@ -203,15 +78,6 @@ function init(_ctx) {
showFlash("Please enter a recipient address.");
return;
}
// Re-validate before proceeding
const validation = validateToAddress(to);
if (!validation.valid) {
showFlash(
validation.error || "Please enter a valid Ethereum address.",
);
return;
}
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
showFlash("Please enter a valid amount.");
return;
@@ -245,11 +111,7 @@ function init(_ctx) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
tokenSymbol = tb ? tb.symbol : "?";
tokenBalance = tb ? tb.balance || "0" : "0";
}
@@ -276,19 +138,4 @@ function init(_ctx) {
});
}
function resetSendValidation() {
const errorEl = $("send-to-error");
const btn = $("btn-send-review");
if (errorEl) errorEl.textContent = "";
if (btn) {
btn.disabled = true;
btn.classList.add("opacity-50");
}
}
module.exports = {
init,
updateSendBalance,
renderSendTokenSelect,
resetSendValidation,
};
module.exports = { init, updateSendBalance, renderSendTokenSelect };

View File

@@ -2,7 +2,7 @@ const { $, showView, showFlash, escapeHtml } = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log, debugFetch } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");
const { decryptWithPassword } = require("../../shared/vault");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -66,6 +66,8 @@ function renderTrackedTokens() {
});
}
let deleteWalletIndex = null;
function renderWalletListSettings() {
const container = $("settings-wallet-list");
if (state.wallets.length === 0) {
@@ -76,47 +78,20 @@ function renderWalletListSettings() {
state.wallets.forEach((wallet, idx) => {
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
html += `<span>${name}</span>`;
html += `<span class="btn-delete-wallet border-b border-dashed border-fg text-fg cursor-pointer" data-idx="${idx}">[delete]</span>`;
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-delete-wallet").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10);
deleteWallet.show(idx);
});
});
// Inline rename on click
container.querySelectorAll(".settings-wallet-name").forEach((span) => {
span.addEventListener("click", () => {
const idx = parseInt(span.dataset.idx, 10);
const wallet = state.wallets[idx];
const input = document.createElement("input");
input.type = "text";
input.className =
"border border-border p-0 text-xs bg-bg text-fg w-full";
input.value = wallet.name || "Wallet " + (idx + 1);
span.replaceWith(input);
input.focus();
input.select();
const finish = async () => {
const val = input.value.trim();
if (val && val !== wallet.name) {
wallet.name = val;
await saveState();
}
renderWalletListSettings();
};
input.addEventListener("blur", finish);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") input.blur();
if (e.key === "Escape") {
input.value = wallet.name || "Wallet " + (idx + 1);
input.blur();
}
});
deleteWalletIndex = idx;
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (idx + 1);
$("delete-wallet-password").value = "";
$("delete-wallet-confirm").classList.remove("hidden");
});
});
}
@@ -127,6 +102,8 @@ function show() {
renderTrackedTokens();
renderSiteLists();
renderWalletListSettings();
deleteWalletIndex = null;
$("delete-wallet-confirm").classList.add("hidden");
showView("settings");
}
@@ -141,8 +118,6 @@ function renderSiteLists() {
}
function init(ctx) {
deleteWallet.init(ctx);
$("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim();
if (!url) {
@@ -252,6 +227,72 @@ function init(ctx) {
ctx.renderWalletList();
showView("main");
});
$("btn-delete-wallet-cancel").addEventListener("click", () => {
$("delete-wallet-confirm").classList.add("hidden");
$("delete-wallet-password").value = "";
deleteWalletIndex = null;
});
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value;
if (!pw) {
showFlash("Password required.");
return;
}
if (deleteWalletIndex === null) {
showFlash("No wallet selected for deletion.");
return;
}
const walletIdx = deleteWalletIndex;
const wallet = state.wallets[walletIdx];
// Verify password against the wallet's encrypted data
try {
await decryptWithPassword(wallet.encrypted, pw);
} catch (_e) {
showFlash("Wrong password.");
return;
}
// Collect addresses to clean up from allowedSites/deniedSites
const addresses = (wallet.addresses || []).map((a) => a.address);
// Remove wallet
state.wallets.splice(walletIdx, 1);
// Clean up site permissions for deleted addresses
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
deleteWalletIndex = null;
if (state.wallets.length === 0) {
// No wallets left — reset selection and show welcome
state.selectedWallet = null;
state.selectedAddress = null;
state.activeAddress = null;
await saveState();
$("delete-wallet-confirm").classList.add("hidden");
showView("welcome");
} else {
// Switch to first wallet if deleted wallet was active
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress =
state.wallets[0].addresses[0]?.address || null;
await saveState();
$("delete-wallet-confirm").classList.add("hidden");
showFlash("Wallet deleted.");
renderWalletListSettings();
ctx.renderWalletList();
showView("main");
}
});
}
module.exports = { init, show, renderSiteLists };

View File

@@ -13,8 +13,6 @@ const {
} = require("./helpers");
const { state } = require("../../shared/state");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -37,35 +35,27 @@ function blockieHtml(address) {
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function etherscanLinkHtml(url) {
return (
`<a href="${url}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center"` +
`>${EXT_ICON}</a>`
);
}
function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address);
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = etherscanLinkHtml(link);
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
let html = `<div class="mb-1">${blockie}</div>`;
if (title) {
html += `<div class="font-bold">${escapeHtml(title)}</div>`;
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
}
if (ensName) {
html +=
`<div class="flex items-center">${dot}` +
`<div class="flex items-center">${title ? "" : dot}` +
copyableHtml(ensName, "") +
`</div>` +
`<div class="flex items-center">${dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>` +
`<div class="break-all">` +
copyableHtml(address, "break-all") +
`</div>`;
} else {
html +=
`<div class="flex items-center">${dot}` +
`<div class="flex items-center">${title ? "" : dot}` +
copyableHtml(address, "break-all") +
extLink +
`</div>`;
@@ -75,7 +65,7 @@ function txAddressHtml(address, ensName, title) {
function txHashHtml(hash) {
const link = `https://etherscan.io/tx/${hash}`;
const extLink = etherscanLinkHtml(link);
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return copyableHtml(hash, "break-all") + extLink;
}
@@ -95,9 +85,6 @@ function show(tx) {
fromEns: tx.fromEns || null,
toEns: tx.toEns || null,
directionLabel: tx.directionLabel || null,
direction: tx.direction || null,
isContractCall: tx.isContractCall || false,
method: tx.method || null,
},
};
render();
@@ -134,30 +121,6 @@ function render() {
nativeEl.parentElement.classList.add("hidden");
}
// Show type label for contract interactions (Swap, Execute, etc.)
const typeSection = $("tx-detail-type-section");
const typeEl = $("tx-detail-type");
const headingEl = $("tx-detail-heading");
if (tx.direction === "contract" && tx.directionLabel) {
if (typeSection) {
typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden");
}
} else {
if (typeSection) typeSection.classList.add("hidden");
}
if (headingEl) headingEl.textContent = "Transaction";
// Hide calldata and raw data sections; re-fetch if this is a contract call
const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section");
if (rawDataSection) rawDataSection.classList.add("hidden");
if (tx.isContractCall || tx.direction === "contract") {
loadCalldata(tx.hash, tx.to);
}
$("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
@@ -174,87 +137,6 @@ function render() {
});
}
async function loadCalldata(txHash, toAddress) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
const wellEl = $("tx-detail-calldata-well");
const rawSection = $("tx-detail-rawdata-section");
const rawEl = $("tx-detail-rawdata");
if (!section || !actionEl || !detailsEl) return;
try {
const resp = await debugFetch(
state.blockscoutUrl + "/transactions/" + txHash,
);
if (!resp.ok) return;
const txData = await resp.json();
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;
const decoded = decodeCalldata(inputData, toAddress || "");
if (decoded) {
// Render decoded calldata matching approval view style
actionEl.textContent = decoded.name;
let detailsHtml = "";
if (decoded.description) {
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
}
for (const d of decoded.details || []) {
detailsHtml += `<div class="mb-2">`;
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address && d.isToken) {
// Token entry: show symbol on its own line, then dot + address + Etherscan link
const dot = addressDotHtml(d.address);
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`;
}
const etherscanUrl = `https://etherscan.io/token/${d.address}`;
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.address, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
} else if (d.address) {
// Protocol/contract entry: show name + Etherscan link
const dot = addressDotHtml(d.address);
const etherscanUrl = `https://etherscan.io/address/${d.address}`;
detailsHtml += `<div class="flex items-center">${dot}${copyableHtml(d.value, "break-all")}${etherscanLinkHtml(etherscanUrl)}</div>`;
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
detailsHtml += `</div>`;
}
detailsEl.innerHTML = detailsHtml;
if (wellEl) wellEl.classList.remove("hidden");
} else {
// Unknown contract call — show method name in well
const method = txData.method || "Unknown contract call";
actionEl.textContent = method;
detailsEl.innerHTML = "";
if (wellEl) wellEl.classList.remove("hidden");
}
// Always show raw data
if (rawSection && rawEl) {
rawEl.innerHTML = copyableHtml(inputData, "break-all");
rawSection.classList.remove("hidden");
}
section.classList.remove("hidden");
// Bind copy handlers for new elements (including raw data now outside section)
const copyTargets = [section, rawSection].filter(Boolean);
for (const container of copyTargets) {
container.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
};
});
}
} catch (e) {
log.errorf("loadCalldata failed:", e.message);
}
}
function init(_ctx) {
ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => {

View File

@@ -5,10 +5,8 @@ const {
showView,
showFlash,
addressDotHtml,
addressTitle,
escapeHtml,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -39,13 +37,6 @@ function toAddressHtml(address) {
const dot = addressDotHtml(address);
const link = `https://etherscan.io/address/${address}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
const title = addressTitle(address, state.wallets);
if (title) {
return (
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
`<div class="break-all">${escapeHtml(address)}${extLink}</div>`
);
}
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
@@ -122,51 +113,11 @@ function showSuccess(txInfo, txHash, blockNumber) {
to: txInfo.to,
hash: txHash,
blockNumber: blockNumber,
decoded: txInfo.decoded || null,
};
renderSuccess();
ctx.doRefreshAndRender();
}
function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = "";
if (decoded.name) {
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`;
html += `<div class="font-bold">${escapeHtml(decoded.name)}</div></div>`;
}
if (decoded.description) {
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Description</div>`;
html += `<div>${escapeHtml(decoded.description)}</div></div>`;
}
for (const d of decoded.details) {
html += `<div class="mb-2">`;
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) {
if (d.isToken) {
const sym = tokenLabel(d.address) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += toAddressHtml(d.address);
} else {
html += toAddressHtml(d.address);
}
} else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
html += `</div>`;
}
return html;
}
function renderSuccess() {
const d = state.viewData;
if (!d || !d.hash) return;
@@ -174,16 +125,6 @@ function renderSuccess() {
$("success-tx-to").innerHTML = toAddressHtml(d.to);
$("success-tx-block").textContent = String(d.blockNumber);
$("success-tx-hash").innerHTML = txHashHtml(d.hash);
// Show decoded calldata details if present
const decodedEl = $("success-tx-decoded");
if (decodedEl && d.decoded) {
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
}
attachCopyHandlers("view-success-tx");
showView("success-tx");
}

View File

@@ -85,7 +85,6 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
balances.push({
address: item.token.address_hash,
name: item.token.name || "",
symbol: item.token.symbol || "???",
decimals: decimals,
balance: bal,
@@ -124,27 +123,15 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
}),
);
// ENS reverse lookup — only overwrite on success so that
// transient RPC errors don't wipe a previously resolved name.
// ENS reverse lookup
updates.push(
provider
.lookupAddress(addr.address)
.then((name) => {
addr.ensName = name || null;
log.debugf(
"ENS reverse",
addr.address,
"->",
addr.ensName,
);
})
.catch((e) => {
log.errorf(
"ENS reverse failed",
addr.address,
e.message,
);
// Keep existing addr.ensName if we had one
.catch(() => {
addr.ensName = null;
}),
);
@@ -205,10 +192,6 @@ async function lookupTokenInfo(contractAddress, rpcUrl) {
name = symbol;
}
// Truncate to prevent storage of excessively long values from RPC
name = String(name).slice(0, 64);
symbol = String(symbol).slice(0, 12);
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
return { name, symbol, decimals: Number(decimals) };
}

View File

@@ -39,7 +39,7 @@ async function resolveEnsName(address, rpcUrl) {
return name;
} catch (e) {
log.errorf("ENS reverse lookup failed", address, e.message);
// Don't cache failures — let subsequent lookups retry
setCache(address, null);
return null;
}
}

View File

@@ -3645,27 +3645,10 @@ async function getTopTokenPrices(n) {
return prices;
}
// Resolve a token symbol from multiple sources, never returning "?".
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
const lower = (tokenAddress || "").toLowerCase();
const tb = (tokenBalances || []).find(
(t) => t.address.toLowerCase() === lower,
);
if (tb && tb.symbol) return tb.symbol;
const known = TOKEN_BY_ADDRESS.get(lower);
if (known && known.symbol) return known.symbol;
const tracked = (trackedTokens || []).find(
(t) => t.address.toLowerCase() === lower,
);
if (tracked && tracked.symbol) return tracked.symbol;
return lower.slice(0, 10) + "\u2026";
}
module.exports = {
TOKENS,
TOKEN_BY_ADDRESS,
KNOWN_SYMBOLS,
getTopTokens,
getTopTokenPrices,
resolveSymbol,
};

View File

@@ -37,21 +37,7 @@ function parseTx(tx, addrLower) {
if (token) {
symbol = token.symbol;
}
// Map known DEX methods to "Swap" for cleaner display
const SWAP_METHODS = new Set([
"execute",
"swap",
"swapExactTokensForTokens",
"swapTokensForExactTokens",
"swapExactETHForTokens",
"swapTokensForExactETH",
"swapExactTokensForETH",
"swapETHForExactTokens",
"multicall",
]);
const label = SWAP_METHODS.has(method)
? "Swap"
: method.charAt(0).toUpperCase() + method.slice(1);
const label = method.charAt(0).toUpperCase() + method.slice(1);
direction = "contract";
directionLabel = label;
value = "";
@@ -153,26 +139,10 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. A single transaction (e.g. a swap) can produce
// multiple token transfers (one per token involved), so we key token
// transfers by hash + contract address to keep all of them. We also
// preserve contract-call metadata (direction, label, method) from the
// matching normal tx so swaps display correctly.
// amount and symbol. Replace the normal tx with the token transfer.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
parsed.direction = "contract";
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
// Remove the bare-hash normal tx so it doesn't appear as a
// duplicate with empty value; token transfers replace it.
txsByHash.delete(parsed.hash);
}
// Use composite key so multiple token transfers per tx are kept.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
txsByHash.set(parsed.hash, parsed);
}
const txs = [...txsByHash.values()];

View File

@@ -161,157 +161,6 @@ function decodeWrapEth(input) {
}
}
// V4 inner action IDs
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e;
// Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
// and swap amounts from the swap sub-actions.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
const actions = getBytes(d[0]);
const params = d[1];
let settleToken = null;
let takeToken = null;
let amountIn = null;
let amountOutMin = null;
for (let i = 0; i < actions.length; i++) {
const actionId = actions[i];
try {
if (actionId === V4_SETTLE) {
// SETTLE: (address currency, uint256 maxAmount, bool payerIsUser)
const s = coder.decode(
["address", "uint256", "bool"],
params[i],
);
settleToken = s[0];
} else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode(
["address", "address", "uint256"],
params[i],
);
takeToken = t[0];
} else if (
actionId === V4_SWAP_EXACT_IN ||
actionId === V4_SWAP_EXACT_IN_SINGLE
) {
// Extract amounts from exact-in swap actions
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
try {
const s = coder.decode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!settleToken) settleToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !takeToken) {
takeToken = path[path.length - 1][0];
}
if (!amountIn) amountIn = s[0][2];
if (!amountOutMin) amountOutMin = s[0][3];
} catch {
// Fall through — SETTLE/TAKE will provide tokens
}
} else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try {
const s = coder.decode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!settleToken)
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!takeToken)
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
if (!amountIn) amountIn = s[0][2];
if (!amountOutMin) amountOutMin = s[0][3];
} catch {
// Fall through
}
}
} else if (
actionId === V4_SWAP_EXACT_OUT ||
actionId === V4_SWAP_EXACT_OUT_SINGLE
) {
if (actionId === V4_SWAP_EXACT_OUT) {
try {
const s = coder.decode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!takeToken) takeToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !settleToken) {
settleToken = path[path.length - 1][0];
}
} catch {
// Fall through
}
} else {
try {
const s = coder.decode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!settleToken)
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!takeToken)
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
} catch {
// Fall through
}
}
}
} catch {
// Skip sub-actions we can't decode
}
}
return {
tokenIn: settleToken,
tokenOut: takeToken,
amountIn,
amountOutMin,
};
} catch {
return null;
}
}
// Try to decode a Universal Router execute() call.
// Returns { name, description, details } matching the format used by
// the approval UI, or null if the calldata is not a recognised execute().
@@ -384,19 +233,6 @@ function decode(data, toAddress) {
}
}
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!outputToken && v4.tokenOut)
outputToken = v4.tokenOut;
if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn;
if (!minOutput && v4.amountOutMin)
minOutput = v4.amountOutMin;
}
}
if (cmdId === 0x0c) {
hasUnwrapWeth = true;
}

View File

@@ -1,10 +1,9 @@
const { AbiCoder, Interface, solidityPacked, getBytes } = require("ethers");
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 USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27.
@@ -257,87 +256,6 @@ describe("uniswap decoder", () => {
expect(amount.value).toBe("5.0000 USDT");
});
// This test validates the decodeV4Swap() fix: a V4 ERC20→ERC20 swap
// (USDT→USDC) where the token addresses are ONLY discoverable inside
// the V4_SWAP sub-actions (SETTLE/TAKE). Before decodeV4Swap() was added,
// command 0x10 was opaque and this would decode as "Uniswap Swap" with
// no token info (or "ETH → ETH"). Now it correctly shows "USDT → USDC".
test("decodes V4_SWAP ERC20→ERC20 tokens via SETTLE/TAKE (regression: #59)", () => {
// Build a V4_SWAP input with SETTLE(USDT) + SWAP_EXACT_IN_SINGLE + TAKE(USDC)
const V4_SETTLE = 0x0b;
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
const V4_TAKE = 0x0e;
// actions: SETTLE, SWAP_EXACT_IN_SINGLE, TAKE
const actions = new Uint8Array([
V4_SETTLE,
V4_SWAP_EXACT_IN_SINGLE,
V4_TAKE,
]);
// SETTLE params: (address currency, uint256 maxAmount, bool payerIsUser)
const settleParam = coder.encode(
["address", "uint256", "bool"],
[USDT_ADDR, 5000000n, true],
);
// SWAP_EXACT_IN_SINGLE params:
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
const swapParam = coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[
USDT_ADDR,
USDC_ADDR,
100, // fee
1, // tickSpacing
"0x0000000000000000000000000000000000000000", // hooks
],
true, // zeroForOne
5000000n, // amountIn (5 USDT)
4900000n, // amountOutMin (4.9 USDC)
"0x", // hookData
],
],
);
// TAKE params: (address currency, address recipient, uint256 amount)
const takeParam = coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
);
// Encode the V4_SWAP input: (bytes actions, bytes[] params)
const v4Input = coder.encode(
["bytes", "bytes[]"],
[actions, [settleParam, swapParam, takeParam]],
);
// Build execute() with PERMIT2_PERMIT (0x0a) + V4_SWAP (0x10)
// The permit provides the input token, but V4_SWAP must provide
// the OUTPUT token — without decodeV4Swap, output would be unknown.
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0a, 0x10]),
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR), v4Input],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
// Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap"
// After fix: correctly identifies both tokens from V4 sub-actions
expect(result.name).toBe("Swap USDT \u2192 USDC");
const tokenIn = result.details.find((d) => d.label === "Token In");
expect(tokenIn.value).toContain("USDT");
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toContain("V4 Swap");
});
test("handles unknown tokens gracefully", () => {
const fakeToken = "0x1111111111111111111111111111111111111111";
const data = buildExecute(

676
yarn.lock

File diff suppressed because it is too large Load Diff