Out of the box the wallet sent the user's addresses to Blockscout and each recipient's address to Etherscan. Two settings, "Use Blockscout" and "Check recipients on Etherscan", now gate every request that sends the user's data to those services. Both start off and are offered unticked on Welcome and in Settings. Each request reads its setting from storage before it asks. An answer that lands after the setting went off is dropped. While a setting is off, the screens that rely on it say so. ENS lookups ask only the RPC endpoint, so a name resolved off-chain does not resolve. RULES.md's External Communication item is now out of date. Its new wording will be proposed on #211 once this lands. Model: opus-5-5 (implementation, summary); fable-5-1 (prose)
124 lines
4.5 KiB
JavaScript
124 lines
4.5 KiB
JavaScript
// Etherscan address label lookup via page scraping.
|
|
// Extension users make the requests directly to Etherscan — no proxy needed.
|
|
// This is a best-effort enrichment: network failures return null silently.
|
|
|
|
const { networkByChainId } = require("./networks");
|
|
const { storedSettingIsOn } = require("./storedSettings");
|
|
|
|
// Patterns in the page title that indicate a flagged address.
|
|
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
|
|
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
|
|
|
|
// Patterns in the page body that indicate a scam/phishing warning.
|
|
const SCAM_BODY_PATTERNS = [
|
|
/used in a\s+(?:\w+\s+)?phishing scam/i,
|
|
/used in a\s+(?:\w+\s+)?scam/i,
|
|
/wallet\s+drainer/i,
|
|
];
|
|
|
|
/**
|
|
* Parse the Etherscan address page HTML to extract label info.
|
|
* Exported for unit testing (no fetch needed).
|
|
*
|
|
* @param {string} html - Raw HTML of the Etherscan address page.
|
|
* @returns {{ label: string|null, isPhishing: boolean, warning: string|null }}
|
|
*/
|
|
function parseEtherscanPage(html) {
|
|
// Extract <title> content
|
|
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
let label = null;
|
|
let isPhishing = false;
|
|
let warning = null;
|
|
|
|
if (titleMatch) {
|
|
const title = titleMatch[1].trim();
|
|
// Title: "LABEL | Address: 0x... | Etherscan" or "Address: 0x... | Etherscan"
|
|
const labelMatch = title.match(/^(.+?)\s*\|\s*Address:/);
|
|
if (labelMatch) {
|
|
const candidate = labelMatch[1].trim();
|
|
// Only treat as a label if it's not just "Address" (unlabeled addresses)
|
|
if (candidate.toLowerCase() !== "address") {
|
|
label = candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check label against phishing patterns
|
|
if (label) {
|
|
for (const pat of PHISHING_LABEL_PATTERNS) {
|
|
if (pat.test(label)) {
|
|
isPhishing = true;
|
|
warning = `Etherscan labels this address as "${label}" (Phish/Hack).`;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check page body for scam warning banners
|
|
if (!isPhishing) {
|
|
for (const pat of SCAM_BODY_PATTERNS) {
|
|
if (pat.test(html)) {
|
|
isPhishing = true;
|
|
warning = label
|
|
? `Etherscan labels this address as "${label}" and reports it was used in a scam.`
|
|
: "Etherscan reports this address was flagged for phishing/scam activity.";
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { label, isPhishing, warning };
|
|
}
|
|
|
|
/**
|
|
* Fetch an address page from Etherscan and check for scam/phishing labels.
|
|
* Returns a warning object if the address is flagged, or null.
|
|
* Network failures return null silently (best-effort check).
|
|
*
|
|
* Uses the explorer of the network with `chainId`, or of the active network
|
|
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
|
|
* Sepolia.
|
|
*
|
|
* While the stored "Check recipients on Etherscan" is off, Etherscan is not
|
|
* asked. An answer that lands after the setting went off is dropped
|
|
* (src/shared/storedSettings.js). Either way the answer is null.
|
|
*
|
|
* @param {string} address - Ethereum address to check.
|
|
* @param {string} [chainId] - Chain id of the network to look it up on.
|
|
* @returns {Promise<{type: string, message: string, severity: string}|null>}
|
|
*/
|
|
async function checkEtherscanLabel(address, chainId) {
|
|
try {
|
|
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
|
|
return null;
|
|
}
|
|
// Lazy require to avoid pulling in chrome.storage at module scope
|
|
// (which breaks unit tests that only exercise parseEtherscanPage).
|
|
const { currentNetwork } = require("./state");
|
|
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
|
const etherscanBase = network.explorerUrl + "/address/";
|
|
const resp = await fetch(etherscanBase + address, {
|
|
headers: { Accept: "text/html" },
|
|
});
|
|
if (!resp.ok) return null;
|
|
const html = await resp.text();
|
|
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
|
|
return null;
|
|
}
|
|
const result = parseEtherscanPage(html);
|
|
if (result.isPhishing) {
|
|
return {
|
|
type: "etherscan-phishing",
|
|
message: result.warning,
|
|
severity: "critical",
|
|
};
|
|
}
|
|
return null;
|
|
} catch {
|
|
// Network errors are expected — Etherscan may rate-limit or block.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = { parseEtherscanPage, checkEtherscanLabel };
|