Compare commits
5 Commits
06324158aa
...
0c73c8e4cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c73c8e4cc | ||
|
|
d84d95d36c | ||
| 02238b7a1b | |||
|
|
e08b409043 | ||
|
|
bf01ae6f4d |
35
README.md
35
README.md
@@ -437,29 +437,25 @@ transitions.
|
||||
#### TransactionDetail
|
||||
|
||||
- **When**: User tapped a transaction row from AddressDetail or AddressToken.
|
||||
- **Elements** (grouped into logical blocks using light well containers; field
|
||||
labels are self-explanatory so groups have no headings):
|
||||
- **Elements**:
|
||||
- "Transaction" heading, "Back" button
|
||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
|
||||
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
|
||||
- Status: "Success" or "Failed"
|
||||
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
||||
ENS name if available
|
||||
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
|
||||
name if available
|
||||
- Time: ISO datetime + relative age in parentheses
|
||||
- Block: block number (tap to copy) + etherscan block link
|
||||
- Amount: value + symbol (bold)
|
||||
- Native quantity: raw integer + unit (shown when available)
|
||||
- Token contract: shown for ERC-20 transfers — color dot + full contract
|
||||
address (tap to copy) + etherscan token link
|
||||
- Decoded details (shown for contract calls): action name, decoded
|
||||
parameters, token details, swap steps
|
||||
- Network details (shown when on-chain data is available): nonce, gas price,
|
||||
gas used, transaction fee (all tap to copy)
|
||||
- Raw data (shown when calldata is present): full calldata in monospace
|
||||
dashed border
|
||||
- Status: "Success" or "Failed"
|
||||
- Time: ISO datetime + relative age in parentheses
|
||||
- Amount: value + symbol (bold)
|
||||
- From: blockie + color dot + full address (tap to copy) + etherscan link
|
||||
- ENS name if available
|
||||
- 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**
|
||||
|
||||
@@ -803,7 +799,8 @@ 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).
|
||||
parent domains (subdomain matching), with whitelist overrides for legitimate
|
||||
sites that share a parent domain with a blocklisted entry.
|
||||
|
||||
#### Transaction Decoding
|
||||
|
||||
|
||||
@@ -1101,84 +1101,46 @@
|
||||
<h2 id="tx-detail-heading" class="font-bold mb-2">
|
||||
Transaction
|
||||
</h2>
|
||||
|
||||
<!-- ── Identity ── -->
|
||||
<div class="bg-well p-3 mx-1 mb-3">
|
||||
<div class="mb-2">
|
||||
<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-type-section" class="mb-2 hidden">
|
||||
<div id="tx-detail-type-section" class="mb-4 hidden">
|
||||
<div class="text-xs text-muted mb-1">Type</div>
|
||||
<div
|
||||
id="tx-detail-type"
|
||||
class="text-xs font-bold"
|
||||
></div>
|
||||
<div id="tx-detail-type" class="text-xs font-bold"></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-muted mb-1">Status</div>
|
||||
<div id="tx-detail-status" class="text-xs"></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="text-xs text-muted mb-1">From</div>
|
||||
<div
|
||||
id="tx-detail-from"
|
||||
class="text-xs break-all"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="text-xs text-muted mb-1">To</div>
|
||||
<div id="tx-detail-to" class="text-xs break-all"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Timing ── -->
|
||||
<div class="bg-well p-3 mx-1 mb-3">
|
||||
<div class="mb-2">
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-muted mb-1">Time</div>
|
||||
<div id="tx-detail-time" class="text-xs"></div>
|
||||
</div>
|
||||
<div id="tx-detail-block-section" class="mb-2 hidden">
|
||||
<div class="text-xs text-muted mb-1">Block</div>
|
||||
<div id="tx-detail-block" class="text-xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Value ── -->
|
||||
<div class="bg-well p-3 mx-1 mb-3">
|
||||
<div class="mb-2">
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-muted mb-1">Amount</div>
|
||||
<div id="tx-detail-value" class="text-xs"></div>
|
||||
</div>
|
||||
<div class="mb-2 hidden">
|
||||
<div class="text-xs text-muted mb-1">
|
||||
Native quantity
|
||||
</div>
|
||||
<div class="mb-4 hidden">
|
||||
<div class="text-xs text-muted mb-1">Native quantity</div>
|
||||
<div id="tx-detail-native" class="text-xs"></div>
|
||||
</div>
|
||||
<div
|
||||
id="tx-detail-token-contract-section"
|
||||
class="mb-2 hidden"
|
||||
>
|
||||
<div class="text-xs text-muted mb-1">
|
||||
Token contract
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-muted mb-1">From</div>
|
||||
<div id="tx-detail-from" class="text-xs break-all"></div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<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>
|
||||
|
||||
<!-- ── Decoded details ── -->
|
||||
<div id="tx-detail-calldata-section" class="hidden">
|
||||
<div class="bg-well p-3 mx-1 mb-3">
|
||||
<div id="tx-detail-calldata-well" class="mb-2">
|
||||
<div id="tx-detail-calldata-section" class="mb-4 hidden">
|
||||
<div
|
||||
id="tx-detail-calldata-well"
|
||||
class="mb-3 border border-border border-dashed p-2"
|
||||
>
|
||||
<div class="text-xs text-muted mb-1">Action</div>
|
||||
<div
|
||||
id="tx-detail-calldata-action"
|
||||
@@ -1190,39 +1152,31 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-muted mb-1">Transaction hash</div>
|
||||
<div id="tx-detail-hash" class="text-xs break-all"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Network details ── -->
|
||||
<div id="tx-detail-network-section" class="hidden">
|
||||
<div class="bg-well p-3 mx-1 mb-3">
|
||||
<div id="tx-detail-nonce-section" class="mb-2 hidden">
|
||||
<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-gasprice-section"
|
||||
class="mb-2 hidden"
|
||||
>
|
||||
<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-2 hidden">
|
||||
<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-fee-section" class="mb-2 hidden">
|
||||
<div class="text-xs text-muted mb-1">
|
||||
Transaction fee
|
||||
</div>
|
||||
<div id="tx-detail-fee" class="text-xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Raw data ── -->
|
||||
<div id="tx-detail-rawdata-section" class="hidden">
|
||||
<div class="bg-well p-3 mx-1 mb-3">
|
||||
<div class="mb-2">
|
||||
<div id="tx-detail-rawdata-section" class="mb-4 hidden">
|
||||
<div class="text-xs text-muted mb-1">Raw data</div>
|
||||
<div
|
||||
id="tx-detail-rawdata"
|
||||
@@ -1230,8 +1184,6 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ TRANSACTION APPROVAL ============ -->
|
||||
<div id="view-approve-tx" class="view hidden">
|
||||
|
||||
@@ -197,7 +197,6 @@ function render() {
|
||||
"tx-detail-fee-section",
|
||||
"tx-detail-gasprice-section",
|
||||
"tx-detail-gasused-section",
|
||||
"tx-detail-network-section",
|
||||
]) {
|
||||
const el = $(id);
|
||||
if (el) el.classList.add("hidden");
|
||||
@@ -286,21 +285,6 @@ function populateOnChainDetails(txData) {
|
||||
);
|
||||
}
|
||||
|
||||
// Show the network details wrapper if any child section is visible
|
||||
const networkWrapper = $("tx-detail-network-section");
|
||||
if (networkWrapper) {
|
||||
const hasVisible = [
|
||||
"tx-detail-nonce-section",
|
||||
"tx-detail-fee-section",
|
||||
"tx-detail-gasprice-section",
|
||||
"tx-detail-gasused-section",
|
||||
].some((id) => {
|
||||
const el = $(id);
|
||||
return el && !el.classList.contains("hidden");
|
||||
});
|
||||
if (hasVisible) networkWrapper.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Bind copy handlers for newly added elements
|
||||
for (const id of [
|
||||
"tx-detail-block-section",
|
||||
|
||||
@@ -21,13 +21,17 @@ const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const DELTA_STORAGE_KEY = "phishing-delta";
|
||||
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
|
||||
|
||||
// Vendored set — built once from the bundled JSON.
|
||||
// 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 set — only entries from live list that are NOT in vendored.
|
||||
// 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;
|
||||
@@ -46,6 +50,11 @@ function loadDeltaFromStorage() {
|
||||
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
|
||||
}
|
||||
@@ -58,6 +67,7 @@ function saveDeltaToStorage() {
|
||||
try {
|
||||
const data = {
|
||||
blacklist: Array.from(deltaBlacklist),
|
||||
whitelist: Array.from(deltaWhitelist),
|
||||
};
|
||||
const json = JSON.stringify(data);
|
||||
if (json.length < MAX_DELTA_BYTES) {
|
||||
@@ -75,15 +85,19 @@ function saveDeltaToStorage() {
|
||||
* Load a pre-parsed config and compute the delta against the vendored list.
|
||||
* Used for both live fetches and testing.
|
||||
*
|
||||
* @param {{ blacklist?: string[] }} config
|
||||
* @param {{ blacklist?: string[], whitelist?: string[] }} config
|
||||
*/
|
||||
function loadConfig(config) {
|
||||
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)),
|
||||
);
|
||||
deltaWhitelist = new Set(
|
||||
liveWhitelist.filter((d) => !vendoredWhitelist.has(d)),
|
||||
);
|
||||
|
||||
lastFetchTime = Date.now();
|
||||
saveDeltaToStorage();
|
||||
@@ -110,6 +124,7 @@ 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.
|
||||
*
|
||||
* @param {string} hostname - The hostname to check.
|
||||
* @returns {boolean}
|
||||
@@ -118,6 +133,11 @@ function isPhishingDomain(hostname) {
|
||||
if (!hostname) return false;
|
||||
const variants = hostnameVariants(hostname);
|
||||
|
||||
// Whitelist takes priority — check delta whitelist first, then vendored
|
||||
for (const v of variants) {
|
||||
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 (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true;
|
||||
@@ -189,6 +209,7 @@ function getDeltaSize() {
|
||||
*/
|
||||
function _reset() {
|
||||
deltaBlacklist = new Set();
|
||||
deltaWhitelist = new Set();
|
||||
lastFetchTime = 0;
|
||||
fetchPromise = null;
|
||||
if (refreshTimer) {
|
||||
@@ -211,5 +232,7 @@ module.exports = {
|
||||
_reset,
|
||||
// Exposed for testing only
|
||||
_getVendoredBlacklistSize: () => vendoredBlacklist.size,
|
||||
_getVendoredWhitelistSize: () => vendoredWhitelist.size,
|
||||
_getDeltaBlacklist: () => deltaBlacklist,
|
||||
_getDeltaWhitelist: () => deltaWhitelist,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,9 @@ const {
|
||||
hostnameVariants,
|
||||
_reset,
|
||||
_getVendoredBlacklistSize,
|
||||
_getVendoredWhitelistSize,
|
||||
_getDeltaBlacklist,
|
||||
_getDeltaWhitelist,
|
||||
} = require("../src/shared/phishingDomains");
|
||||
|
||||
// Reset delta state before each test to avoid cross-test contamination.
|
||||
@@ -43,12 +45,21 @@ describe("phishingDomains", () => {
|
||||
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);
|
||||
});
|
||||
@@ -88,6 +99,7 @@ describe("phishingDomains", () => {
|
||||
"brand-new-scam-site-xyz123.com",
|
||||
"hopprotocol.pro", // already in vendored
|
||||
],
|
||||
whitelist: [],
|
||||
});
|
||||
// Only the new domain should be in the delta
|
||||
expect(
|
||||
@@ -97,14 +109,30 @@ describe("phishingDomains", () => {
|
||||
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);
|
||||
@@ -114,6 +142,7 @@ describe("phishingDomains", () => {
|
||||
const baseSize = getBlocklistSize();
|
||||
loadConfig({
|
||||
blacklist: ["delta-only-scam-xyz.com"],
|
||||
whitelist: [],
|
||||
});
|
||||
expect(getBlocklistSize()).toBe(baseSize + 1);
|
||||
});
|
||||
@@ -123,6 +152,7 @@ describe("phishingDomains", () => {
|
||||
test("detects domain from delta blacklist", () => {
|
||||
loadConfig({
|
||||
blacklist: ["fresh-scam-xyz.com"],
|
||||
whitelist: [],
|
||||
});
|
||||
expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true);
|
||||
});
|
||||
@@ -144,13 +174,34 @@ describe("phishingDomains", () => {
|
||||
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);
|
||||
@@ -161,7 +212,7 @@ describe("phishingDomains", () => {
|
||||
expect(isPhishingDomain(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("handles config with no blacklist key", () => {
|
||||
test("handles config with no blacklist/whitelist keys", () => {
|
||||
loadConfig({});
|
||||
expect(getDeltaSize()).toBe(0);
|
||||
// Vendored list still works
|
||||
@@ -173,16 +224,19 @@ describe("phishingDomains", () => {
|
||||
test("saveDeltaToStorage persists delta under 256KiB", () => {
|
||||
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"],
|
||||
whitelist: [],
|
||||
});
|
||||
expect(getDeltaSize()).toBe(1);
|
||||
_reset();
|
||||
@@ -197,7 +251,7 @@ describe("phishingDomains", () => {
|
||||
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not flag legitimate domains", () => {
|
||||
test("does not flag legitimate whitelisted domains", () => {
|
||||
expect(isPhishingDomain("opensea.io")).toBe(false);
|
||||
expect(isPhishingDomain("etherscan.io")).toBe(false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user