Compare commits

..

1 Commits

Author SHA1 Message Date
user
1df770d3b6 fix: prevent double symbol display on swap tx broadcast/status views
All checks were successful
check / check (push) Successful in 22s
For Uniswap swaps, the decoded Amount value includes the token symbol
(e.g. '2.0000 USDT'). The tx status view then appended 'ETH' because
pendingTxDetails.token remained 'ETH' (router address is not a token).

Fix:
- Add rawValue (numeric-only) to uniswap decoder's Amount detail
- Extract Token In address from decoded details in approval.js to set
  the correct token/tokenSymbol on pendingTxDetails for swaps
- The existing d.rawValue || d.value pattern now picks up the clean
  numeric value, and symbol comes from tokenSymbol
2026-02-28 11:30:44 -08:00
33 changed files with 463 additions and 4472 deletions

View File

@@ -12,10 +12,6 @@ const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch } = require("../shared/log");
const { decryptWithPassword } = require("../shared/vault");
const { getSignerForAddress } = require("../shared/wallet");
const {
isPhishingDomain,
updatePhishingList,
} = require("../shared/phishingDomains");
const storageApi =
typeof browser !== "undefined"
@@ -575,10 +571,6 @@ async function backgroundRefresh() {
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL);
// Fetch the MetaMask eth-phishing-detect domain blocklist on startup.
// Refreshes every 24 hours automatically.
updatePhishingList();
// When approval window is closed without a response, treat as rejection
if (windowsApi && windowsApi.onRemoved) {
windowsApi.onRemoved.addListener((windowId) => {
@@ -651,8 +643,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
resp.type = "sign";
resp.signParams = approval.signParams;
}
// Flag if the requesting domain is on the phishing blocklist.
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
sendResponse(resp);
} else {
sendResponse(null);

View File

@@ -56,37 +56,9 @@
< Back
</button>
<h2 class="font-bold mb-2">Add Wallet</h2>
<!-- Mode selector tabs -->
<div
class="flex border-b border-border mb-3"
id="add-wallet-tabs"
>
<button
id="tab-mnemonic"
class="px-3 py-1.5 cursor-pointer text-xs font-bold border border-border border-b-bg bg-bg -mb-px"
>
From Phrase
</button>
<button
id="tab-privkey"
class="px-3 py-1.5 cursor-pointer text-xs text-muted border border-dashed border-border-light border-b-transparent -mb-px hover:bg-fg hover:text-bg"
>
From Key
</button>
<button
id="tab-xprv"
class="px-3 py-1.5 cursor-pointer text-xs text-muted border border-dashed border-border-light border-b-transparent -mb-px hover:bg-fg hover:text-bg"
>
From xprv
</button>
</div>
<!-- Mnemonic form section -->
<div id="add-wallet-section-mnemonic">
<p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click
the button to roll the die for a new one.
Enter your 12 or 24 word recovery phrase below, or click the
button to roll the die for a new one.
</p>
<div class="mb-1 flex justify-end">
<button
@@ -107,54 +79,14 @@
</div>
<div
id="add-wallet-phrase-warning"
class="text-xs mb-2 border border-border border-dashed p-2"
style="visibility: hidden"
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
>
Write these words down and keep them safe. Anyone with
them can take your funds; if you lose them, your wallet
is gone.
Write these words down and keep them safe. Anyone with them
can take your funds; if you lose them, your wallet is gone.
</div>
</div>
<!-- Private key form section -->
<div id="add-wallet-section-privkey" class="hidden">
<p class="mb-2">
Paste your private key below. This wallet will have a
single address.
</p>
<div class="mb-2">
<input
type="password"
id="import-private-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
</div>
</div>
<!-- Extended key (xprv) form section -->
<div id="add-wallet-section-xprv" class="hidden">
<p class="mb-2">
Paste your extended private key (xprv) below. This will
import the HD wallet and scan for used addresses.
</p>
<div class="mb-2">
<input
type="password"
id="import-xprv-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="xprv..."
/>
</div>
</div>
<!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label>
<p
class="text-xs text-muted mb-1"
id="add-wallet-password-hint"
>
<p class="text-xs text-muted mb-1">
This password encrypts your recovery phrase on this
device. You will need it to send funds.
</p>
@@ -175,6 +107,64 @@
<button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Add
</button>
<div class="mt-3 text-xs text-muted">
Have a private key instead?
<button
id="btn-add-wallet-import-key"
class="underline cursor-pointer bg-transparent border-none text-fg text-xs font-mono p-0"
>
Import private key
</button>
</div>
</div>
<!-- ============ IMPORT PRIVATE KEY ============ -->
<div id="view-import-key" class="view hidden">
<button
id="btn-import-key-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-2">Import Private Key</h2>
<p class="mb-2">
Paste your private key below. This wallet will have a single
address.
</p>
<div class="mb-2">
<input
type="password"
id="import-private-key"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="0x..."
/>
</div>
<div class="mb-2" id="import-key-password-section">
<label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1">
This password encrypts your private key on this device.
You will need it to send funds.
</p>
<input
type="password"
id="import-key-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="mb-2" id="import-key-password-confirm-section">
<label class="block mb-1">Confirm password</label>
<input
type="password"
id="import-key-password-confirm"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<button
id="btn-import-key-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
@@ -185,7 +175,7 @@
<!-- active address headline -->
<div
id="total-value"
class="text-2xl font-bold min-h-[2rem] text-fg"
class="text-2xl font-bold min-h-[2rem]"
></div>
<div
id="total-value-sub"
@@ -315,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 -->
@@ -348,61 +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-flash"
class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></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
@@ -507,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">
@@ -581,78 +491,22 @@
<div class="text-xs text-muted mb-1">Your balance</div>
<div id="confirm-balance" class="text-xs"></div>
</div>
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
<div id="confirm-fee" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">
Estimated network fee
</div>
<div id="confirm-fee-amount" class="text-xs"></div>
</div>
<div
id="confirm-warnings"
class="mb-2"
style="visibility: hidden"
></div>
<div
id="confirm-recipient-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient address has ZERO transaction
history. This may indicate a fresh or unused address.
Double-check the address before sending.
</div>
</div>
<div
id="confirm-contract-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient is a smart contract. Sending ETH
or tokens directly to a contract may result in permanent
loss of funds.
</div>
</div>
<div
id="confirm-burn-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: This is a known null/burn address. Funds sent
here are permanently destroyed and cannot be recovered.
</div>
</div>
<div id="confirm-warnings" class="mb-2 hidden"></div>
<div
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2"
style="visibility: hidden; min-height: 1.25rem"
></div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
type="password"
id="confirm-tx-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div
id="confirm-tx-password-error"
class="text-xs mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="mb-2 border border-border border-dashed p-2 hidden"
></div>
<button
id="btn-confirm-send"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Sign &amp; Send
Send
</button>
</div>
@@ -677,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>
@@ -731,6 +584,42 @@
</button>
</div>
<!-- ============ PASSWORD MODAL ============ -->
<div
id="password-modal"
class="hidden fixed inset-0 bg-bg flex items-center justify-center z-50"
>
<div class="border border-border p-4 bg-bg w-80">
<h2 class="font-bold mb-2">Enter Password</h2>
<p class="text-xs text-muted mb-2">
Your password is needed to authorize this transaction.
</p>
<input
type="password"
id="modal-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg mb-2"
/>
<div
id="modal-password-error"
class="text-xs mb-2 border border-border border-dashed p-1 hidden"
></div>
<div class="flex gap-2">
<button
id="btn-modal-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Confirm
</button>
<button
id="btn-modal-cancel"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Cancel
</button>
</div>
</div>
</div>
<!-- ============ RECEIVE ============ -->
<div id="view-receive" class="view hidden">
<button
@@ -747,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
@@ -761,8 +649,7 @@
</button>
<div
id="receive-erc20-warning"
class="text-xs border border-border border-dashed p-2 mt-3"
style="visibility: hidden"
class="text-xs border border-border border-dashed p-2 mt-3 hidden"
></div>
</div>
@@ -790,8 +677,7 @@
</div>
<div
id="add-token-info"
class="text-xs text-muted mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs text-muted mb-2 hidden"
></div>
<div class="mb-2">
<label class="block mb-1 text-xs text-muted"
@@ -849,7 +735,7 @@
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Display</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
class="text-xs flex items-center gap-1 cursor-pointer"
>
<input
type="checkbox"
@@ -857,17 +743,6 @@
/>
Show tracked tokens with zero balance
</label>
<div class="text-xs flex items-center gap-1">
<label for="settings-theme">Theme:</label>
<select
id="settings-theme"
class="border border-border p-1 bg-bg text-fg text-xs cursor-pointer"
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<div class="bg-well p-3 mx-1 mb-3">
@@ -949,12 +824,6 @@
/>
<span class="text-xs text-muted">gwei</span>
</div>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input type="checkbox" id="settings-utc-timestamps" />
UTC Timestamps
</label>
</div>
<div class="bg-well p-3 mx-1 mb-3">
@@ -990,8 +859,7 @@
</p>
<div
id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs text-red-500 mb-2 hidden"
></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
@@ -1066,8 +934,7 @@
/>
<div
id="settings-addtoken-info"
class="text-xs text-muted mt-1 min-h-[1.25rem]"
style="visibility: hidden"
class="text-xs text-muted mt-1 hidden"
></div>
<button
id="btn-settings-addtoken-manual"
@@ -1149,20 +1016,6 @@
<!-- ============ TRANSACTION APPROVAL ============ -->
<div id="view-approve-tx" class="view hidden">
<h2 class="font-bold mb-2">Transaction Request</h2>
<div
id="approve-tx-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden"
style="
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
border-radius: 6px;
"
>
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. This transaction may steal your funds. Proceed
with extreme caution.
</div>
<p class="mb-2">
<span id="approve-tx-hostname" class="font-bold"></span>
wants to send a transaction.
@@ -1205,11 +1058,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 mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div id="approve-tx-error" class="text-xs hidden mb-2"></div>
<div class="flex justify-between">
<button
id="btn-approve-tx"
@@ -1229,20 +1078,6 @@
<!-- ============ SIGNATURE APPROVAL ============ -->
<div id="view-approve-sign" class="view hidden">
<h2 class="font-bold mb-2">Signature Request</h2>
<div
id="approve-sign-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden"
style="
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
border-radius: 6px;
"
>
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. Signing this message may authorize theft of your
funds. Proceed with extreme caution.
</div>
<p class="mb-2">
<span id="approve-sign-hostname" class="font-bold"></span>
wants you to sign a message.
@@ -1250,10 +1085,8 @@
<div
id="approve-sign-danger-warning"
class="mb-3 p-2 text-xs font-bold"
class="hidden mb-3 p-2 text-xs font-bold"
style="
visibility: hidden;
min-height: 1.25rem;
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
@@ -1288,11 +1121,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 mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div id="approve-sign-error" class="text-xs hidden mb-2"></div>
<div class="flex justify-between">
<button
id="btn-approve-sign"
@@ -1312,20 +1141,6 @@
<!-- ============ SITE APPROVAL ============ -->
<div id="view-approve-site" class="view hidden">
<h2 class="font-bold mb-2">Connection Request</h2>
<div
id="approve-site-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden"
style="
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
border-radius: 6px;
"
>
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. Connecting your wallet may result in loss of
funds. Proceed with extreme caution.
</div>
<div class="mb-3">
<p class="mb-2">
<span id="approve-hostname" class="font-bold"></span>

View File

@@ -6,11 +6,11 @@ const { state, saveState, loadState } = require("../shared/state");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { $, showView } = require("./views/helpers");
const { applyTheme } = require("./theme");
const home = require("./views/home");
const welcome = require("./views/welcome");
const addWallet = require("./views/addWallet");
const importKey = require("./views/importKey");
const addressDetail = require("./views/addressDetail");
const addressToken = require("./views/addressToken");
const send = require("./views/send");
@@ -54,6 +54,7 @@ const ctx = {
renderWalletList,
doRefreshAndRender,
showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(),
showAddressToken: () => addressToken.show(),
showAddTokenView: () => addToken.show(),
@@ -73,7 +74,6 @@ const RESTORABLE_VIEWS = new Set([
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
@@ -127,13 +127,6 @@ function restoreView() {
case "settings-addtoken":
settingsAddToken.show();
break;
case "confirm-tx":
if (state.viewData && state.viewData.pendingTx) {
confirmTx.restore();
} else {
fallbackView();
}
break;
case "transaction":
if (state.viewData && state.viewData.tx) {
transactionDetail.render();
@@ -177,7 +170,6 @@ async function init() {
}
await loadState();
applyTheme(state.theme);
// Auto-default active address
if (
@@ -217,6 +209,7 @@ async function init() {
welcome.init(ctx);
addWallet.init(ctx);
importKey.init(ctx);
home.init(ctx);
addressDetail.init(ctx);
addressToken.init(ctx);

View File

@@ -11,36 +11,10 @@
--color-border-light: #cccccc;
--color-hover: #eeeeee;
--color-well: #f5f5f5;
--color-danger-well: #fef2f2;
--color-section: #dddddd;
}
html.dark {
--color-bg: #000000;
--color-fg: #ffffff;
--color-muted: #aaaaaa;
--color-border: #ffffff;
--color-border-light: #444444;
--color-hover: #222222;
--color-well: #1a1a1a;
--color-danger-well: #2a0a0a;
--color-section: #2a2a2a;
}
body {
width: 396px;
overflow-x: hidden;
}
/* Copy-flash feedback: inverts colors then fades back */
.copy-flash-active {
background-color: var(--color-fg) !important;
color: var(--color-bg) !important;
transition: none;
}
.copy-flash-fade {
transition:
background-color 225ms ease-out,
color 225ms ease-out;
}

View File

@@ -1,33 +0,0 @@
// Theme management: applies light/dark class to <html> based on preference.
let mediaQuery = null;
let mediaHandler = null;
function applyTheme(theme) {
// Clean up previous system listener
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
}
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else if (theme === "light") {
document.documentElement.classList.remove("dark");
} else {
// system
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => {
if (mediaQuery.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
mediaHandler = update;
mediaQuery.addEventListener("change", update);
update();
}
}
module.exports = { applyTheme };

View File

@@ -7,8 +7,7 @@ const { log } = require("../../shared/log");
function show() {
$("add-token-address").value = "";
$("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden";
$("add-token-info").classList.add("hidden");
const list = $("common-token-list");
list.innerHTML = getTopTokens(25)
.map(
@@ -46,7 +45,7 @@ function init(ctx) {
}
const infoEl = $("add-token-info");
infoEl.textContent = "Looking up token...";
infoEl.style.visibility = "visible";
infoEl.classList.remove("hidden");
log.debugf("Looking up token contract", contractAddr);
try {
const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
@@ -64,8 +63,7 @@ function init(ctx) {
const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", contractAddr, detail);
showFlash(detail);
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
infoEl.classList.add("hidden");
}
});

View File

@@ -3,101 +3,31 @@ const {
generateMnemonic,
hdWalletFromMnemonic,
isValidMnemonic,
addressFromPrivateKey,
hdWalletFromXprv,
isValidXprv,
} = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
/**
* Check if an address already exists in ANY wallet (hd, xprv, or key).
* Returns the wallet object if found, or undefined.
*/
function findWalletByAddress(addr) {
const lower = addr.toLowerCase();
return state.wallets.find((w) =>
w.addresses.some((a) => a.address.toLowerCase() === lower),
);
}
/**
* Check if an xpub already exists in any HD-type wallet (hd or xprv).
* Returns the wallet object if found, or undefined.
*/
function findWalletByXpub(xpub) {
return state.wallets.find((w) => w.xpub && w.xpub === xpub);
}
let currentMode = "mnemonic";
const MODES = ["mnemonic", "privkey", "xprv"];
const PASSWORD_HINTS = {
mnemonic:
"This password encrypts your recovery phrase on this device. You will need it to send funds.",
privkey:
"This password encrypts your private key on this device. You will need it to send funds.",
xprv: "This password encrypts your key on this device. You will need it to send funds.",
};
function switchMode(mode) {
currentMode = mode;
for (const m of MODES) {
$("add-wallet-section-" + m).classList.toggle("hidden", m !== mode);
const tab = $("tab-" + m);
const isActive = m === mode;
// Active: bold, solid border on top/sides, no bottom border (connects to content)
tab.classList.toggle("font-bold", isActive);
tab.classList.toggle("border-solid", isActive);
tab.classList.toggle("border-border", isActive);
tab.classList.toggle("border-b-bg", isActive);
tab.classList.toggle("bg-bg", isActive);
// Inactive: muted text, dashed border on top/sides, transparent bottom, hover invert
tab.classList.toggle("text-muted", !isActive);
tab.classList.toggle("border-dashed", !isActive);
tab.classList.toggle("border-border-light", !isActive);
tab.classList.toggle("border-b-transparent", !isActive);
tab.classList.toggle("hover:bg-fg", !isActive);
tab.classList.toggle("hover:text-bg", !isActive);
}
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
}
function show() {
$("wallet-mnemonic").value = "";
$("import-private-key").value = "";
$("import-xprv-key").value = "";
$("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").style.visibility = "hidden";
switchMode("mnemonic");
$("add-wallet-phrase-warning").classList.add("hidden");
showView("add-wallet");
}
function validatePassword() {
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
return null;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return null;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return null;
}
return pw;
}
function init(ctx) {
$("btn-generate-phrase").addEventListener("click", () => {
$("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").classList.remove("hidden");
});
async function importMnemonic(ctx) {
$("btn-add-wallet-confirm").addEventListener("click", async () => {
const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) {
showFlash("Enter a recovery phrase or press the die to generate one.");
showFlash(
"Enter a recovery phrase or press the die to generate one.",
);
return;
}
const words = mnemonic.split(/\s+/);
@@ -113,19 +43,34 @@ async function importMnemonic(ctx) {
showFlash("Invalid recovery phrase. Check for typos.");
return;
}
const pw = validatePassword();
if (!pw) return;
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const xpubDup = findWalletByXpub(xpub);
if (xpubDup) {
showFlash(
"This recovery phrase is already added (" + xpubDup.name + ").",
);
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
return;
}
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in wallet (" + addrDup.name + ").");
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return;
}
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const duplicate = state.wallets.find(
(w) =>
w.type === "hd" &&
w.addresses[0] &&
w.addresses[0].address.toLowerCase() ===
firstAddress.toLowerCase(),
);
if (duplicate) {
showFlash(
"This recovery phrase is already added (" +
duplicate.name +
").",
);
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw);
@@ -164,138 +109,8 @@ async function importMnemonic(ctx) {
}
ctx.doRefreshAndRender();
}
async function importPrivateKey(ctx) {
const key = $("import-private-key").value.trim();
if (!key) {
showFlash("Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
showFlash("Invalid private key.");
return;
}
const pw = validatePassword();
if (!pw) return;
const duplicate = findWalletByAddress(addr);
if (duplicate) {
showFlash(
"This address already exists in wallet (" + duplicate.name + ").",
);
return;
}
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
type: "key",
name: "Wallet " + walletNum,
encryptedSecret: encrypted,
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
});
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
}
async function importXprvKey(ctx) {
const xprv = $("import-xprv-key").value.trim();
if (!xprv) {
showFlash("Please enter your extended private key.");
return;
}
if (!isValidXprv(xprv)) {
showFlash("Invalid extended private key.");
return;
}
let result;
try {
result = hdWalletFromXprv(xprv);
} catch (e) {
showFlash("Invalid extended private key.");
return;
}
const { xpub, firstAddress } = result;
const xpubDup = findWalletByXpub(xpub);
if (xpubDup) {
showFlash("This key is already added (" + xpubDup.name + ").");
return;
}
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in wallet (" + addrDup.name + ").");
return;
}
const pw = validatePassword();
if (!pw) return;
const encrypted = await encryptWithPassword(xprv, pw);
const walletNum = state.wallets.length + 1;
const wallet = {
type: "xprv",
name: "Wallet " + walletNum,
xpub: xpub,
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
};
state.wallets.push(wallet);
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
balance: "0.0000",
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
await saveState();
ctx.renderWalletList();
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
}
function init(ctx) {
// Tab click handlers
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
$("tab-privkey").addEventListener("click", () => switchMode("privkey"));
$("tab-xprv").addEventListener("click", () => switchMode("xprv"));
// Generate mnemonic
$("btn-generate-phrase").addEventListener("click", () => {
$("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").style.visibility = "visible";
});
// Import / confirm
$("btn-add-wallet-confirm").addEventListener("click", async () => {
if (currentMode === "mnemonic") {
await importMnemonic(ctx);
} else if (currentMode === "privkey") {
await importPrivateKey(ctx);
} else if (currentMode === "xprv") {
await importXprvKey(ctx);
}
});
// Back button
$("btn-add-wallet-back").addEventListener("click", () => {
if (!state.hasWallet) {
showView("welcome");
@@ -304,6 +119,11 @@ function init(ctx) {
showView("main");
}
});
$("btn-add-wallet-import-key").addEventListener(
"click",
ctx.showImportKeyView,
);
}
module.exports = { init, show };

View File

@@ -2,7 +2,6 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
balanceLinesForAddress,
addressDotHtml,
addressTitle,
@@ -16,15 +15,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;
@@ -95,39 +88,18 @@ function show() {
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
pad(d.getSeconds())
);
}
@@ -263,7 +235,6 @@ function init(_ctx) {
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback($("address-full"));
}
});
@@ -286,7 +257,6 @@ function init(_ctx) {
$("send-token").classList.remove("hidden");
$("send-token-static").classList.add("hidden");
updateSendBalance();
resetSendValidation();
showView("send");
});
@@ -295,110 +265,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 = "";
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
$("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) {
$("export-privkey-flash").textContent = "Password is required.";
$("export-privkey-flash").style.visibility = "visible";
return;
}
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
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");
$("export-privkey-flash").style.visibility = "hidden";
} catch {
$("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").style.visibility = "visible";
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
});
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (key) {
navigator.clipboard.writeText(key);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-value"));
}
});
$("export-privkey-address").addEventListener("click", () => {
const full = $("export-privkey-address").dataset.full;
if (full) {
navigator.clipboard.writeText(full);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-address"));
}
});
$("btn-export-privkey-back").addEventListener("click", () => {
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
show();
});
}
module.exports = { init, show };

View File

@@ -5,7 +5,6 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
addressDotHtml,
addressTitle,
escapeHtml,
@@ -13,7 +12,7 @@ const {
balanceLine,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const {
formatUsd,
getPrice,
@@ -24,11 +23,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");
@@ -48,39 +43,18 @@ function etherscanAddressLink(address) {
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
pad(d.getSeconds())
);
}
@@ -122,11 +96,14 @@ function show() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
const tracked = (state.trackedTokens || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol =
(tb && tb.symbol) ||
(tracked && tracked.symbol) ||
(knownToken && knownToken.symbol) ||
"?";
amount = tb ? parseFloat(tb.balance || "0") : 0;
price = getPrice(symbol);
}
@@ -339,7 +316,6 @@ function init(_ctx) {
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback($("address-token-full"));
}
});
@@ -348,7 +324,6 @@ function init(_ctx) {
if (copyEl) {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
}
});
@@ -397,11 +372,9 @@ function init(_ctx) {
copyEl.addEventListener("click", () => {
navigator.clipboard.writeText(copyEl.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(copyEl);
});
}
updateSendBalance();
resetSendValidation();
showView("send");
});

View File

@@ -4,8 +4,6 @@ const {
addressTitle,
escapeHtml,
showView,
showError,
hideError,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
@@ -13,7 +11,6 @@ const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const { isPhishingDomain } = require("../../shared/phishingDomains");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -156,24 +153,7 @@ function decodeCalldata(data, toAddress) {
return null;
}
function showPhishingWarning(elementId, hostname, isPhishing) {
const el = $(elementId);
if (!el) return;
// Check both the flag from background and a local re-check
if (isPhishing || isPhishingDomain(hostname)) {
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
}
}
function showTxApproval(details) {
showPhishingWarning(
"approve-tx-phishing-warning",
details.hostname,
details.isPhishingDomain,
);
const toAddr = details.txParams.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(details.txParams.value || "0");
@@ -187,11 +167,11 @@ function showTxApproval(details) {
tokenSymbol: token ? token.symbol : null,
};
// If this is an ERC-20 call, try to extract the real recipient and amount
// If this is an ERC-20 call or a swap, extract the real recipient, amount, and token info
const decoded = decodeCalldata(details.txParams.data, toAddr || "");
if (decoded && decoded.details) {
let decodedTokenAddr = null;
let decodedTokenSymbol = null;
let decodedTokenAddress = null;
for (const d of decoded.details) {
if (d.label === "Recipient" && d.address) {
pendingTxDetails.to = d.address;
@@ -199,32 +179,22 @@ function showTxApproval(details) {
if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value;
}
if (d.label === "Token In" && d.isToken && d.address) {
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
if (t) {
decodedTokenAddr = d.address;
decodedTokenSymbol = t.symbol;
}
if (d.label === "Token In" && !decodedTokenSymbol) {
// Extract token symbol and address from decoded details
decodedTokenSymbol = d.value;
if (d.address) decodedTokenAddress = d.address;
}
}
if (token) {
pendingTxDetails.token = toAddr;
pendingTxDetails.tokenSymbol = token.symbol;
} else if (decodedTokenAddr) {
pendingTxDetails.token = decodedTokenAddr;
} else if (decodedTokenAddress) {
// For swaps through routers: use the input token info
pendingTxDetails.token = decodedTokenAddress;
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
}
}
// 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);
@@ -286,9 +256,6 @@ function showTxApproval(details) {
$("approve-tx-data-section").classList.add("hidden");
}
$("approve-tx-password").value = "";
hideError("approve-tx-error");
showView("approve-tx");
}
@@ -341,12 +308,6 @@ function formatTypedDataHtml(jsonStr) {
}
function showSignApproval(details) {
showPhishingWarning(
"approve-sign-phishing-warning",
details.hostname,
details.isPhishingDomain,
);
const sp = details.signParams;
$("approve-sign-hostname").textContent = details.hostname;
@@ -375,15 +336,15 @@ function showSignApproval(details) {
if (warningEl) {
if (sp.dangerWarning) {
warningEl.textContent = sp.dangerWarning;
warningEl.style.visibility = "visible";
warningEl.classList.remove("hidden");
} else {
warningEl.textContent = "";
warningEl.style.visibility = "hidden";
warningEl.classList.add("hidden");
}
}
$("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");
@@ -406,12 +367,6 @@ function show(id) {
showSignApproval(details);
return;
}
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
details.hostname,
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress,
@@ -454,10 +409,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");
@@ -493,10 +449,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");
@@ -514,7 +471,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");
}

View File

@@ -1,6 +1,6 @@
// Transaction confirmation view with inline password.
// Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts.
// Transaction confirmation view + password modal.
// Shows transaction details, warnings, errors. On proceed, opens
// password modal, decrypts secret, signs and broadcasts.
const {
parseEther,
@@ -14,8 +14,6 @@ const {
showError,
hideError,
showView,
showFlash,
flashCopyFeedback,
addressTitle,
addressDotHtml,
escapeHtml,
@@ -25,11 +23,8 @@ const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
const { getProvider } = require("../../shared/balances");
const {
getLocalWarnings,
getFullWarnings,
} = require("../../shared/addressWarnings");
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const { isScamAddress } = require("../../shared/scamlist");
const { ERC20_ABI } = require("../../shared/constants");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus");
@@ -43,13 +38,6 @@ const EXT_ICON =
let pendingTx = null;
function restore() {
const d = state.viewData;
if (d && d.pendingTx) {
show(d.pendingTx);
}
}
function etherscanTokenLink(address) {
return `https://etherscan.io/token/${address}`;
}
@@ -107,23 +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!");
flashCopyFeedback(copyEl);
};
}
} else {
tokenSection.classList.add("hidden");
}
@@ -170,23 +146,28 @@ function show(txInfo) {
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
}
// Check for warnings (synchronous local checks)
const localWarnings = getLocalWarnings(txInfo.to, {
fromAddress: txInfo.from,
});
// Check for warnings
const warnings = [];
if (isScamAddress(txInfo.to)) {
warnings.push(
"This address is on a known scam/fraud list. Do not send funds to this address.",
);
}
if (txInfo.to.toLowerCase() === txInfo.from.toLowerCase()) {
warnings.push("You are sending to your own address.");
}
const warningsEl = $("confirm-warnings");
if (localWarnings.length > 0) {
warningsEl.innerHTML = localWarnings
if (warnings.length > 0) {
warningsEl.innerHTML = warnings
.map(
(w) =>
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w.message}</div>`,
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w}</div>`,
)
.join("");
warningsEl.style.visibility = "visible";
warningsEl.classList.remove("hidden");
} else {
warningsEl.innerHTML = "";
warningsEl.style.visibility = "hidden";
warningsEl.classList.add("hidden");
}
// Check for errors
@@ -224,38 +205,21 @@ function show(txInfo) {
errorsEl.innerHTML = errors
.map((e) => `<div class="text-xs">${e}</div>`)
.join("");
errorsEl.style.visibility = "visible";
errorsEl.classList.remove("hidden");
sendBtn.disabled = true;
sendBtn.classList.add("text-muted");
} else {
errorsEl.innerHTML = "";
errorsEl.style.visibility = "hidden";
errorsEl.classList.add("hidden");
sendBtn.disabled = false;
sendBtn.classList.remove("text-muted");
}
// Reset password field and error
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
// Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible";
$("confirm-fee").classList.remove("hidden");
$("confirm-fee-amount").textContent = "Estimating...";
state.viewData = { pendingTx: txInfo };
showView("confirm-tx");
// Reset async warnings to hidden (space always reserved, no layout shift)
$("confirm-recipient-warning").style.visibility = "hidden";
$("confirm-contract-warning").style.visibility = "hidden";
$("confirm-burn-warning").style.visibility = "hidden";
// Show burn warning via reserved element (in addition to inline warning)
if (isBurnAddress(txInfo.to)) {
$("confirm-burn-warning").style.visibility = "visible";
}
estimateGas(txInfo);
checkRecipientHistory(txInfo);
}
async function estimateGas(txInfo) {
@@ -298,39 +262,39 @@ async function estimateGas(txInfo) {
}
}
async function checkRecipientHistory(txInfo) {
try {
const provider = getProvider(state.rpcUrl);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
});
for (const w of asyncWarnings) {
if (w.type === "contract") {
$("confirm-contract-warning").style.visibility = "visible";
}
if (w.type === "new-address") {
$("confirm-recipient-warning").style.visibility = "visible";
}
}
} catch (e) {
log.errorf("recipient history check failed:", e.message);
}
function showPasswordModal() {
$("modal-password").value = "";
hideError("modal-password-error");
$("password-modal").classList.remove("hidden");
}
function hidePasswordModal() {
$("password-modal").classList.add("hidden");
}
function init(ctx) {
$("btn-confirm-send").addEventListener("click", async () => {
const password = $("confirm-tx-password").value;
$("btn-confirm-send").addEventListener("click", () => {
showPasswordModal();
});
$("btn-confirm-back").addEventListener("click", () => {
showView("send");
});
$("btn-modal-cancel").addEventListener("click", () => {
hidePasswordModal();
});
$("btn-modal-confirm").addEventListener("click", async () => {
const password = $("modal-password").value;
if (!password) {
showError(
"confirm-tx-password-error",
"Please enter your password.",
);
showError("modal-password-error", "Please enter your password.");
return;
}
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret;
hideError("confirm-tx-password-error");
hideError("modal-password-error");
try {
decryptedSecret = await decryptWithPassword(
@@ -338,12 +302,11 @@ function init(ctx) {
password,
);
} catch (e) {
showError("confirm-tx-password-error", "Wrong password.");
showError("modal-password-error", "Wrong password.");
return;
}
$("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
hidePasswordModal();
let tx;
try {
@@ -380,15 +343,8 @@ function init(ctx) {
decryptedSecret = null;
const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
} finally {
$("btn-confirm-send").disabled = false;
$("btn-confirm-send").classList.remove("text-muted");
}
});
$("btn-confirm-back").addEventListener("click", () => {
showView("send");
});
}
module.exports = { init, show, restore };
module.exports = { init, show };

View File

@@ -12,7 +12,7 @@ function show(walletIdx) {
wallet.name || "Wallet " + (walletIdx + 1);
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").style.visibility = "hidden";
$("delete-wallet-flash").classList.add("hidden");
showView("delete-wallet-confirm");
}
@@ -29,21 +29,17 @@ function init(_ctx) {
if (!pw) {
$("delete-wallet-flash").textContent =
"Please enter your password.";
$("delete-wallet-flash").style.visibility = "visible";
$("delete-wallet-flash").classList.remove("hidden");
return;
}
if (deleteWalletIndex === null) {
$("delete-wallet-flash").textContent =
"No wallet selected for deletion.";
$("delete-wallet-flash").style.visibility = "visible";
$("delete-wallet-flash").classList.remove("hidden");
return;
}
const btn = $("btn-delete-wallet-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const walletIdx = deleteWalletIndex;
const wallet = state.wallets[walletIdx];
@@ -52,9 +48,7 @@ function init(_ctx) {
await decryptWithPassword(wallet.encryptedSecret, pw);
} catch (_e) {
$("delete-wallet-flash").textContent = "Wrong password.";
$("delete-wallet-flash").style.visibility = "visible";
btn.disabled = false;
btn.classList.remove("text-muted");
$("delete-wallet-flash").classList.remove("hidden");
return;
}

View File

@@ -13,6 +13,7 @@ const { state, saveState } = require("../../shared/state");
const VIEWS = [
"welcome",
"add-wallet",
"import-key",
"main",
"address",
"address-token",
@@ -30,7 +31,6 @@ const VIEWS = [
"approve-site",
"approve-tx",
"approve-sign",
"export-privkey",
];
function $(id) {
@@ -40,13 +40,11 @@ function $(id) {
function showError(id, msg) {
const el = $(id);
el.textContent = msg;
el.style.visibility = "visible";
el.classList.remove("hidden");
}
function hideError(id) {
const el = $(id);
el.textContent = "";
el.style.visibility = "hidden";
$(id).classList.add("hidden");
}
function showView(name) {
@@ -228,39 +226,18 @@ function formatAddressHtml(address, ensName, maxLen, title) {
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
pad(d.getSeconds())
);
}
@@ -281,26 +258,12 @@ function timeAgo(timestamp) {
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
function flashCopyFeedback(el) {
if (!el) return;
el.classList.remove("copy-flash-fade");
el.classList.add("copy-flash-active");
setTimeout(() => {
el.classList.remove("copy-flash-active");
el.classList.add("copy-flash-fade");
setTimeout(() => {
el.classList.remove("copy-flash-fade");
}, 275);
}, 75);
}
module.exports = {
$,
showError,
hideError,
showView,
showFlash,
flashCopyFeedback,
balanceLine,
balanceLinesForAddress,
addressColor,

View File

@@ -2,7 +2,6 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
balanceLinesForAddress,
isoDate,
timeAgo,
@@ -12,11 +11,7 @@ const {
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,
@@ -86,10 +81,9 @@ function renderActiveAddress() {
el.innerHTML =
`<span class="underline decoration-dashed cursor-pointer" id="active-addr-copy">${dot}${escapeHtml(addr)}</span>` +
`<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
$("active-addr-copy").addEventListener("click", (e) => {
$("active-addr-copy").addEventListener("click", () => {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
});
} else {
el.textContent = "";
@@ -241,7 +235,7 @@ function render(ctx) {
html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
if (wallet.type === "hd" || wallet.type === "xprv") {
if (wallet.type === "hd") {
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
}
html += `</div>`;
@@ -394,7 +388,6 @@ function init(ctx) {
$("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr);
updateSendBalance();
resetSendValidation();
showView("send");
});

View File

@@ -0,0 +1,69 @@
const { $, showView, showFlash } = require("./helpers");
const { addressFromPrivateKey } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
function show() {
$("import-private-key").value = "";
$("import-key-password").value = "";
$("import-key-password-confirm").value = "";
showView("import-key");
}
function init(ctx) {
$("btn-import-key-confirm").addEventListener("click", async () => {
const key = $("import-private-key").value.trim();
if (!key) {
showFlash("Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
showFlash("Invalid private key.");
return;
}
const pw = $("import-key-password").value;
const pw2 = $("import-key-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
return;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
return;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
return;
}
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
state.wallets.push({
type: "key",
name: "Wallet " + walletNum,
encryptedSecret: encrypted,
addresses: [
{ address: addr, balance: "0.0000", tokenBalances: [] },
],
});
state.hasWallet = true;
await saveState();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
});
$("btn-import-key-back").addEventListener("click", () => {
if (!state.hasWallet) {
showView("welcome");
} else {
ctx.renderWalletList();
showView("main");
}
});
}
module.exports = { init, show };

View File

@@ -1,11 +1,4 @@
const {
$,
showView,
showFlash,
flashCopyFeedback,
formatAddressHtml,
addressTitle,
} = require("./helpers");
const { $, showView, showFlash, addressDotHtml } = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
const QRCode = require("qrcode");
@@ -19,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>`
@@ -53,30 +42,19 @@ function show() {
"This is an ERC-20 token. Only send " +
symbol +
" on the Ethereum network to this address. Sending tokens on other networks will result in permanent loss.";
warningEl.style.visibility = "visible";
warningEl.classList.remove("hidden");
} else {
warningEl.textContent = "";
warningEl.style.visibility = "hidden";
warningEl.classList.add("hidden");
}
showView("receive");
}
function init(ctx) {
$("receive-address-block").addEventListener("click", (e) => {
const addr = $("receive-address-block").dataset.full;
if (addr) {
navigator.clipboard.writeText(addr);
showFlash("Copied!");
flashCopyFeedback(e.currentTarget);
}
});
$("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!");
flashCopyFeedback($("receive-address-block"));
}
});

View File

@@ -10,108 +10,7 @@ const {
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, TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const EXT_ICON =
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
@@ -174,11 +73,15 @@ function updateSendBalance() {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const symbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
const knownToken = TOKEN_BY_ADDRESS.get(token.toLowerCase());
const tracked = (state.trackedTokens || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const symbol =
(tb && tb.symbol) ||
(tracked && tracked.symbol) ||
(knownToken && knownToken.symbol) ||
"?";
const bal = tb ? tb.balance || "0" : "0";
$("send-balance").textContent =
"Current balance: " + bal + " " + symbol;
@@ -189,13 +92,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 +99,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 +132,15 @@ function init(_ctx) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
const knownTk = TOKEN_BY_ADDRESS.get(token.toLowerCase());
const trackedTk = (state.trackedTokens || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
tokenSymbol =
(tb && tb.symbol) ||
(trackedTk && trackedTk.symbol) ||
(knownTk && knownTk.symbol) ||
"?";
tokenBalance = tb ? tb.balance || "0" : "0";
}
@@ -276,19 +167,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

@@ -1,5 +1,4 @@
const { $, showView, showFlash, escapeHtml } = require("./helpers");
const { applyTheme } = require("../theme");
const { state, saveState } = require("../../shared/state");
const { ETHEREUM_MAINNET_CHAIN_ID } = require("../../shared/constants");
const { log, debugFetch } = require("../../shared/log");
@@ -215,13 +214,6 @@ function init(ctx) {
await saveState();
});
$("settings-theme").value = state.theme;
$("settings-theme").addEventListener("change", async () => {
state.theme = $("settings-theme").value;
await saveState();
applyTheme(state.theme);
});
$("settings-hide-low-holders").checked = state.hideLowHolderTokens;
$("settings-hide-low-holders").addEventListener("change", async () => {
state.hideLowHolderTokens = $("settings-hide-low-holders").checked;
@@ -249,12 +241,6 @@ function init(ctx) {
}
});
$("settings-utc-timestamps").checked = state.utcTimestamps;
$("settings-utc-timestamps").addEventListener("change", async () => {
state.utcTimestamps = $("settings-utc-timestamps").checked;
await saveState();
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-add-token").addEventListener(

View File

@@ -73,8 +73,7 @@ function renderDropdown() {
function show() {
$("settings-addtoken-address").value = "";
$("settings-addtoken-info").textContent = "";
$("settings-addtoken-info").style.visibility = "hidden";
$("settings-addtoken-info").classList.add("hidden");
renderTop10();
renderDropdown();
showView("settings-addtoken");
@@ -130,7 +129,7 @@ function init(_ctx) {
}
const infoEl = $("settings-addtoken-info");
infoEl.textContent = "Looking up token...";
infoEl.style.visibility = "visible";
infoEl.classList.remove("hidden");
log.debugf("Looking up token contract", addr);
try {
const info = await lookupTokenInfo(addr, state.rpcUrl);
@@ -144,8 +143,7 @@ function init(_ctx) {
await saveState();
showFlash("Added " + info.symbol);
$("settings-addtoken-address").value = "";
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
infoEl.classList.add("hidden");
renderTop10();
renderDropdown();
ctx.doRefreshAndRender();
@@ -153,8 +151,7 @@ function init(_ctx) {
const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", addr, detail);
showFlash(detail);
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
infoEl.classList.add("hidden");
}
});
}

View File

@@ -5,7 +5,6 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
addressDotHtml,
addressTitle,
escapeHtml,
@@ -144,10 +143,11 @@ function render() {
typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden");
}
if (headingEl) headingEl.textContent = tx.directionLabel;
} 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");
@@ -159,9 +159,8 @@ function render() {
loadCalldata(tx.hash, tx.to);
}
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
$("tx-detail-time").textContent =
isoDate(tx.timestamp) + " (" + timeAgo(tx.timestamp) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction");
@@ -172,7 +171,6 @@ function render() {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}
@@ -250,7 +248,6 @@ async function loadCalldata(txHash, toAddress) {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}

View File

@@ -4,12 +4,10 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
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");
@@ -44,11 +42,10 @@ function toAddressHtml(address) {
if (title) {
return (
`<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>` +
`<div class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</div>` +
extLink
`<div class="break-all">${escapeHtml(address)}${extLink}</div>`
);
}
return `<div class="flex items-center">${dot}<span class="break-all underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(address)}">${escapeHtml(address)}</span>${extLink}</div>`;
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(address)}</span>${extLink}</div>`;
}
function txHashHtml(hash) {
@@ -60,16 +57,6 @@ function txHashHtml(hash) {
);
}
function blockNumberHtml(blockNumber) {
const num = String(blockNumber);
const link = `https://etherscan.io/block/${num}`;
const extLink = `<a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
return (
`<span class="underline decoration-dashed cursor-pointer" data-copy="${escapeHtml(num)}">${escapeHtml(num)}</span>` +
extLink
);
}
function attachCopyHandlers(viewId) {
document
.getElementById(viewId)
@@ -78,7 +65,6 @@ function attachCopyHandlers(viewId) {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}
@@ -135,84 +121,18 @@ 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 = `<div class="border border-border border-dashed p-2 mb-3">`;
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>`;
}
html += `</div>`;
return html;
}
function renderSuccess() {
const d = state.viewData;
if (!d || !d.hash) return;
const hasDecoded = d.decoded && d.decoded.details;
// When decoded details are present, the Amount and To are already
// shown inside the decoded well — hide the top-level duplicates.
const summarySection = $("success-tx-summary").parentElement;
const toSection = $("success-tx-to").parentElement;
if (hasDecoded) {
summarySection.classList.add("hidden");
toSection.classList.add("hidden");
} else {
summarySection.classList.remove("hidden");
toSection.classList.remove("hidden");
$("success-tx-summary").textContent = d.amount + " " + d.symbol;
$("success-tx-to").innerHTML = toAddressHtml(d.to);
}
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
$("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 && hasDecoded) {
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

@@ -1,109 +0,0 @@
// Address warning module.
// Provides local and async (RPC-based) warning checks for Ethereum addresses.
// Returns arrays of {type, message, severity} objects.
const { isScamAddress } = require("./scamlist");
const { isBurnAddress } = require("./constants");
const { checkEtherscanLabel } = require("./etherscanLabels");
const { log } = require("./log");
/**
* Check an address against local-only lists (scam, burn, self-send).
* Synchronous — no network calls.
*
* @param {string} address - The target address to check.
* @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check).
* @returns {Array<{type: string, message: string, severity: string}>}
*/
function getLocalWarnings(address, options = {}) {
const warnings = [];
const addr = address.toLowerCase();
if (isScamAddress(addr)) {
warnings.push({
type: "scam",
message:
"This address is on a known scam/fraud list. Do not send funds to this address.",
severity: "critical",
});
}
if (isBurnAddress(addr)) {
warnings.push({
type: "burn",
message:
"This is a known null/burn address. Funds sent here are permanently destroyed and cannot be recovered.",
severity: "critical",
});
}
if (options.fromAddress && addr === options.fromAddress.toLowerCase()) {
warnings.push({
type: "self-send",
message: "You are sending to your own address.",
severity: "warning",
});
}
return warnings;
}
/**
* Check an address against local lists AND via RPC queries.
* Async — performs network calls to check contract status and tx history.
*
* @param {string} address - The target address to check.
* @param {object} provider - An ethers.js provider instance.
* @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check).
* @returns {Promise<Array<{type: string, message: string, severity: string}>>}
*/
async function getFullWarnings(address, provider, options = {}) {
const warnings = getLocalWarnings(address, options);
try {
const code = await provider.getCode(address);
if (code && code !== "0x") {
warnings.push({
type: "contract",
message:
"This address is a smart contract, not a regular wallet.",
severity: "warning",
});
// If it's a contract, skip the tx count check — contracts
// may legitimately have zero inbound EOA transactions.
return warnings;
}
} catch (e) {
log.errorf("contract check failed:", e.message);
}
try {
const txCount = await provider.getTransactionCount(address);
if (txCount === 0) {
warnings.push({
type: "new-address",
message:
"This address has never sent a transaction. Double-check it is correct.",
severity: "info",
});
}
} catch (e) {
log.errorf("tx count check failed:", e.message);
}
// Etherscan label check (best-effort async — network failures are silent).
try {
const etherscanWarning = await checkEtherscanLabel(address);
if (etherscanWarning) {
warnings.push(etherscanWarning);
}
} catch (e) {
log.errorf("etherscan label check failed:", e.message);
}
return warnings;
}
module.exports = { getLocalWarnings, getFullWarnings };

View File

@@ -20,19 +20,6 @@ const ERC20_ABI = [
"function approve(address spender, uint256 amount) returns (bool)",
];
// Known null/burn addresses that permanently destroy funds.
const BURN_ADDRESSES = new Set([
"0x0000000000000000000000000000000000000000",
"0x0000000000000000000000000000000000000001",
"0x000000000000000000000000000000000000dead",
"0xdead000000000000000000000000000000000000",
"0x00000000000000000000000000000000deadbeef",
]);
function isBurnAddress(address) {
return BURN_ADDRESSES.has(address.toLowerCase());
}
module.exports = {
DEBUG,
DEBUG_MNEMONIC,
@@ -41,6 +28,4 @@ module.exports = {
DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH,
ERC20_ABI,
BURN_ADDRESSES,
isBurnAddress,
};

View File

@@ -1,102 +0,0 @@
// Etherscan address label lookup via page scraping.
// Extension users make the requests directly to Etherscan — no proxy needed.
// This is a best-effort enrichment: network failures return null silently.
const ETHERSCAN_BASE = "https://etherscan.io/address/";
// Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
// Patterns in the page body that indicate a scam/phishing warning.
const SCAM_BODY_PATTERNS = [
/used in a\s+(?:\w+\s+)?phishing scam/i,
/used in a\s+(?:\w+\s+)?scam/i,
/wallet\s+drainer/i,
];
/**
* Parse the Etherscan address page HTML to extract label info.
* Exported for unit testing (no fetch needed).
*
* @param {string} html - Raw HTML of the Etherscan address page.
* @returns {{ label: string|null, isPhishing: boolean, warning: string|null }}
*/
function parseEtherscanPage(html) {
// Extract <title> content
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
let label = null;
let isPhishing = false;
let warning = null;
if (titleMatch) {
const title = titleMatch[1].trim();
// Title: "LABEL | Address: 0x... | Etherscan" or "Address: 0x... | Etherscan"
const labelMatch = title.match(/^(.+?)\s*\|\s*Address:/);
if (labelMatch) {
const candidate = labelMatch[1].trim();
// Only treat as a label if it's not just "Address" (unlabeled addresses)
if (candidate.toLowerCase() !== "address") {
label = candidate;
}
}
}
// Check label against phishing patterns
if (label) {
for (const pat of PHISHING_LABEL_PATTERNS) {
if (pat.test(label)) {
isPhishing = true;
warning = `Etherscan labels this address as "${label}" (Phish/Hack).`;
break;
}
}
}
// Check page body for scam warning banners
if (!isPhishing) {
for (const pat of SCAM_BODY_PATTERNS) {
if (pat.test(html)) {
isPhishing = true;
warning = label
? `Etherscan labels this address as "${label}" and reports it was used in a scam.`
: "Etherscan reports this address was flagged for phishing/scam activity.";
break;
}
}
}
return { label, isPhishing, warning };
}
/**
* Fetch an address page from Etherscan and check for scam/phishing labels.
* Returns a warning object if the address is flagged, or null.
* Network failures return null silently (best-effort check).
*
* @param {string} address - Ethereum address to check.
* @returns {Promise<{type: string, message: string, severity: string}|null>}
*/
async function checkEtherscanLabel(address) {
try {
const resp = await fetch(ETHERSCAN_BASE + address, {
headers: { Accept: "text/html" },
});
if (!resp.ok) return null;
const html = await resp.text();
const result = parseEtherscanPage(html);
if (result.isPhishing) {
return {
type: "etherscan-phishing",
message: result.warning,
severity: "critical",
};
}
return null;
} catch {
// Network errors are expected — Etherscan may rate-limit or block.
return null;
}
}
module.exports = { parseEtherscanPage, checkEtherscanLabel };

View File

@@ -1,133 +0,0 @@
// Domain-based phishing detection using MetaMask's eth-phishing-detect blocklist.
// Fetches the blocklist at runtime, caches it in memory, and checks hostnames.
//
// The blocklist source:
// https://github.com/MetaMask/eth-phishing-detect (src/config.json)
//
// The config uses { blacklist: [...], whitelist: [...], fuzzylist: [...] }.
// We check exact hostname and parent-domain matches against the blacklist,
// with whitelist overrides.
const BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
let blacklistSet = new Set();
let whitelistSet = new Set();
let lastFetchTime = 0;
let fetchPromise = null;
/**
* Load a pre-parsed config into the in-memory sets.
* Used for testing and for loading from cache.
*
* @param {{ blacklist?: string[], whitelist?: string[] }} config
*/
function loadConfig(config) {
blacklistSet = new Set(
(config.blacklist || []).map((d) => d.toLowerCase()),
);
whitelistSet = new Set(
(config.whitelist || []).map((d) => d.toLowerCase()),
);
lastFetchTime = Date.now();
}
/**
* Generate hostname variants for subdomain matching.
* "sub.evil.com" yields ["sub.evil.com", "evil.com"].
*
* @param {string} hostname
* @returns {string[]}
*/
function hostnameVariants(hostname) {
const h = hostname.toLowerCase();
const variants = [h];
const parts = h.split(".");
// Parent domains: a.b.c.d -> b.c.d, c.d
for (let i = 1; i < parts.length - 1; i++) {
variants.push(parts.slice(i).join("."));
}
return variants;
}
/**
* Check if a hostname is on the phishing blocklist.
* Checks exact hostname and all parent domains.
* Whitelisted domains are never flagged.
*
* @param {string} hostname - The hostname to check.
* @returns {boolean}
*/
function isPhishingDomain(hostname) {
if (!hostname) return false;
const variants = hostnameVariants(hostname);
// Whitelist takes priority
for (const v of variants) {
if (whitelistSet.has(v)) return false;
}
for (const v of variants) {
if (blacklistSet.has(v)) return true;
}
return false;
}
/**
* Fetch the latest blocklist from the MetaMask repo.
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS.
*
* @returns {Promise<void>}
*/
async function updatePhishingList() {
// Skip if recently fetched
if (Date.now() - lastFetchTime < CACHE_TTL_MS && blacklistSet.size > 0) {
return;
}
// De-duplicate concurrent calls
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
try {
const resp = await fetch(BLOCKLIST_URL);
if (!resp.ok) throw new Error("HTTP " + resp.status);
const config = await resp.json();
loadConfig(config);
} catch {
// Silently fail — we'll retry next time.
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
/**
* Return the current blocklist size (for diagnostics).
*
* @returns {number}
*/
function getBlocklistSize() {
return blacklistSet.size;
}
/**
* Reset internal state (for testing).
*/
function _reset() {
blacklistSet = new Set();
whitelistSet = new Set();
lastFetchTime = 0;
fetchPromise = null;
}
module.exports = {
isPhishingDomain,
updatePhishingList,
loadConfig,
getBlocklistSize,
hostnameVariants,
_reset,
};

File diff suppressed because it is too large Load Diff

View File

@@ -23,10 +23,8 @@ const DEFAULT_STATE = {
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
};
const state = {
@@ -55,10 +53,8 @@ async function saveState() {
hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions,
dustThresholdGwei: state.dustThresholdGwei,
utcTimestamps: state.utcTimestamps,
fraudContracts: state.fraudContracts,
tokenHolderCache: state.tokenHolderCache,
theme: state.theme,
currentView: state.currentView,
selectedWallet: state.selectedWallet,
selectedAddress: state.selectedAddress,
@@ -112,11 +108,8 @@ async function loadState() {
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
state.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system";
state.currentView = saved.currentView || null;
state.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : 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

@@ -153,11 +153,9 @@ 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,
// but preserve contract call metadata (direction, label, method) so
// swaps and other contract interactions display correctly.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
@@ -166,13 +164,8 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
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

@@ -445,17 +445,18 @@ function decode(data, toAddress) {
const maxUint160 = BigInt(
"0xffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = inputAmount >= maxUint160;
const amountRaw = isUnlimited
const rawAmount =
inputAmount >= maxUint160
? "Unlimited"
: formatAmount(inputAmount, inInfo.decimals);
const amountStr = isUnlimited
const amountStr =
inputAmount >= maxUint160
? "Unlimited"
: amountRaw + (inSymbol ? " " + inSymbol : "");
: rawAmount + (inSymbol ? " " + inSymbol : "");
details.push({
label: "Amount",
value: amountStr,
rawValue: amountRaw,
rawValue: rawAmount,
});
}

View File

@@ -24,26 +24,6 @@ function hdWalletFromMnemonic(mnemonic) {
return { xpub, firstAddress };
}
function hdWalletFromXprv(xprv) {
const root = HDNodeWallet.fromExtendedKey(xprv);
if (!root.privateKey) {
throw new Error("Not an extended private key (xprv).");
}
const node = root.derivePath("44'/60'/0'/0");
const xpub = node.neuter().extendedKey;
const firstAddress = node.deriveChild(0).address;
return { xpub, firstAddress };
}
function isValidXprv(key) {
try {
const node = HDNodeWallet.fromExtendedKey(key);
return !!node.privateKey;
} catch {
return false;
}
}
function addressFromPrivateKey(key) {
const w = new Wallet(key);
return w.address;
@@ -58,11 +38,6 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
);
return node.deriveChild(addrIndex);
}
if (walletData.type === "xprv") {
const root = HDNodeWallet.fromExtendedKey(decryptedSecret);
const node = root.derivePath("44'/60'/0'/0");
return node.deriveChild(addrIndex);
}
return new Wallet(decryptedSecret);
}
@@ -74,8 +49,6 @@ module.exports = {
generateMnemonic,
deriveAddressFromXpub,
hdWalletFromMnemonic,
hdWalletFromXprv,
isValidXprv,
addressFromPrivateKey,
getSignerForAddress,
isValidMnemonic,

View File

@@ -1,100 +0,0 @@
const { parseEtherscanPage } = require("../src/shared/etherscanLabels");
describe("etherscanLabels", () => {
describe("parseEtherscanPage", () => {
test("detects Fake_Phishing label in title", () => {
const html = `<html><head><title>Fake_Phishing184810 | Address: 0x00000c07...3ea470000 | Etherscan</title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Fake_Phishing184810");
expect(result.isPhishing).toBe(true);
expect(result.warning).toContain("Fake_Phishing184810");
expect(result.warning).toContain("Phish/Hack");
});
test("detects Fake_Phishing with different number", () => {
const html = `<html><head><title>Fake_Phishing5169 | Address: 0x3e0defb8...99a7a8a74 | Etherscan</title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Fake_Phishing5169");
expect(result.isPhishing).toBe(true);
});
test("detects Exploiter label", () => {
const html = `<html><head><title>Exploiter 42 | Address: 0xabcdef...1234 | Etherscan</title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Exploiter 42");
expect(result.isPhishing).toBe(true);
});
test("detects scam warning in body text", () => {
const html =
`<html><head><title>Address: 0xabcdef...1234 | Etherscan</title></head>` +
`<body>There are reports that this address was used in a Phishing scam.</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(true);
expect(result.warning).toContain("phishing/scam");
});
test("detects scam warning with label in body", () => {
const html =
`<html><head><title>SomeScammer | Address: 0xabcdef...1234 | Etherscan</title></head>` +
`<body>There are reports that this address was used in a scam.</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("SomeScammer");
expect(result.isPhishing).toBe(true);
expect(result.warning).toContain("SomeScammer");
});
test("returns clean result for legitimate address", () => {
const html = `<html><head><title>vitalik.eth | Address: 0xd8dA6BF2...37aA96045 | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("vitalik.eth");
expect(result.isPhishing).toBe(false);
expect(result.warning).toBeNull();
});
test("returns clean result for unlabeled address", () => {
const html = `<html><head><title>Address: 0x1234567890...abcdef | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(false);
expect(result.warning).toBeNull();
});
test("handles exchange labels correctly (not phishing)", () => {
const html = `<html><head><title>Coinbase 10 | Address: 0xa9d1e08c...b81d3e43 | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Coinbase 10");
expect(result.isPhishing).toBe(false);
});
test("handles contract names correctly (not phishing)", () => {
const html = `<html><head><title>Beacon Deposit Contract | Address: 0x00000000...03d7705Fa | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Beacon Deposit Contract");
expect(result.isPhishing).toBe(false);
});
test("handles empty HTML gracefully", () => {
const result = parseEtherscanPage("");
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(false);
expect(result.warning).toBeNull();
});
test("handles malformed title tag", () => {
const html = `<html><head><title></title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(false);
});
test("detects wallet drainer warning", () => {
const html =
`<html><head><title>Address: 0xabc...def | Etherscan</title></head>` +
`<body>This is a known wallet drainer contract.</body></html>`;
const result = parseEtherscanPage(html);
expect(result.isPhishing).toBe(true);
});
});
});

View File

@@ -1,166 +0,0 @@
const {
isPhishingDomain,
loadConfig,
getBlocklistSize,
hostnameVariants,
_reset,
} = require("../src/shared/phishingDomains");
// Reset state before each test to avoid cross-test contamination.
beforeEach(() => {
_reset();
});
describe("phishingDomains", () => {
describe("hostnameVariants", () => {
test("returns exact hostname plus parent domains", () => {
const variants = hostnameVariants("sub.evil.com");
expect(variants).toEqual(["sub.evil.com", "evil.com"]);
});
test("returns just the hostname for a bare domain", () => {
const variants = hostnameVariants("example.com");
expect(variants).toEqual(["example.com"]);
});
test("handles deep subdomain chains", () => {
const variants = hostnameVariants("a.b.c.d.com");
expect(variants).toEqual([
"a.b.c.d.com",
"b.c.d.com",
"c.d.com",
"d.com",
]);
});
test("lowercases hostnames", () => {
const variants = hostnameVariants("Evil.COM");
expect(variants).toEqual(["evil.com"]);
});
});
describe("loadConfig + isPhishingDomain", () => {
test("detects exact blacklisted domain", () => {
loadConfig({
blacklist: ["evil-phishing.com", "scam-swap.xyz"],
whitelist: [],
});
expect(isPhishingDomain("evil-phishing.com")).toBe(true);
expect(isPhishingDomain("scam-swap.xyz")).toBe(true);
});
test("returns false for clean domains", () => {
loadConfig({
blacklist: ["evil-phishing.com"],
whitelist: [],
});
expect(isPhishingDomain("etherscan.io")).toBe(false);
expect(isPhishingDomain("uniswap.org")).toBe(false);
});
test("detects subdomain of blacklisted domain", () => {
loadConfig({
blacklist: ["evil-phishing.com"],
whitelist: [],
});
expect(isPhishingDomain("app.evil-phishing.com")).toBe(true);
expect(isPhishingDomain("sub.app.evil-phishing.com")).toBe(true);
});
test("whitelist overrides blacklist", () => {
loadConfig({
blacklist: ["metamask.io"],
whitelist: ["metamask.io"],
});
expect(isPhishingDomain("metamask.io")).toBe(false);
});
test("whitelist on parent domain overrides blacklist", () => {
loadConfig({
blacklist: ["sub.legit.com"],
whitelist: ["legit.com"],
});
expect(isPhishingDomain("sub.legit.com")).toBe(false);
});
test("case-insensitive matching", () => {
loadConfig({
blacklist: ["Evil-Phishing.COM"],
whitelist: [],
});
expect(isPhishingDomain("evil-phishing.com")).toBe(true);
expect(isPhishingDomain("EVIL-PHISHING.COM")).toBe(true);
});
test("returns false for empty/null hostname", () => {
loadConfig({
blacklist: ["evil.com"],
whitelist: [],
});
expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false);
});
test("getBlocklistSize reflects loaded config", () => {
loadConfig({
blacklist: ["a.com", "b.com", "c.com"],
whitelist: ["d.com"],
});
expect(getBlocklistSize()).toBe(3);
});
test("handles config with no blacklist/whitelist keys", () => {
loadConfig({});
expect(isPhishingDomain("anything.com")).toBe(false);
expect(getBlocklistSize()).toBe(0);
});
test("re-loading config replaces previous data", () => {
loadConfig({
blacklist: ["old-scam.com"],
whitelist: [],
});
expect(isPhishingDomain("old-scam.com")).toBe(true);
loadConfig({
blacklist: ["new-scam.com"],
whitelist: [],
});
expect(isPhishingDomain("old-scam.com")).toBe(false);
expect(isPhishingDomain("new-scam.com")).toBe(true);
});
});
describe("real-world MetaMask blocklist patterns", () => {
test("detects known phishing domains from MetaMask list", () => {
loadConfig({
blacklist: [
"uniswap-trade.web.app",
"hopprotocol.pro",
"blast-pools.pages.dev",
],
whitelist: [],
});
expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true);
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
});
test("does not flag legitimate domains whitelisted by MetaMask", () => {
loadConfig({
blacklist: ["opensea.pro"],
whitelist: [
"opensea.io",
"metamask.io",
"etherscan.io",
"opensea.pro",
],
});
expect(isPhishingDomain("opensea.io")).toBe(false);
expect(isPhishingDomain("metamask.io")).toBe(false);
expect(isPhishingDomain("etherscan.io")).toBe(false);
// opensea.pro is both blacklisted and whitelisted — whitelist wins
expect(isPhishingDomain("opensea.pro")).toBe(false);
});
});
});