Compare commits

..

5 Commits

Author SHA1 Message Date
6bafb18ebd fix: strip wildcard prefixes from vendored blocklist entries
All checks were successful
check / check (push) Successful in 13s
The MetaMask blocklist contains 2 entries with '*.' wildcard prefixes
(e.g. *.coinbase-563513.com). These were stored literally and never
matched because hostnameVariants() doesn't generate '*.' prefixed
strings. Fix: normalizeDomain() strips the '*.' prefix at load time
and during delta computation. The subdomain matching in
hostnameVariants() already handles child domains correctly.

Found during review.
2026-03-01 07:38:01 -08:00
0d06df6cbe refactor: vendor phishing blocklist, delta-only in-memory updates
All checks were successful
check / check (push) Successful in 25s
Vendor the MetaMask eth-phishing-detect config.json (231k domains) into
src/data/phishing-domains.json as the baseline blocklist shipped with
the extension.

On 24h refresh, only the delta (new domains not in the vendored snapshot)
is kept in memory. Domain checks hit the in-memory delta first (fresh
scam sites), then binary-search the vendored sorted array.

If the delta is under 256 KiB it is persisted to chrome.storage.local
so it survives service-worker restarts without re-fetching.

Removes the previous approach of downloading and holding the full
blocklist in memory as a Set.
2026-03-01 07:33:10 -08:00
b8d81a4c8a fix: etherscan label check runs for contracts, UI displays etherscan-phishing warnings
All checks were successful
check / check (push) Successful in 10s
Bug 1: getFullWarnings returned early for contract addresses, skipping
checkEtherscanLabel. Restructured to use isContract flag so the Etherscan
check runs for all addresses (contracts are often the most dangerous).

Bug 2: confirmTx.js only handled 'contract' and 'new-address' warning types,
silently discarding 'etherscan-phishing'. Added confirm-etherscan-warning
HTML element and handler in the async warnings loop.

Style: converted inline style attributes on phishing warning banners
(approve-tx, approve-sign, approve-site) to Tailwind utility classes
(bg-red-100 text-red-800 border-2 border-red-600 rounded-md).
2026-03-01 05:11:54 -08:00
user
01839d9c47 feat: add Etherscan label scraping and MetaMask phishing domain blocklist
All checks were successful
check / check (push) Successful in 22s
- Add etherscanLabels module: scrapes Etherscan address pages for
  phishing/scam labels (Fake_Phishing*, Exploiter, scam warnings).
  Integrated as best-effort async check in addressWarnings.

- Add phishingDomains module: fetches MetaMask's eth-phishing-detect
  blocklist (~231K domains) at runtime, caches in memory, refreshes
  every 24h. Checks hostnames with subdomain matching and whitelist
  overrides.

- Integrate domain phishing checks into all approval flows:
  connection requests, transaction approvals, and signature requests
  show a prominent red warning banner when the requesting site is on
  the MetaMask blocklist.

- Add unit tests for both modules (12 tests for etherscanLabels
  parsing, 15 tests for phishingDomains matching).

Closes #114
2026-03-01 05:03:42 -08:00
clawbot
9eef2ea602 feat: expand confirm-tx warnings — closes #114
- Refactor address warnings into src/shared/addressWarnings.js module
  - getLocalWarnings(address, options): sync checks against local lists
  - getFullWarnings(address, provider, options): async local + RPC checks
- Expand scam address list from 652 to 2417 addresses
  - Added EtherScamDB (MIT) as additional source
- Update confirmTx.js to use the new addressWarnings module
2026-03-01 05:03:42 -08:00
10 changed files with 231409 additions and 231563 deletions

View File

@@ -15,12 +15,10 @@ Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform.
Everything you need, nothing you don't. We import as few libraries as possible, Everything you need, nothing you don't. We import as few libraries as possible,
don't implement any crypto, and don't send user-specific data anywhere but a don't implement any crypto, and don't send user-specific data anywhere but a
(user-configurable) Ethereum RPC endpoint (which defaults to a public node). The (user-configurable) Ethereum RPC endpoint (which defaults to a public node). The
extension contacts three user-configurable services: the configured RPC node for extension contacts exactly three external services: the configured RPC node for
blockchain interactions, a public CoinDesk API (no API key) for realtime price blockchain interactions, a public CoinDesk API (no API key) for realtime price
information, and a Blockscout block-explorer API for transaction history and information, and a Blockscout block-explorer API for transaction history and
token balances. It also fetches a community-maintained phishing domain blocklist token balances. All three endpoints are user-configurable.
periodically and performs best-effort Etherscan address label lookups during
transaction confirmation.
In the extension is a hardcoded list of the top ERC20 contract addresses. You In the extension is a hardcoded list of the top ERC20 contract addresses. You
can add any ERC20 contract by contract address if you wish, but the hardcoded can add any ERC20 contract by contract address if you wish, but the hardcoded
@@ -439,10 +437,6 @@ transitions.
- **When**: User tapped a transaction row from AddressDetail or AddressToken. - **When**: User tapped a transaction row from AddressDetail or AddressToken.
- **Elements**: - **Elements**:
- "Transaction" heading, "Back" button - "Transaction" heading, "Back" button
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
- Token contract: shown for ERC-20 transfers — color dot + full contract
address (tap to copy) + etherscan token link
- Status: "Success" or "Failed" - Status: "Success" or "Failed"
- Time: ISO datetime + relative age in parentheses - Time: ISO datetime + relative age in parentheses
- Amount: value + symbol (bold) - Amount: value + symbol (bold)
@@ -451,11 +445,6 @@ transitions.
- To: blockie + color dot + full address (tap to copy) + etherscan link - To: blockie + color dot + full address (tap to copy) + etherscan link
- ENS name if available - ENS name if available
- Transaction hash: full hash (tap to copy) + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link
- Block: block number (tap to copy) + etherscan block link
- Nonce: transaction nonce (tap to copy)
- Transaction fee: ETH amount (tap to copy)
- Gas price: value in Gwei (tap to copy)
- Gas used: integer (tap to copy)
- **Transitions**: - **Transitions**:
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail** - "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
@@ -578,25 +567,14 @@ What the extension does NOT do:
- No analytics or telemetry services - No analytics or telemetry services
- No token list APIs (user adds tokens manually by contract address) - No token list APIs (user adds tokens manually by contract address)
- No phishing/blocklist APIs
- No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer - No backend servers operated by the developer
In addition to the three user-configurable services above (RPC endpoint, These three services (RPC endpoint, CoinDesk price API, and Blockscout API) are
CoinDesk price API, and Blockscout API), AutistMask also contacts: the only external services. All three endpoints are user-configurable. Users who
want maximum privacy can point the RPC and Blockscout URLs at their own
- **Phishing domain blocklist**: A community-maintained phishing domain self-hosted instances (price fetching can be disabled in a future version).
blocklist is vendored into the extension at build time. At runtime, the
extension fetches the live list once every 24 hours to detect newly added
domains. Only the delta (domains not already in the vendored list) is kept in
memory, keeping runtime memory usage small. The delta is persisted to
localStorage if it is under 256 KiB.
- **Etherscan address labels**: When confirming a transaction, the extension
performs a best-effort lookup of the recipient address on Etherscan to check
for phishing/scam labels. This is a direct page fetch with no API key; the
user's browser makes the request.
Users who want maximum privacy can point the RPC and Blockscout URLs at their
own self-hosted instances (price fetching can be disabled in a future version).
### Dependencies ### Dependencies
@@ -786,22 +764,6 @@ indexes it as a real token transfer.
designed as a sharp tool — users who understand the risks can configure the designed as a sharp tool — users who understand the risks can configure the
wallet to show everything unfiltered, unix-style. wallet to show everything unfiltered, unix-style.
#### Phishing Domain Protection
AutistMask protects users from known phishing sites when they connect their
wallet or approve transactions/signatures. A community-maintained domain
blocklist is vendored into the extension at build time, providing immediate
protection without any network requests. At runtime, the extension fetches the
live list once every 24 hours and keeps only the delta (newly added domains not
in the vendored list) in memory. This architecture keeps runtime memory usage
small while ensuring fresh coverage of new phishing domains.
When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning
banner alerting the user. The domain checker matches exact hostnames and all
parent domains (subdomain matching), with whitelist overrides for legitimate
sites that share a parent domain with a blocklisted entry.
#### Transaction Decoding #### Transaction Decoding
When a dApp asks the user to approve a transaction, AutistMask attempts to When a dApp asks the user to approve a transaction, AutistMask attempts to

View File

@@ -15,7 +15,6 @@ const { getSignerForAddress } = require("../shared/wallet");
const { const {
isPhishingDomain, isPhishingDomain,
updatePhishingList, updatePhishingList,
startPeriodicRefresh,
} = require("../shared/phishingDomains"); } = require("../shared/phishingDomains");
const storageApi = const storageApi =
@@ -576,10 +575,9 @@ async function backgroundRefresh() {
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL); setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL);
// Fetch the phishing domain blocklist delta on startup and refresh every 24h. // Fetch the MetaMask eth-phishing-detect domain blocklist on startup.
// The vendored blocklist is bundled at build time; this fetches only new entries. // Refreshes every 24 hours automatically.
updatePhishingList(); updatePhishingList();
startPeriodicRefresh();
// When approval window is closed without a response, treat as rejection // When approval window is closed without a response, treat as rejection
if (windowsApi && windowsApi.onRemoved) { if (windowsApi && windowsApi.onRemoved) {

View File

@@ -1129,13 +1129,6 @@
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div> <div id="tx-detail-to" class="text-xs break-all"></div>
</div> </div>
<div id="tx-detail-token-contract-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Token contract</div>
<div
id="tx-detail-token-contract"
class="text-xs break-all"
></div>
</div>
<div id="tx-detail-calldata-section" class="mb-4 hidden"> <div id="tx-detail-calldata-section" class="mb-4 hidden">
<div <div
id="tx-detail-calldata-well" id="tx-detail-calldata-well"
@@ -1156,26 +1149,6 @@
<div class="text-xs text-muted mb-1">Transaction hash</div> <div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></div> <div id="tx-detail-hash" class="text-xs break-all"></div>
</div> </div>
<div id="tx-detail-block-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Block</div>
<div id="tx-detail-block" class="text-xs"></div>
</div>
<div id="tx-detail-nonce-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Nonce</div>
<div id="tx-detail-nonce" class="text-xs"></div>
</div>
<div id="tx-detail-fee-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Transaction fee</div>
<div id="tx-detail-fee" class="text-xs"></div>
</div>
<div id="tx-detail-gasprice-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Gas price</div>
<div id="tx-detail-gasprice" class="text-xs"></div>
</div>
<div id="tx-detail-gasused-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Gas used</div>
<div id="tx-detail-gasused" class="text-xs"></div>
</div>
<div id="tx-detail-rawdata-section" class="mb-4 hidden"> <div id="tx-detail-rawdata-section" class="mb-4 hidden">
<div class="text-xs text-muted mb-1">Raw data</div> <div class="text-xs text-muted mb-1">Raw data</div>
<div <div
@@ -1192,7 +1165,7 @@
id="approve-tx-phishing-warning" id="approve-tx-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md" class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
> >
⚠️ PHISHING WARNING: This site is on a known phishing ⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. This transaction may steal your funds. Proceed blocklist. This transaction may steal your funds. Proceed
with extreme caution. with extreme caution.
</div> </div>
@@ -1266,7 +1239,7 @@
id="approve-sign-phishing-warning" id="approve-sign-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md" class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
> >
⚠️ PHISHING WARNING: This site is on a known phishing ⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. Signing this message may authorize theft of your blocklist. Signing this message may authorize theft of your
funds. Proceed with extreme caution. funds. Proceed with extreme caution.
</div> </div>
@@ -1343,7 +1316,7 @@
id="approve-site-phishing-warning" id="approve-site-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md" class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
> >
⚠️ PHISHING WARNING: This site is on a known phishing ⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. Connecting your wallet may result in loss of blocklist. Connecting your wallet may result in loss of
funds. Proceed with extreme caution. funds. Proceed with extreme caution.
</div> </div>

View File

@@ -13,6 +13,8 @@ const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap"); const uniswap = require("../../shared/uniswap");
const { isPhishingDomain } = require("../../shared/phishingDomains");
const runtime = const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime; typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -154,12 +156,11 @@ function decodeCalldata(data, toAddress) {
return null; return null;
} }
function showPhishingWarning(elementId, isPhishing) { function showPhishingWarning(elementId, hostname, isPhishing) {
const el = $(elementId); const el = $(elementId);
if (!el) return; if (!el) return;
// The background script performs the authoritative phishing domain check // Check both the flag from background and a local re-check
// and passes the result via the isPhishingDomain flag. if (isPhishing || isPhishingDomain(hostname)) {
if (isPhishing) {
el.classList.remove("hidden"); el.classList.remove("hidden");
} else { } else {
el.classList.add("hidden"); el.classList.add("hidden");
@@ -169,6 +170,7 @@ function showPhishingWarning(elementId, isPhishing) {
function showTxApproval(details) { function showTxApproval(details) {
showPhishingWarning( showPhishingWarning(
"approve-tx-phishing-warning", "approve-tx-phishing-warning",
details.hostname,
details.isPhishingDomain, details.isPhishingDomain,
); );
@@ -341,6 +343,7 @@ function formatTypedDataHtml(jsonStr) {
function showSignApproval(details) { function showSignApproval(details) {
showPhishingWarning( showPhishingWarning(
"approve-sign-phishing-warning", "approve-sign-phishing-warning",
details.hostname,
details.isPhishingDomain, details.isPhishingDomain,
); );
@@ -406,6 +409,7 @@ function show(id) {
// Site connection approval // Site connection approval
showPhishingWarning( showPhishingWarning(
"approve-site-phishing-warning", "approve-site-phishing-warning",
details.hostname,
details.isPhishingDomain, details.isPhishingDomain,
); );
$("approve-hostname").textContent = details.hostname; $("approve-hostname").textContent = details.hostname;

View File

@@ -13,7 +13,6 @@ const {
timeAgo, timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
@@ -27,25 +26,6 @@ const EXT_ICON =
let ctx; let ctx;
/**
* Determine a human-readable transaction type string from tx fields.
*/
function getTransactionType(tx) {
if (!tx.to) return "Contract Creation";
if (tx.direction === "contract") {
if (tx.directionLabel === "Swap") return "Swap";
if (
tx.method === "approve" ||
tx.directionLabel === "Approve" ||
tx.method === "setApprovalForAll"
)
return "Token Approval";
return "Contract Call";
}
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
return "Native ETH Transfer";
}
function copyableHtml(text, extraClass) { function copyableHtml(text, extraClass) {
const cls = const cls =
"underline decoration-dashed cursor-pointer" + "underline decoration-dashed cursor-pointer" +
@@ -119,7 +99,6 @@ function show(tx) {
direction: tx.direction || null, direction: tx.direction || null,
isContractCall: tx.isContractCall || false, isContractCall: tx.isContractCall || false,
method: tx.method || null, method: tx.method || null,
contractAddress: tx.contractAddress || null,
}, },
}; };
render(); render();
@@ -156,54 +135,30 @@ function render() {
nativeEl.parentElement.classList.add("hidden"); nativeEl.parentElement.classList.add("hidden");
} }
// Always show transaction type as the first field // Show type label for contract interactions (Swap, Execute, etc.)
const typeSection = $("tx-detail-type-section"); const typeSection = $("tx-detail-type-section");
const typeEl = $("tx-detail-type"); const typeEl = $("tx-detail-type");
const headingEl = $("tx-detail-heading"); const headingEl = $("tx-detail-heading");
if (typeSection && typeEl) { if (tx.direction === "contract" && tx.directionLabel) {
typeEl.textContent = getTransactionType(tx); if (typeSection) {
typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden"); typeSection.classList.remove("hidden");
} }
} else {
if (typeSection) typeSection.classList.add("hidden");
}
if (headingEl) headingEl.textContent = "Transaction"; if (headingEl) headingEl.textContent = "Transaction";
// Token contract address (for ERC-20 transfers) // Hide calldata and raw data sections; re-fetch if this is a contract call
const tokenContractSection = $("tx-detail-token-contract-section");
const tokenContractEl = $("tx-detail-token-contract");
if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = `https://etherscan.io/token/${tx.contractAddress}`;
tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") +
etherscanLinkHtml(link) +
`</div>`;
tokenContractSection.classList.remove("hidden");
} else {
tokenContractSection.classList.add("hidden");
}
}
// Hide calldata and raw data sections; always fetch full tx details
const calldataSection = $("tx-detail-calldata-section"); const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden"); if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section"); const rawDataSection = $("tx-detail-rawdata-section");
if (rawDataSection) rawDataSection.classList.add("hidden"); if (rawDataSection) rawDataSection.classList.add("hidden");
// Hide on-chain detail sections until populated if (tx.isContractCall || tx.direction === "contract") {
for (const id of [ loadCalldata(tx.hash, tx.to);
"tx-detail-block-section",
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
]) {
const el = $(id);
if (el) el.classList.add("hidden");
} }
loadFullTxDetails(tx.hash, tx.to, tx.isContractCall);
const isoStr = isoDate(tx.timestamp); const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML = $("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")"; copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
@@ -222,90 +177,7 @@ function render() {
}); });
} }
function showDetailField(sectionId, contentId, value) { async function loadCalldata(txHash, toAddress) {
const section = $(sectionId);
const el = $(contentId);
if (!section || !el) return;
el.innerHTML = copyableHtml(value, "");
section.classList.remove("hidden");
}
function populateOnChainDetails(txData) {
// Block number
if (txData.block_number != null) {
const blockLink = `https://etherscan.io/block/${txData.block_number}`;
const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block");
if (blockSection && blockEl) {
blockEl.innerHTML =
copyableHtml(String(txData.block_number), "") +
etherscanLinkHtml(blockLink);
blockSection.classList.remove("hidden");
}
}
// Nonce
if (txData.nonce != null) {
showDetailField(
"tx-detail-nonce-section",
"tx-detail-nonce",
String(txData.nonce),
);
}
// Transaction fee
const feeWei = txData.fee?.value || txData.tx_fee;
if (feeWei) {
const feeEth = formatEther(String(feeWei));
showDetailField(
"tx-detail-fee-section",
"tx-detail-fee",
feeEth + " ETH",
);
}
// Gas price
const gasPrice = txData.gas_price;
if (gasPrice) {
const gwei = formatUnits(String(gasPrice), "gwei");
showDetailField(
"tx-detail-gasprice-section",
"tx-detail-gasprice",
gwei + " Gwei",
);
}
// Gas used
const gasUsed = txData.gas_used;
if (gasUsed) {
showDetailField(
"tx-detail-gasused-section",
"tx-detail-gasused",
String(gasUsed),
);
}
// Bind copy handlers for newly added elements
for (const id of [
"tx-detail-block-section",
"tx-detail-nonce-section",
"tx-detail-fee-section",
"tx-detail-gasprice-section",
"tx-detail-gasused-section",
]) {
const section = $(id);
if (!section) continue;
section.querySelectorAll("[data-copy]").forEach((el) => {
el.onclick = () => {
navigator.clipboard.writeText(el.dataset.copy);
showFlash("Copied!");
flashCopyFeedback(el);
};
});
}
}
async function loadFullTxDetails(txHash, toAddress, isContractCall) {
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -320,10 +192,6 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
); );
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData);
const inputData = txData.raw_input || txData.input || null; const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return; if (!inputData || inputData === "0x") return;

View File

@@ -1,106 +1,68 @@
// Domain-based phishing detection using a vendored blocklist with delta updates. // Domain-based phishing detection using MetaMask's eth-phishing-detect blocklist.
// //
// A community-maintained phishing domain blocklist is vendored in // Architecture:
// phishingBlocklist.json and bundled at build time. At runtime, we fetch // 1. A vendored copy of the blocklist ships with the extension
// the live list periodically and keep only the delta (new entries not in // (src/data/phishing-domains.json — sorted blacklist for binary search).
// the vendored list) in memory. This keeps runtime memory usage small. // 2. Every 24h we fetch the latest list from MetaMask's repo and compute
// the delta (new domains not in the vendored snapshot).
// 3. Only the delta is kept in memory / persisted to chrome.storage.local.
// 4. Domain checks hit the delta first (fresh scam sites), then the
// vendored baseline via binary search.
// //
// The domain-checker checks the in-memory delta first (fresh/recent scam // Source: https://github.com/MetaMask/eth-phishing-detect (src/config.json)
// sites), then falls back to the vendored list.
//
// If the delta is under 256 KiB it is persisted to localStorage so it
// survives extension/service-worker restarts.
const vendoredConfig = require("./phishingBlocklist.json"); const vendoredConfig = require("../data/phishing-domains.json");
const BLOCKLIST_URL = const BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json"; "https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const DELTA_STORAGE_KEY = "phishing_domain_delta";
const DELTA_STORAGE_KEY = "phishing-delta"; const DELTA_MAX_BYTES = 256 * 1024; // 256 KiB
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
// Vendored sets — built once from the bundled JSON. // Vendored baseline — sorted arrays for binary search (no extra Set needed).
const vendoredBlacklist = new Set( const vendoredBlacklist = vendoredConfig.blacklist; // pre-sorted lowercase
(vendoredConfig.blacklist || []).map((d) => d.toLowerCase()),
);
const vendoredWhitelist = new Set( const vendoredWhitelist = new Set(
(vendoredConfig.whitelist || []).map((d) => d.toLowerCase()), (vendoredConfig.whitelist || []).map((d) => d.toLowerCase()),
); );
// Delta sets — only entries from live list that are NOT in vendored. // Delta state — only domains added upstream since the vendored snapshot.
let deltaBlacklist = new Set(); let deltaBlacklistSet = new Set();
let deltaWhitelist = new Set(); let deltaWhitelistSet = new Set();
let lastFetchTime = 0; let lastFetchTime = 0;
let fetchPromise = null; let fetchPromise = null;
let refreshTimer = null; let persistedDeltaLoaded = false;
/** /**
* Load delta entries from localStorage on startup. * Normalize a domain entry: lowercase and strip wildcard prefix ("*.").
* Called once during module initialization in the background script. * Wildcard domains like "*.evil.com" become "evil.com" — our subdomain
*/ * matching in hostnameVariants() already covers child domains.
function loadDeltaFromStorage() {
try {
const raw = localStorage.getItem(DELTA_STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data.blacklist && Array.isArray(data.blacklist)) {
deltaBlacklist = new Set(
data.blacklist.map((d) => d.toLowerCase()),
);
}
if (data.whitelist && Array.isArray(data.whitelist)) {
deltaWhitelist = new Set(
data.whitelist.map((d) => d.toLowerCase()),
);
}
} catch {
// localStorage unavailable or corrupt — start empty
}
}
/**
* Persist delta to localStorage if it fits within MAX_DELTA_BYTES.
*/
function saveDeltaToStorage() {
try {
const data = {
blacklist: Array.from(deltaBlacklist),
whitelist: Array.from(deltaWhitelist),
};
const json = JSON.stringify(data);
if (json.length < MAX_DELTA_BYTES) {
localStorage.setItem(DELTA_STORAGE_KEY, json);
} else {
// Too large — remove stale key if present
localStorage.removeItem(DELTA_STORAGE_KEY);
}
} catch {
// localStorage unavailable — skip silently
}
}
/**
* Load a pre-parsed config and compute the delta against the vendored list.
* Used for both live fetches and testing.
* *
* @param {{ blacklist?: string[], whitelist?: string[] }} config * @param {string} domain
* @returns {string}
*/ */
function loadConfig(config) { function normalizeDomain(domain) {
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase()); const d = domain.toLowerCase();
const liveWhitelist = (config.whitelist || []).map((d) => d.toLowerCase()); return d.startsWith("*.") ? d.slice(2) : d;
}
// Delta = entries in the live list that are NOT in the vendored list /**
deltaBlacklist = new Set( * Binary search on a sorted string array.
liveBlacklist.filter((d) => !vendoredBlacklist.has(d)), *
); * @param {string[]} sorted - Sorted array of lowercase strings.
deltaWhitelist = new Set( * @param {string} target - Lowercase string to find.
liveWhitelist.filter((d) => !vendoredWhitelist.has(d)), * @returns {boolean}
); */
function binarySearch(sorted, target) {
lastFetchTime = Date.now(); let lo = 0;
saveDeltaToStorage(); let hi = sorted.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (sorted[mid] === target) return true;
if (sorted[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return false;
} }
/** /**
@@ -114,7 +76,6 @@ function hostnameVariants(hostname) {
const h = hostname.toLowerCase(); const h = hostname.toLowerCase();
const variants = [h]; const variants = [h];
const parts = h.split("."); const parts = h.split(".");
// Parent domains: a.b.c.d -> b.c.d, c.d
for (let i = 1; i < parts.length - 1; i++) { for (let i = 1; i < parts.length - 1; i++) {
variants.push(parts.slice(i).join(".")); variants.push(parts.slice(i).join("."));
} }
@@ -123,8 +84,8 @@ function hostnameVariants(hostname) {
/** /**
* Check if a hostname is on the phishing blocklist. * Check if a hostname is on the phishing blocklist.
* Checks delta first (fresh/recent scam sites), then vendored list. * Checks delta (fresh additions) first, then vendored baseline.
* Whitelisted domains (delta + vendored) are never flagged. * Whitelisted domains (vendored + delta) are never flagged.
* *
* @param {string} hostname - The hostname to check. * @param {string} hostname - The hostname to check.
* @returns {boolean} * @returns {boolean}
@@ -133,27 +94,107 @@ function isPhishingDomain(hostname) {
if (!hostname) return false; if (!hostname) return false;
const variants = hostnameVariants(hostname); const variants = hostnameVariants(hostname);
// Whitelist takes priority — check delta whitelist first, then vendored // Whitelist takes priority (both vendored and delta)
for (const v of variants) { for (const v of variants) {
if (deltaWhitelist.has(v) || vendoredWhitelist.has(v)) return false; if (vendoredWhitelist.has(v) || deltaWhitelistSet.has(v)) return false;
} }
// Check delta blacklist first (fresh/recent scam sites), then vendored // Check delta first fresh scam sites hit here
for (const v of variants) { for (const v of variants) {
if (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true; if (deltaBlacklistSet.has(v)) return true;
} }
// Check vendored baseline via binary search
for (const v of variants) {
if (binarySearch(vendoredBlacklist, v)) return true;
}
return false; return false;
} }
/** /**
* Fetch the latest blocklist and compute delta against vendored data. * Get the storage API if available (chrome.storage.local / browser.storage.local).
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS. *
* @returns {object|null}
*/
function getStorageApi() {
if (typeof browser !== "undefined" && browser.storage) {
return browser.storage.local;
}
if (typeof chrome !== "undefined" && chrome.storage) {
return chrome.storage.local;
}
return null;
}
/**
* Load persisted delta from chrome.storage.local.
* Called once on first update to restore delta across restarts.
*
* @returns {Promise<void>}
*/
async function loadPersistedDelta() {
const storage = getStorageApi();
if (!storage) return;
try {
const result = await storage.get(DELTA_STORAGE_KEY);
const data = result[DELTA_STORAGE_KEY];
if (data && data.blacklist && data.whitelist) {
deltaBlacklistSet = new Set(data.blacklist);
deltaWhitelistSet = new Set(data.whitelist);
if (data.fetchTime) {
lastFetchTime = data.fetchTime;
}
}
} catch {
// Storage unavailable or corrupted — start fresh.
}
persistedDeltaLoaded = true;
}
/**
* Persist the current delta to chrome.storage.local if it fits in 256 KiB.
*
* @returns {Promise<void>}
*/
async function persistDelta() {
const storage = getStorageApi();
if (!storage) return;
const data = {
blacklist: Array.from(deltaBlacklistSet),
whitelist: Array.from(deltaWhitelistSet),
fetchTime: lastFetchTime,
};
const serialized = JSON.stringify(data);
if (serialized.length > DELTA_MAX_BYTES) {
// Delta too large to persist — keep in memory only.
return;
}
try {
await storage.set({ [DELTA_STORAGE_KEY]: data });
} catch {
// Storage write failed — non-fatal.
}
}
/**
* Fetch the latest blocklist, compute delta against vendored baseline,
* and update in-memory state. De-duplicates concurrent fetches.
* *
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function updatePhishingList() { async function updatePhishingList() {
// Load persisted delta on first call
if (!persistedDeltaLoaded) {
await loadPersistedDelta();
}
// Skip if recently fetched // Skip if recently fetched
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) { if (Date.now() - lastFetchTime < CACHE_TTL_MS) {
return; return;
} }
@@ -165,10 +206,32 @@ async function updatePhishingList() {
const resp = await fetch(BLOCKLIST_URL); const resp = await fetch(BLOCKLIST_URL);
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error("HTTP " + resp.status);
const config = await resp.json(); const config = await resp.json();
loadConfig(config);
// Compute blacklist delta: remote items not in vendored baseline
const newDeltaBl = new Set();
for (const domain of config.blacklist || []) {
const d = normalizeDomain(domain);
if (!binarySearch(vendoredBlacklist, d)) {
newDeltaBl.add(d);
}
}
// Compute whitelist delta: remote items not in vendored whitelist
const newDeltaWl = new Set();
for (const domain of config.whitelist || []) {
const d = normalizeDomain(domain);
if (!vendoredWhitelist.has(d)) {
newDeltaWl.add(d);
}
}
deltaBlacklistSet = newDeltaBl;
deltaWhitelistSet = newDeltaWl;
lastFetchTime = Date.now();
await persistDelta();
} catch { } catch {
// Silently fail — vendored list still provides coverage. // Fetch failedkeep existing delta, retry next time.
// We'll retry next time.
} finally { } finally {
fetchPromise = null; fetchPromise = null;
} }
@@ -178,61 +241,57 @@ async function updatePhishingList() {
} }
/** /**
* Start periodic refresh of the phishing list. * Load a pre-parsed config directly into state (vendored + delta combined).
* Should be called once from the background script on startup. * Used for testing.
*
* @param {{ blacklist?: string[], whitelist?: string[] }} config
*/ */
function startPeriodicRefresh() { function loadConfig(config) {
if (refreshTimer) return; // For tests: treat the entire config as delta (overlaid on vendored).
refreshTimer = setInterval(updatePhishingList, REFRESH_INTERVAL_MS); // Clear existing delta first.
deltaBlacklistSet = new Set((config.blacklist || []).map(normalizeDomain));
deltaWhitelistSet = new Set((config.whitelist || []).map(normalizeDomain));
lastFetchTime = Date.now();
persistedDeltaLoaded = true;
} }
/** /**
* Return the total blocklist size (vendored + delta) for diagnostics. * Return total blocklist size (vendored + delta, for diagnostics).
* *
* @returns {number} * @returns {number}
*/ */
function getBlocklistSize() { function getBlocklistSize() {
return vendoredBlacklist.size + deltaBlacklist.size; return vendoredBlacklist.length + deltaBlacklistSet.size;
} }
/** /**
* Return the delta blocklist size for diagnostics. * Return delta size (for diagnostics).
* *
* @returns {number} * @returns {number}
*/ */
function getDeltaSize() { function getDeltaSize() {
return deltaBlacklist.size; return deltaBlacklistSet.size;
} }
/** /**
* Reset internal state (for testing). * Reset internal state (for testing).
*/ */
function _reset() { function _reset() {
deltaBlacklist = new Set(); deltaBlacklistSet = new Set();
deltaWhitelist = new Set(); deltaWhitelistSet = new Set();
lastFetchTime = 0; lastFetchTime = 0;
fetchPromise = null; fetchPromise = null;
if (refreshTimer) { persistedDeltaLoaded = false;
clearInterval(refreshTimer);
refreshTimer = null;
} }
}
// Load persisted delta on module initialization
loadDeltaFromStorage();
module.exports = { module.exports = {
isPhishingDomain, isPhishingDomain,
updatePhishingList, updatePhishingList,
startPeriodicRefresh,
loadConfig, loadConfig,
getBlocklistSize, getBlocklistSize,
getDeltaSize, getDeltaSize,
hostnameVariants, hostnameVariants,
binarySearch,
normalizeDomain,
_reset, _reset,
// Exposed for testing only
_getVendoredBlacklistSize: () => vendoredBlacklist.size,
_getVendoredWhitelistSize: () => vendoredWhitelist.size,
_getDeltaBlacklist: () => deltaBlacklist,
_getDeltaWhitelist: () => deltaWhitelist,
}; };

View File

@@ -153,38 +153,24 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
// When a token transfer shares a hash with a normal tx, the normal tx // 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 // is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. For contract calls (swaps), a single transaction // amount and symbol. A single transaction (e.g. a swap) can produce
// can produce multiple token transfers (input, intermediates, output). // multiple token transfers (one per token involved), so we key token
// We consolidate these into the original tx entry using the token // transfers by hash + contract address to keep all of them. We also
// transfer where the user *receives* tokens (the swap output), so // preserve contract-call metadata (direction, label, method) from the
// the transaction list shows the final result rather than confusing // matching normal tx so swaps display correctly.
// intermediate hops. We preserve the original tx's from/to so the
// user sees their own address, not a router or Permit2 contract.
for (const tt of ttJson.items || []) { for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower); const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash); const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") { if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original parsed.direction = "contract";
// tx entry. Prefer the "received" transfer (swap output) parsed.directionLabel = existing.directionLabel;
// for the display amount. If no received transfer exists, parsed.isContractCall = true;
// fall back to the first "sent" transfer (swap input). parsed.method = existing.method;
const isReceived = parsed.direction === "received"; // Remove the bare-hash normal tx so it doesn't appear as a
const needsAmount = !existing.exactValue; // duplicate with empty value; token transfers replace it.
if (isReceived || needsAmount) { txsByHash.delete(parsed.hash);
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
} }
// Keep the original tx's from/to (the user's address and the // Use composite key so multiple token transfers per tx are kept.
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
// Non-contract token transfers get their own entries.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || ""); const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed); txsByHash.set(ttKey, parsed);
} }

View File

@@ -359,12 +359,9 @@ function decode(data, toAddress) {
const s = decodeV3SwapExactIn(inputs[i]); const s = decodeV3SwapExactIn(inputs[i]);
if (s) { if (s) {
if (!inputToken) inputToken = s.tokenIn; if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn; if (!inputAmount) inputAmount = s.amountIn;
// Always update output: in multi-step swaps (V3 → V4), if (!minOutput) minOutput = s.amountOutMin;
// the last swap step determines the final output token
// and minimum received amount.
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
} }
} }
@@ -372,9 +369,9 @@ function decode(data, toAddress) {
const s = decodeV2SwapExactIn(inputs[i]); const s = decodeV2SwapExactIn(inputs[i]);
if (s) { if (s) {
if (!inputToken) inputToken = s.tokenIn; if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn; if (!inputAmount) inputAmount = s.amountIn;
outputToken = s.tokenOut; if (!minOutput) minOutput = s.amountOutMin;
minOutput = s.amountOutMin;
} }
} }
@@ -391,11 +388,12 @@ function decode(data, toAddress) {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
if (v4) { if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn; if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!outputToken && v4.tokenOut)
outputToken = v4.tokenOut;
if (!inputAmount && v4.amountIn) if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn; inputAmount = v4.amountIn;
// Always update output: last swap step wins if (!minOutput && v4.amountOutMin)
if (v4.tokenOut) outputToken = v4.tokenOut; minOutput = v4.amountOutMin;
if (v4.amountOutMin) minOutput = v4.amountOutMin;
} }
} }

View File

@@ -1,70 +1,21 @@
// Provide a localStorage mock for Node.js test environment.
// Must be set before requiring the module since it calls loadDeltaFromStorage()
// at module load time.
const localStorageStore = {};
global.localStorage = {
getItem: (key) =>
Object.prototype.hasOwnProperty.call(localStorageStore, key)
? localStorageStore[key]
: null,
setItem: (key, value) => {
localStorageStore[key] = String(value);
},
removeItem: (key) => {
delete localStorageStore[key];
},
};
const { const {
isPhishingDomain, isPhishingDomain,
loadConfig, loadConfig,
getBlocklistSize, getBlocklistSize,
getDeltaSize, getDeltaSize,
hostnameVariants, hostnameVariants,
binarySearch,
normalizeDomain,
_reset, _reset,
_getVendoredBlacklistSize,
_getVendoredWhitelistSize,
_getDeltaBlacklist,
_getDeltaWhitelist,
} = require("../src/shared/phishingDomains"); } = require("../src/shared/phishingDomains");
// Reset delta state before each test to avoid cross-test contamination. // The vendored baseline is loaded automatically via require().
// Note: vendored sets are immutable and always present. // _reset() clears only the delta state, not the vendored baseline.
beforeEach(() => { beforeEach(() => {
_reset(); _reset();
// Clear localStorage mock between tests
for (const key of Object.keys(localStorageStore)) {
delete localStorageStore[key];
}
}); });
describe("phishingDomains", () => { describe("phishingDomains", () => {
describe("vendored blocklist", () => {
test("vendored blacklist is loaded from bundled JSON", () => {
// The vendored blocklist should have a large number of entries
expect(_getVendoredBlacklistSize()).toBeGreaterThan(100000);
});
test("vendored whitelist is loaded from bundled JSON", () => {
expect(_getVendoredWhitelistSize()).toBeGreaterThan(0);
});
test("detects domains from vendored blacklist", () => {
// These are well-known phishing domains in the vendored list
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
});
test("vendored whitelist overrides vendored blacklist", () => {
// opensea.pro is whitelisted in the vendored config
expect(isPhishingDomain("opensea.pro")).toBe(false);
});
test("getBlocklistSize includes vendored entries", () => {
expect(getBlocklistSize()).toBeGreaterThan(100000);
});
});
describe("hostnameVariants", () => { describe("hostnameVariants", () => {
test("returns exact hostname plus parent domains", () => { test("returns exact hostname plus parent domains", () => {
const variants = hostnameVariants("sub.evil.com"); const variants = hostnameVariants("sub.evil.com");
@@ -92,168 +43,205 @@ describe("phishingDomains", () => {
}); });
}); });
describe("delta computation via loadConfig", () => { describe("binarySearch", () => {
test("loadConfig computes delta of new entries not in vendored list", () => { const sorted = ["alpha.com", "beta.com", "gamma.com", "zeta.com"];
test("finds existing elements", () => {
expect(binarySearch(sorted, "alpha.com")).toBe(true);
expect(binarySearch(sorted, "gamma.com")).toBe(true);
expect(binarySearch(sorted, "zeta.com")).toBe(true);
});
test("returns false for missing elements", () => {
expect(binarySearch(sorted, "aaa.com")).toBe(false);
expect(binarySearch(sorted, "delta.com")).toBe(false);
expect(binarySearch(sorted, "zzz.com")).toBe(false);
});
test("handles empty array", () => {
expect(binarySearch([], "anything")).toBe(false);
});
test("handles single-element array", () => {
expect(binarySearch(["only.com"], "only.com")).toBe(true);
expect(binarySearch(["only.com"], "other.com")).toBe(false);
});
});
describe("normalizeDomain", () => {
test("strips *. wildcard prefix", () => {
expect(normalizeDomain("*.evil.com")).toBe("evil.com");
expect(normalizeDomain("*.sub.evil.com")).toBe("sub.evil.com");
});
test("lowercases domains", () => {
expect(normalizeDomain("Evil.COM")).toBe("evil.com");
expect(normalizeDomain("*.Evil.COM")).toBe("evil.com");
});
test("passes through normal domains unchanged", () => {
expect(normalizeDomain("example.com")).toBe("example.com");
});
});
describe("wildcard domain handling", () => {
test("wildcard blacklist entries match via loadConfig", () => {
loadConfig({ loadConfig({
blacklist: [ blacklist: ["*.scam-site.com", "normal-scam.com"],
"brand-new-scam-site-xyz123.com",
"hopprotocol.pro", // already in vendored
],
whitelist: [], whitelist: [],
}); });
// Only the new domain should be in the delta // *.scam-site.com is normalized to scam-site.com
expect( expect(isPhishingDomain("scam-site.com")).toBe(true);
_getDeltaBlacklist().has("brand-new-scam-site-xyz123.com"), expect(isPhishingDomain("sub.scam-site.com")).toBe(true);
).toBe(true); expect(isPhishingDomain("normal-scam.com")).toBe(true);
expect(_getDeltaBlacklist().has("hopprotocol.pro")).toBe(false);
expect(getDeltaSize()).toBe(1);
});
test("delta whitelist entries are computed correctly", () => {
loadConfig({
blacklist: [],
whitelist: [
"new-safe-site-xyz789.com",
"opensea.pro", // already in vendored whitelist
],
});
expect(_getDeltaWhitelist().has("new-safe-site-xyz789.com")).toBe(
true,
);
expect(_getDeltaWhitelist().has("opensea.pro")).toBe(false);
});
test("re-loading config replaces previous delta", () => {
loadConfig({
blacklist: ["first-scam-xyz.com"],
whitelist: [],
});
expect(isPhishingDomain("first-scam-xyz.com")).toBe(true);
loadConfig({
blacklist: ["second-scam-xyz.com"],
whitelist: [],
});
expect(isPhishingDomain("first-scam-xyz.com")).toBe(false);
expect(isPhishingDomain("second-scam-xyz.com")).toBe(true);
});
test("getBlocklistSize includes both vendored and delta", () => {
const baseSize = getBlocklistSize();
loadConfig({
blacklist: ["delta-only-scam-xyz.com"],
whitelist: [],
});
expect(getBlocklistSize()).toBe(baseSize + 1);
}); });
}); });
describe("isPhishingDomain with delta + vendored", () => { describe("vendored baseline detection", () => {
test("detects domain from delta blacklist", () => { // These tests verify that the vendored phishing-domains.json
loadConfig({ // is loaded and searchable without any delta loaded.
blacklist: ["fresh-scam-xyz.com"],
whitelist: [], test("getBlocklistSize reflects vendored list (no delta)", () => {
}); // The vendored list has 231k+ domains; delta is empty after reset.
expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true); expect(getBlocklistSize()).toBeGreaterThan(200000);
expect(getDeltaSize()).toBe(0);
}); });
test("detects domain from vendored blacklist", () => { test("returns false for clean domains against vendored list", () => {
// No delta loaded — vendored still works expect(isPhishingDomain("google.com")).toBe(false);
expect(isPhishingDomain("hopprotocol.pro")).toBe(true); expect(isPhishingDomain("github.com")).toBe(false);
});
test("returns false for clean domains", () => {
expect(isPhishingDomain("etherscan.io")).toBe(false);
expect(isPhishingDomain("example.com")).toBe(false);
});
test("detects subdomain of blacklisted domain (vendored)", () => {
expect(isPhishingDomain("app.hopprotocol.pro")).toBe(true);
});
test("detects subdomain of blacklisted domain (delta)", () => {
loadConfig({
blacklist: ["delta-phish-xyz.com"],
whitelist: [],
});
expect(isPhishingDomain("sub.delta-phish-xyz.com")).toBe(true);
});
test("delta whitelist overrides vendored blacklist", () => {
// hopprotocol.pro is in the vendored blacklist
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
loadConfig({
blacklist: [],
whitelist: ["hopprotocol.pro"],
});
// Now whitelisted via delta — should not be flagged
expect(isPhishingDomain("hopprotocol.pro")).toBe(false);
});
test("vendored whitelist overrides delta blacklist", () => {
loadConfig({
blacklist: ["opensea.pro"], // opensea.pro is vendored-whitelisted
whitelist: [],
});
expect(isPhishingDomain("opensea.pro")).toBe(false);
});
test("case-insensitive matching", () => {
loadConfig({
blacklist: ["Delta-Scam-XYZ.COM"],
whitelist: [],
});
expect(isPhishingDomain("delta-scam-xyz.com")).toBe(true);
expect(isPhishingDomain("DELTA-SCAM-XYZ.COM")).toBe(true);
}); });
test("returns false for empty/null hostname", () => { test("returns false for empty/null hostname", () => {
expect(isPhishingDomain("")).toBe(false); expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false); expect(isPhishingDomain(null)).toBe(false);
}); });
});
describe("delta (loadConfig) + isPhishingDomain", () => {
test("detects domains loaded into delta via loadConfig", () => {
loadConfig({
blacklist: ["evil-phishing.com", "scam-swap.xyz"],
whitelist: [],
});
expect(isPhishingDomain("evil-phishing.com")).toBe(true);
expect(isPhishingDomain("scam-swap.xyz")).toBe(true);
});
test("detects subdomain of delta-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("delta whitelist overrides delta blacklist", () => {
loadConfig({
blacklist: ["metamask.io"],
whitelist: ["metamask.io"],
});
expect(isPhishingDomain("metamask.io")).toBe(false);
});
test("delta 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 in delta", () => {
loadConfig({
blacklist: ["Evil-Phishing.COM"],
whitelist: [],
});
expect(isPhishingDomain("evil-phishing.com")).toBe(true);
expect(isPhishingDomain("EVIL-PHISHING.COM")).toBe(true);
});
test("getDeltaSize reflects loaded delta", () => {
loadConfig({
blacklist: ["a.com", "b.com", "c.com"],
whitelist: ["d.com"],
});
expect(getDeltaSize()).toBe(3);
});
test("re-loading config replaces previous delta", () => {
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);
});
test("handles config with no blacklist/whitelist keys", () => { test("handles config with no blacklist/whitelist keys", () => {
loadConfig({}); loadConfig({});
expect(getDeltaSize()).toBe(0); expect(getDeltaSize()).toBe(0);
// Vendored list still works
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
}); });
}); });
describe("localStorage persistence", () => { describe("real-world MetaMask blocklist patterns (via delta)", () => {
test("saveDeltaToStorage persists delta under 256KiB", () => { test("detects known phishing domains loaded as delta", () => {
loadConfig({ loadConfig({
blacklist: ["persisted-scam-xyz.com"], blacklist: [
whitelist: ["persisted-safe-xyz.com"], "uniswap-trade.web.app",
}); "hopprotocol.pro",
const stored = localStorage.getItem("phishing-delta"); "blast-pools.pages.dev",
expect(stored).not.toBeNull(); ],
const data = JSON.parse(stored);
expect(data.blacklist).toContain("persisted-scam-xyz.com");
expect(data.whitelist).toContain("persisted-safe-xyz.com");
});
test("delta is cleared on _reset", () => {
loadConfig({
blacklist: ["temp-scam-xyz.com"],
whitelist: [], whitelist: [],
}); });
expect(getDeltaSize()).toBe(1);
_reset();
expect(getDeltaSize()).toBe(0);
});
});
describe("real-world blocklist patterns", () => {
test("detects known phishing domains from vendored list", () => {
expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true); expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true);
expect(isPhishingDomain("hopprotocol.pro")).toBe(true); expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true); expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
}); });
test("does not flag legitimate whitelisted domains", () => { test("delta whitelist overrides vendored blacklist entries", () => {
// If a domain is in the vendored blacklist but a fresh whitelist
// update adds it, the whitelist should win.
loadConfig({
blacklist: [],
whitelist: ["opensea.io", "metamask.io", "etherscan.io"],
});
expect(isPhishingDomain("opensea.io")).toBe(false); expect(isPhishingDomain("opensea.io")).toBe(false);
expect(isPhishingDomain("metamask.io")).toBe(false);
expect(isPhishingDomain("etherscan.io")).toBe(false); expect(isPhishingDomain("etherscan.io")).toBe(false);
}); });
}); });
describe("delta + vendored interaction", () => {
test("delta blacklist entries are found even with empty vendored match", () => {
// This domain is (almost certainly) not in the vendored list
const uniqueDomain =
"test-unique-domain-not-in-vendored-" +
Date.now() +
".example.com";
expect(isPhishingDomain(uniqueDomain)).toBe(false);
loadConfig({
blacklist: [uniqueDomain],
whitelist: [],
});
expect(isPhishingDomain(uniqueDomain)).toBe(true);
});
test("getBlocklistSize includes both vendored and delta", () => {
const baseSize = getBlocklistSize();
loadConfig({
blacklist: ["new-a.com", "new-b.com"],
whitelist: [],
});
expect(getBlocklistSize()).toBe(baseSize + 2);
});
});
}); });