refactor: vendor phishing blocklist, delta-only memory model

- Vendor community-maintained phishing domain blocklist into
  src/shared/phishingBlocklist.json (bundled at build time by esbuild)
- Refactor phishingDomains.js: build vendored Sets at module load,
  fetch live list periodically, keep only delta (new entries not in
  vendored) in memory for small runtime footprint
- Domain checker checks delta first (fresh scam sites), then vendored
- Persist delta to localStorage if under 256 KiB
- Load delta from localStorage on startup for instant coverage
- Add startPeriodicRefresh() with 24h setInterval in background script
- Remove dead code: popup's local isPhishingDomain() re-check was inert
  (popup never called updatePhishingList so its blacklistSet was always
  empty); now relies solely on background's authoritative flag
- Remove all competitor name mentions from UI warning text and comments
- Update README: document phishing domain protection architecture,
  update external services list
- Update tests: cover vendored blocklist loading, delta computation,
  localStorage persistence, delta+vendored interaction

Closes #114
This commit is contained in:
clawbot 2026-03-01 07:39:18 -08:00 committed by user
parent cb1446067f
commit 5927dfd45b
7 changed files with 231765 additions and 122 deletions

View File

@ -15,10 +15,12 @@ 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,
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
extension contacts exactly three external services: the configured RPC node for
extension contacts three user-configurable services: the configured RPC node for
blockchain interactions, a public CoinDesk API (no API key) for realtime price
information, and a Blockscout block-explorer API for transaction history and
token balances. All three endpoints are user-configurable.
token balances. It also fetches a community-maintained phishing domain blocklist
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
can add any ERC20 contract by contract address if you wish, but the hardcoded
@ -580,14 +582,25 @@ What the extension does NOT do:
- No analytics or telemetry services
- 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 backend servers operated by the developer
These three services (RPC endpoint, CoinDesk price API, and Blockscout API) are
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
self-hosted instances (price fetching can be disabled in a future version).
In addition to the three user-configurable services above (RPC endpoint,
CoinDesk price API, and Blockscout API), AutistMask also contacts:
- **Phishing domain blocklist**: A community-maintained phishing domain
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
@ -777,6 +790,22 @@ indexes it as a real token transfer.
designed as a sharp tool — users who understand the risks can configure the
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
When a dApp asks the user to approve a transaction, AutistMask attempts to

View File

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

View File

@ -1240,7 +1240,7 @@
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"
>
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
⚠️ PHISHING WARNING: This site is on a known phishing
blocklist. This transaction may steal your funds. Proceed
with extreme caution.
</div>
@ -1314,7 +1314,7 @@
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"
>
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
⚠️ PHISHING WARNING: This site is on a known phishing
blocklist. Signing this message may authorize theft of your
funds. Proceed with extreme caution.
</div>
@ -1391,7 +1391,7 @@
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"
>
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
⚠️ PHISHING WARNING: This site is on a known phishing
blocklist. Connecting your wallet may result in loss of
funds. Proceed with extreme caution.
</div>

View File

@ -13,8 +13,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,11 +154,12 @@ function decodeCalldata(data, toAddress) {
return null;
}
function showPhishingWarning(elementId, hostname, isPhishing) {
function showPhishingWarning(elementId, isPhishing) {
const el = $(elementId);
if (!el) return;
// Check both the flag from background and a local re-check
if (isPhishing || isPhishingDomain(hostname)) {
// The background script performs the authoritative phishing domain check
// and passes the result via the isPhishingDomain flag.
if (isPhishing) {
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
@ -170,7 +169,6 @@ function showPhishingWarning(elementId, hostname, isPhishing) {
function showTxApproval(details) {
showPhishingWarning(
"approve-tx-phishing-warning",
details.hostname,
details.isPhishingDomain,
);
@ -343,7 +341,6 @@ function formatTypedDataHtml(jsonStr) {
function showSignApproval(details) {
showPhishingWarning(
"approve-sign-phishing-warning",
details.hostname,
details.isPhishingDomain,
);
@ -409,7 +406,6 @@ function show(id) {
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
details.hostname,
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +1,106 @@
// Domain-based phishing detection using MetaMask's eth-phishing-detect blocklist.
// Fetches the blocklist at runtime, caches it in memory, and checks hostnames.
// Domain-based phishing detection using a vendored blocklist with delta updates.
//
// The blocklist source:
// https://github.com/MetaMask/eth-phishing-detect (src/config.json)
// A community-maintained phishing domain blocklist is vendored in
// phishingBlocklist.json and bundled at build time. At runtime, we fetch
// the live list periodically and keep only the delta (new entries not in
// the vendored list) in memory. This keeps runtime memory usage small.
//
// The config uses { blacklist: [...], whitelist: [...], fuzzylist: [...] }.
// We check exact hostname and parent-domain matches against the blacklist,
// with whitelist overrides.
// The domain-checker checks the in-memory delta first (fresh/recent scam
// 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 BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
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-delta";
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
let blacklistSet = new Set();
let whitelistSet = new Set();
// Vendored sets — built once from the bundled JSON.
const vendoredBlacklist = new Set(
(vendoredConfig.blacklist || []).map((d) => d.toLowerCase()),
);
const vendoredWhitelist = new Set(
(vendoredConfig.whitelist || []).map((d) => d.toLowerCase()),
);
// Delta sets — only entries from live list that are NOT in vendored.
let deltaBlacklist = new Set();
let deltaWhitelist = new Set();
let lastFetchTime = 0;
let fetchPromise = null;
let refreshTimer = null;
/**
* Load a pre-parsed config into the in-memory sets.
* Used for testing and for loading from cache.
* Load delta entries from localStorage on startup.
* Called once during module initialization in the background script.
*/
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
*/
function loadConfig(config) {
blacklistSet = new Set(
(config.blacklist || []).map((d) => d.toLowerCase()),
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
const liveWhitelist = (config.whitelist || []).map((d) => d.toLowerCase());
// Delta = entries in the live list that are NOT in the vendored list
deltaBlacklist = new Set(
liveBlacklist.filter((d) => !vendoredBlacklist.has(d)),
);
whitelistSet = new Set(
(config.whitelist || []).map((d) => d.toLowerCase()),
deltaWhitelist = new Set(
liveWhitelist.filter((d) => !vendoredWhitelist.has(d)),
);
lastFetchTime = Date.now();
saveDeltaToStorage();
}
/**
@ -54,8 +123,8 @@ function hostnameVariants(hostname) {
/**
* Check if a hostname is on the phishing blocklist.
* Checks exact hostname and all parent domains.
* Whitelisted domains are never flagged.
* Checks delta first (fresh/recent scam sites), then vendored list.
* Whitelisted domains (delta + vendored) are never flagged.
*
* @param {string} hostname - The hostname to check.
* @returns {boolean}
@ -63,25 +132,28 @@ function hostnameVariants(hostname) {
function isPhishingDomain(hostname) {
if (!hostname) return false;
const variants = hostnameVariants(hostname);
// Whitelist takes priority
// Whitelist takes priority — check delta whitelist first, then vendored
for (const v of variants) {
if (whitelistSet.has(v)) return false;
if (deltaWhitelist.has(v) || vendoredWhitelist.has(v)) return false;
}
// Check delta blacklist first (fresh/recent scam sites), then vendored
for (const v of variants) {
if (blacklistSet.has(v)) return true;
if (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true;
}
return false;
}
/**
* Fetch the latest blocklist from the MetaMask repo.
* Fetch the latest blocklist and compute delta against vendored data.
* 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) {
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) {
return;
}
@ -95,7 +167,8 @@ async function updatePhishingList() {
const config = await resp.json();
loadConfig(config);
} catch {
// Silently fail — we'll retry next time.
// Silently fail — vendored list still provides coverage.
// We'll retry next time.
} finally {
fetchPromise = null;
}
@ -105,29 +178,61 @@ async function updatePhishingList() {
}
/**
* Return the current blocklist size (for diagnostics).
* Start periodic refresh of the phishing list.
* Should be called once from the background script on startup.
*/
function startPeriodicRefresh() {
if (refreshTimer) return;
refreshTimer = setInterval(updatePhishingList, REFRESH_INTERVAL_MS);
}
/**
* Return the total blocklist size (vendored + delta) for diagnostics.
*
* @returns {number}
*/
function getBlocklistSize() {
return blacklistSet.size;
return vendoredBlacklist.size + deltaBlacklist.size;
}
/**
* Return the delta blocklist size for diagnostics.
*
* @returns {number}
*/
function getDeltaSize() {
return deltaBlacklist.size;
}
/**
* Reset internal state (for testing).
*/
function _reset() {
blacklistSet = new Set();
whitelistSet = new Set();
deltaBlacklist = new Set();
deltaWhitelist = new Set();
lastFetchTime = 0;
fetchPromise = null;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
// Load persisted delta on module initialization
loadDeltaFromStorage();
module.exports = {
isPhishingDomain,
updatePhishingList,
startPeriodicRefresh,
loadConfig,
getBlocklistSize,
getDeltaSize,
hostnameVariants,
_reset,
// Exposed for testing only
_getVendoredBlacklistSize: () => vendoredBlacklist.size,
_getVendoredWhitelistSize: () => vendoredWhitelist.size,
_getDeltaBlacklist: () => deltaBlacklist,
_getDeltaWhitelist: () => deltaWhitelist,
};

View File

@ -1,17 +1,70 @@
// 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 {
isPhishingDomain,
loadConfig,
getBlocklistSize,
getDeltaSize,
hostnameVariants,
_reset,
_getVendoredBlacklistSize,
_getVendoredWhitelistSize,
_getDeltaBlacklist,
_getDeltaWhitelist,
} = require("../src/shared/phishingDomains");
// Reset state before each test to avoid cross-test contamination.
// Reset delta state before each test to avoid cross-test contamination.
// Note: vendored sets are immutable and always present.
beforeEach(() => {
_reset();
// Clear localStorage mock between tests
for (const key of Object.keys(localStorageStore)) {
delete localStorageStore[key];
}
});
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", () => {
test("returns exact hostname plus parent domains", () => {
const variants = hostnameVariants("sub.evil.com");
@ -39,128 +92,168 @@ describe("phishingDomains", () => {
});
});
describe("loadConfig + isPhishingDomain", () => {
test("detects exact blacklisted domain", () => {
describe("delta computation via loadConfig", () => {
test("loadConfig computes delta of new entries not in vendored list", () => {
loadConfig({
blacklist: ["evil-phishing.com", "scam-swap.xyz"],
blacklist: [
"brand-new-scam-site-xyz123.com",
"hopprotocol.pro", // already in vendored
],
whitelist: [],
});
expect(isPhishingDomain("evil-phishing.com")).toBe(true);
expect(isPhishingDomain("scam-swap.xyz")).toBe(true);
// Only the new domain should be in the delta
expect(
_getDeltaBlacklist().has("brand-new-scam-site-xyz123.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", () => {
test("detects domain from delta blacklist", () => {
loadConfig({
blacklist: ["fresh-scam-xyz.com"],
whitelist: [],
});
expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true);
});
test("detects domain from vendored blacklist", () => {
// No delta loaded — vendored still works
expect(isPhishingDomain("hopprotocol.pro")).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);
expect(isPhishingDomain("example.com")).toBe(false);
});
test("detects subdomain of blacklisted domain", () => {
test("detects subdomain of blacklisted domain (vendored)", () => {
expect(isPhishingDomain("app.hopprotocol.pro")).toBe(true);
});
test("detects subdomain of blacklisted domain (delta)", () => {
loadConfig({
blacklist: ["evil-phishing.com"],
blacklist: ["delta-phish-xyz.com"],
whitelist: [],
});
expect(isPhishingDomain("app.evil-phishing.com")).toBe(true);
expect(isPhishingDomain("sub.app.evil-phishing.com")).toBe(true);
expect(isPhishingDomain("sub.delta-phish-xyz.com")).toBe(true);
});
test("whitelist overrides blacklist", () => {
test("delta whitelist overrides vendored blacklist", () => {
// hopprotocol.pro is in the vendored blacklist
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
loadConfig({
blacklist: ["metamask.io"],
whitelist: ["metamask.io"],
blacklist: [],
whitelist: ["hopprotocol.pro"],
});
expect(isPhishingDomain("metamask.io")).toBe(false);
// Now whitelisted via delta — should not be flagged
expect(isPhishingDomain("hopprotocol.pro")).toBe(false);
});
test("whitelist on parent domain overrides blacklist", () => {
test("vendored whitelist overrides delta blacklist", () => {
loadConfig({
blacklist: ["sub.legit.com"],
whitelist: ["legit.com"],
blacklist: ["opensea.pro"], // opensea.pro is vendored-whitelisted
whitelist: [],
});
expect(isPhishingDomain("sub.legit.com")).toBe(false);
expect(isPhishingDomain("opensea.pro")).toBe(false);
});
test("case-insensitive matching", () => {
loadConfig({
blacklist: ["Evil-Phishing.COM"],
blacklist: ["Delta-Scam-XYZ.COM"],
whitelist: [],
});
expect(isPhishingDomain("evil-phishing.com")).toBe(true);
expect(isPhishingDomain("EVIL-PHISHING.COM")).toBe(true);
expect(isPhishingDomain("delta-scam-xyz.com")).toBe(true);
expect(isPhishingDomain("DELTA-SCAM-XYZ.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);
expect(getDeltaSize()).toBe(0);
// Vendored list still works
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
});
});
describe("real-world MetaMask blocklist patterns", () => {
test("detects known phishing domains from MetaMask list", () => {
describe("localStorage persistence", () => {
test("saveDeltaToStorage persists delta under 256KiB", () => {
loadConfig({
blacklist: [
"uniswap-trade.web.app",
"hopprotocol.pro",
"blast-pools.pages.dev",
],
blacklist: ["persisted-scam-xyz.com"],
whitelist: ["persisted-safe-xyz.com"],
});
const stored = localStorage.getItem("phishing-delta");
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: [],
});
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("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",
],
});
test("does not flag legitimate whitelisted domains", () => {
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);
});
});
});