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,
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 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
information, and a Blockscout block-explorer API for transaction history and
token balances. It also fetches a community-maintained phishing domain blocklist
periodically and performs best-effort Etherscan address label lookups during
transaction confirmation.
token balances. All three endpoints are user-configurable.
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
@@ -439,10 +437,6 @@ transitions.
- **When**: User tapped a transaction row from AddressDetail or AddressToken.
- **Elements**:
- "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"
- Time: ISO datetime + relative age in parentheses
- Amount: value + symbol (bold)
@@ -451,11 +445,6 @@ transitions.
- To: blockie + color dot + full address (tap to copy) + etherscan link
- ENS name if available
- 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**:
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
@@ -578,25 +567,14 @@ 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
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).
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).
### 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
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,7 +15,6 @@ const { getSignerForAddress } = require("../shared/wallet");
const {
isPhishingDomain,
updatePhishingList,
startPeriodicRefresh,
} = require("../shared/phishingDomains");
const storageApi =
@@ -576,10 +575,9 @@ async function backgroundRefresh() {
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL);
// 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.
// Fetch the MetaMask eth-phishing-detect domain blocklist on startup.
// Refreshes every 24 hours automatically.
updatePhishingList();
startPeriodicRefresh();
// When approval window is closed without a response, treat as rejection
if (windowsApi && windowsApi.onRemoved) {

View File

@@ -1129,13 +1129,6 @@
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
</div>
<div id="tx-detail-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-well"
@@ -1156,26 +1149,6 @@
<div class="text-xs text-muted mb-1">Transaction hash</div>
<div id="tx-detail-hash" class="text-xs break-all"></div>
</div>
<div id="tx-detail-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 class="text-xs text-muted mb-1">Raw data</div>
<div
@@ -1192,7 +1165,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 a known phishing
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. This transaction may steal your funds. Proceed
with extreme caution.
</div>
@@ -1266,7 +1239,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 a known phishing
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. Signing this message may authorize theft of your
funds. Proceed with extreme caution.
</div>
@@ -1343,7 +1316,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 a known phishing
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. Connecting your wallet may result in loss of
funds. Proceed with extreme caution.
</div>

View File

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

View File

@@ -13,7 +13,6 @@ const {
timeAgo,
} = require("./helpers");
const { state } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
@@ -27,25 +26,6 @@ const EXT_ICON =
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) {
const cls =
"underline decoration-dashed cursor-pointer" +
@@ -119,7 +99,6 @@ function show(tx) {
direction: tx.direction || null,
isContractCall: tx.isContractCall || false,
method: tx.method || null,
contractAddress: tx.contractAddress || null,
},
};
render();
@@ -156,54 +135,30 @@ function render() {
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 typeEl = $("tx-detail-type");
const headingEl = $("tx-detail-heading");
if (typeSection && typeEl) {
typeEl.textContent = getTransactionType(tx);
typeSection.classList.remove("hidden");
if (tx.direction === "contract" && tx.directionLabel) {
if (typeSection) {
typeEl.textContent = tx.directionLabel;
typeSection.classList.remove("hidden");
}
} else {
if (typeSection) typeSection.classList.add("hidden");
}
if (headingEl) headingEl.textContent = "Transaction";
// Token contract address (for ERC-20 transfers)
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
// Hide calldata and raw data sections; re-fetch if this is a contract call
const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section");
if (rawDataSection) rawDataSection.classList.add("hidden");
// Hide on-chain detail sections until populated
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 el = $(id);
if (el) el.classList.add("hidden");
if (tx.isContractCall || tx.direction === "contract") {
loadCalldata(tx.hash, tx.to);
}
loadFullTxDetails(tx.hash, tx.to, tx.isContractCall);
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
@@ -222,90 +177,7 @@ function render() {
});
}
function showDetailField(sectionId, contentId, value) {
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) {
async function loadCalldata(txHash, toAddress) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -320,10 +192,6 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
);
if (!resp.ok) return;
const txData = await resp.json();
// Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData);
const inputData = txData.raw_input || txData.input || null;
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
// 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.
// Architecture:
// 1. A vendored copy of the blocklist ships with the extension
// (src/data/phishing-domains.json — sorted blacklist for binary search).
// 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
// 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.
// Source: https://github.com/MetaMask/eth-phishing-detect (src/config.json)
const vendoredConfig = require("./phishingBlocklist.json");
const vendoredConfig = require("../data/phishing-domains.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
const DELTA_STORAGE_KEY = "phishing_domain_delta";
const DELTA_MAX_BYTES = 256 * 1024; // 256 KiB
// Vendored sets — built once from the bundled JSON.
const vendoredBlacklist = new Set(
(vendoredConfig.blacklist || []).map((d) => d.toLowerCase()),
);
// Vendored baseline — sorted arrays for binary search (no extra Set needed).
const vendoredBlacklist = vendoredConfig.blacklist; // pre-sorted lowercase
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();
// Delta state — only domains added upstream since the vendored snapshot.
let deltaBlacklistSet = new Set();
let deltaWhitelistSet = new Set();
let lastFetchTime = 0;
let fetchPromise = null;
let refreshTimer = null;
let persistedDeltaLoaded = false;
/**
* 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.
* Normalize a domain entry: lowercase and strip wildcard prefix ("*.").
* Wildcard domains like "*.evil.com" become "evil.com" — our subdomain
* matching in hostnameVariants() already covers child domains.
*
* @param {{ blacklist?: string[], whitelist?: string[] }} config
* @param {string} domain
* @returns {string}
*/
function loadConfig(config) {
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
const liveWhitelist = (config.whitelist || []).map((d) => d.toLowerCase());
function normalizeDomain(domain) {
const d = domain.toLowerCase();
return d.startsWith("*.") ? d.slice(2) : d;
}
// Delta = entries in the live list that are NOT in the vendored list
deltaBlacklist = new Set(
liveBlacklist.filter((d) => !vendoredBlacklist.has(d)),
);
deltaWhitelist = new Set(
liveWhitelist.filter((d) => !vendoredWhitelist.has(d)),
);
lastFetchTime = Date.now();
saveDeltaToStorage();
/**
* Binary search on a sorted string array.
*
* @param {string[]} sorted - Sorted array of lowercase strings.
* @param {string} target - Lowercase string to find.
* @returns {boolean}
*/
function binarySearch(sorted, target) {
let lo = 0;
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 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("."));
}
@@ -123,8 +84,8 @@ function hostnameVariants(hostname) {
/**
* Check if a hostname is on the phishing blocklist.
* Checks delta first (fresh/recent scam sites), then vendored list.
* Whitelisted domains (delta + vendored) are never flagged.
* Checks delta (fresh additions) first, then vendored baseline.
* Whitelisted domains (vendored + delta) are never flagged.
*
* @param {string} hostname - The hostname to check.
* @returns {boolean}
@@ -133,27 +94,107 @@ function isPhishingDomain(hostname) {
if (!hostname) return false;
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) {
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) {
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;
}
/**
* Fetch the latest blocklist and compute delta against vendored data.
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS.
* Get the storage API if available (chrome.storage.local / browser.storage.local).
*
* @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>}
*/
async function updatePhishingList() {
// Load persisted delta on first call
if (!persistedDeltaLoaded) {
await loadPersistedDelta();
}
// Skip if recently fetched
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) {
if (Date.now() - lastFetchTime < CACHE_TTL_MS) {
return;
}
@@ -165,10 +206,32 @@ async function updatePhishingList() {
const resp = await fetch(BLOCKLIST_URL);
if (!resp.ok) throw new Error("HTTP " + resp.status);
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 {
// Silently fail — vendored list still provides coverage.
// We'll retry next time.
// Fetch failedkeep existing delta, retry next time.
} finally {
fetchPromise = null;
}
@@ -178,61 +241,57 @@ async function updatePhishingList() {
}
/**
* Start periodic refresh of the phishing list.
* Should be called once from the background script on startup.
* Load a pre-parsed config directly into state (vendored + delta combined).
* Used for testing.
*
* @param {{ blacklist?: string[], whitelist?: string[] }} config
*/
function startPeriodicRefresh() {
if (refreshTimer) return;
refreshTimer = setInterval(updatePhishingList, REFRESH_INTERVAL_MS);
function loadConfig(config) {
// For tests: treat the entire config as delta (overlaid on vendored).
// 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}
*/
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}
*/
function getDeltaSize() {
return deltaBlacklist.size;
return deltaBlacklistSet.size;
}
/**
* Reset internal state (for testing).
*/
function _reset() {
deltaBlacklist = new Set();
deltaWhitelist = new Set();
deltaBlacklistSet = new Set();
deltaWhitelistSet = new Set();
lastFetchTime = 0;
fetchPromise = null;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
persistedDeltaLoaded = false;
}
// Load persisted delta on module initialization
loadDeltaFromStorage();
module.exports = {
isPhishingDomain,
updatePhishingList,
startPeriodicRefresh,
loadConfig,
getBlocklistSize,
getDeltaSize,
hostnameVariants,
binarySearch,
normalizeDomain,
_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
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. For contract calls (swaps), a single transaction
// can produce multiple token transfers (input, intermediates, output).
// We consolidate these into the original tx entry using the token
// transfer where the user *receives* tokens (the swap output), so
// the transaction list shows the final result rather than confusing
// intermediate hops. We preserve the original tx's from/to so the
// user sees their own address, not a router or Permit2 contract.
// 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.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
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
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
parsed.direction = "contract";
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
// Remove the bare-hash normal tx so it doesn't appear as a
// duplicate with empty value; token transfers replace it.
txsByHash.delete(parsed.hash);
}
// Non-contract token transfers get their own entries.
// Use composite key so multiple token transfers per tx are kept.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
}

View File

@@ -359,12 +359,9 @@ function decode(data, toAddress) {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn;
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
if (!minOutput) minOutput = s.amountOutMin;
}
}
@@ -372,9 +369,9 @@ function decode(data, toAddress) {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!outputToken) outputToken = s.tokenOut;
if (!inputAmount) inputAmount = s.amountIn;
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
if (!minOutput) minOutput = s.amountOutMin;
}
}
@@ -391,11 +388,12 @@ function decode(data, toAddress) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!outputToken && v4.tokenOut)
outputToken = v4.tokenOut;
if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn;
// Always update output: last swap step wins
if (v4.tokenOut) outputToken = v4.tokenOut;
if (v4.amountOutMin) minOutput = v4.amountOutMin;
if (!minOutput && 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 {
isPhishingDomain,
loadConfig,
getBlocklistSize,
getDeltaSize,
hostnameVariants,
binarySearch,
normalizeDomain,
_reset,
_getVendoredBlacklistSize,
_getVendoredWhitelistSize,
_getDeltaBlacklist,
_getDeltaWhitelist,
} = require("../src/shared/phishingDomains");
// Reset delta state before each test to avoid cross-test contamination.
// Note: vendored sets are immutable and always present.
// The vendored baseline is loaded automatically via require().
// _reset() clears only the delta state, not the vendored baseline.
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");
@@ -92,168 +43,205 @@ describe("phishingDomains", () => {
});
});
describe("delta computation via loadConfig", () => {
test("loadConfig computes delta of new entries not in vendored list", () => {
loadConfig({
blacklist: [
"brand-new-scam-site-xyz123.com",
"hopprotocol.pro", // already in vendored
],
whitelist: [],
});
// 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);
describe("binarySearch", () => {
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("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("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("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("handles empty array", () => {
expect(binarySearch([], "anything")).toBe(false);
});
test("getBlocklistSize includes both vendored and delta", () => {
const baseSize = getBlocklistSize();
loadConfig({
blacklist: ["delta-only-scam-xyz.com"],
whitelist: [],
});
expect(getBlocklistSize()).toBe(baseSize + 1);
test("handles single-element array", () => {
expect(binarySearch(["only.com"], "only.com")).toBe(true);
expect(binarySearch(["only.com"], "other.com")).toBe(false);
});
});
describe("isPhishingDomain with delta + vendored", () => {
test("detects domain from delta blacklist", () => {
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({
blacklist: ["fresh-scam-xyz.com"],
blacklist: ["*.scam-site.com", "normal-scam.com"],
whitelist: [],
});
expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true);
// *.scam-site.com is normalized to scam-site.com
expect(isPhishingDomain("scam-site.com")).toBe(true);
expect(isPhishingDomain("sub.scam-site.com")).toBe(true);
expect(isPhishingDomain("normal-scam.com")).toBe(true);
});
});
describe("vendored baseline detection", () => {
// These tests verify that the vendored phishing-domains.json
// is loaded and searchable without any delta loaded.
test("getBlocklistSize reflects vendored list (no delta)", () => {
// The vendored list has 231k+ domains; delta is empty after reset.
expect(getBlocklistSize()).toBeGreaterThan(200000);
expect(getDeltaSize()).toBe(0);
});
test("detects domain from vendored blacklist", () => {
// No delta loaded — vendored still works
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
});
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 clean domains against vendored list", () => {
expect(isPhishingDomain("google.com")).toBe(false);
expect(isPhishingDomain("github.com")).toBe(false);
});
test("returns false for empty/null hostname", () => {
expect(isPhishingDomain("")).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", () => {
loadConfig({});
expect(getDeltaSize()).toBe(0);
// Vendored list still works
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
});
});
describe("localStorage persistence", () => {
test("saveDeltaToStorage persists delta under 256KiB", () => {
describe("real-world MetaMask blocklist patterns (via delta)", () => {
test("detects known phishing domains loaded as delta", () => {
loadConfig({
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"],
blacklist: [
"uniswap-trade.web.app",
"hopprotocol.pro",
"blast-pools.pages.dev",
],
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 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("metamask.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);
});
});
});