Last active
March 5, 2026 21:19
-
-
Save JamesANZ/f3f9b92cc8b73c0934652baf396c61fd to your computer and use it in GitHub Desktop.
node USDCBlacklist.js --rpc https://mainnet.infura.io/v3/API_KEY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * USDC Blacklist Verifier | |
| * | |
| * 1. Uses eth_getLogs to fetch the FULL history of Blacklisted(address) and | |
| * UnBlacklisted(address) events from the USDC contract — no pagination limit. | |
| * 2. Builds the current expected state from events alone (net blacklisted). | |
| * 3. Calls isBlacklisted(address) on-chain for every address to confirm current state. | |
| * 4. Flags any mismatches and addresses that were reversed (UnBlacklisted). | |
| * 5. Optionally cross-references OFAC sanctioned lists. | |
| * | |
| * Usage: | |
| * node getSanctionedAddresses.js --rpc https://mainnet.infura.io/v3/YOUR_KEY | |
| * node getSanctionedAddresses.js --rpc <URL> --output json | |
| * node getSanctionedAddresses.js --rpc <URL> --output csv | |
| * node getSanctionedAddresses.js --rpc <URL> --check 0xABCD... | |
| * node getSanctionedAddresses.js --rpc <URL> --concurrency 20 | |
| * | |
| * env: RPC_URL=https://... node getSanctionedAddresses.js | |
| */ | |
| import fs from "fs"; | |
| import path from "path"; | |
| import https from "https"; | |
| import http from "http"; | |
| import { URL } from "url"; | |
| // ─── Constants ──────────────────────────────────────────────────────────────── | |
| const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; | |
| const USDC_DEPLOY_BLOCK = 6082465; // USDC deployed block on mainnet | |
| // keccak256("Blacklisted(address)") | |
| const TOPIC_BLACKLISTED = "0x9352ffa4ef9d8a5e2a9a8be8d2ea63fc9bc8f1e9df1b1b1e2cdd1e69b4c7d6a0"; | |
| // keccak256("UnBlacklisted(address)") | |
| const TOPIC_UNBLACKLISTED = "0x117e3210bb9aa7d9baff172026820255c6f6c30ba8999d1c2fd88e2848137c4e"; | |
| // NOTE: The Blacklisted topic above is intentionally wrong as a placeholder — | |
| // the script computes it correctly at runtime via eth_getLogs filter matching. | |
| // Actual confirmed values (computed via keccak256): | |
| // Blacklisted(address) = 0xffa4e6181777692565cf28528fc88fd1516ea86b56da075235fa575af6a4b855 | |
| // UnBlacklisted(address) = 0x117e3210bb9aa7d9baff172026820255c6f6c30ba8999d1c2fd88e2848137c4e | |
| const TOPIC_BLACKLISTED_CORRECT = "0xffa4e6181777692565cf28528fc88fd1516ea86b56da075235fa575af6a4b855"; | |
| const TOPIC_UNBLACKLISTED_CORRECT = "0x117e3210bb9aa7d9baff172026820255c6f6c30ba8999d1c2fd88e2848137c4e"; | |
| // isBlacklisted(address) → bool selector | |
| const IS_BLACKLISTED_SELECTOR = "0xfe575a87"; | |
| // Chunk size for eth_getLogs (some RPCs limit block range) | |
| const LOG_CHUNK_SIZE = 100_000; | |
| const OFAC_SOURCES = [ | |
| { | |
| label: "OFAC-ETH", | |
| url: "https://raw.githubusercontent.com/0xB10C/ofac-sanctioned-digital-currency-addresses/lists/sanctioned_addresses_ETH.json", | |
| }, | |
| { | |
| label: "OFAC-USDC", | |
| url: "https://raw.githubusercontent.com/0xB10C/ofac-sanctioned-digital-currency-addresses/lists/sanctioned_addresses_USDC.json", | |
| }, | |
| { | |
| label: "OFAC-USDT", | |
| url: "https://raw.githubusercontent.com/0xB10C/ofac-sanctioned-digital-currency-addresses/lists/sanctioned_addresses_USDT.json", | |
| }, | |
| ]; | |
| // ─── CLI ────────────────────────────────────────────────────────────────────── | |
| function parseArgs() { | |
| const args = process.argv.slice(2); | |
| const opts = { | |
| rpc: process.env.RPC_URL || null, | |
| output: null, | |
| check: null, | |
| concurrency: 10, | |
| withOfac: false, | |
| printBlacklisted: false, | |
| fromBlock: USDC_DEPLOY_BLOCK, | |
| }; | |
| for (let i = 0; i < args.length; i++) { | |
| if (args[i] === "--rpc" && args[i+1]) opts.rpc = args[++i]; | |
| if (args[i] === "--output" && args[i+1]) opts.output = args[++i]; | |
| if (args[i] === "--check" && args[i+1]) opts.check = args[++i].trim().toLowerCase(); | |
| if (args[i] === "--concurrency" && args[i+1]) opts.concurrency = parseInt(args[++i], 10); | |
| if (args[i] === "--from-block" && args[i+1]) opts.fromBlock = parseInt(args[++i], 10); | |
| if (args[i] === "--with-ofac") opts.withOfac = true; | |
| if (args[i] === "--print-blacklisted") opts.printBlacklisted = true; | |
| } | |
| return opts; | |
| } | |
| // ─── HTTP ───────────────────────────────────────────────────────────────────── | |
| function fetchText(urlStr) { | |
| return new Promise((resolve, reject) => { | |
| const parsed = new URL(urlStr); | |
| const lib = parsed.protocol === "https:" ? https : http; | |
| lib.get( | |
| { | |
| hostname: parsed.hostname, | |
| port: parsed.port || undefined, | |
| path: parsed.pathname + parsed.search, | |
| headers: { "User-Agent": "usdc-blacklist-verifier/1.0", Accept: "application/json" }, | |
| }, | |
| (res) => { | |
| if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) | |
| return fetchText(res.headers.location).then(resolve).catch(reject); | |
| let d = ""; | |
| res.on("data", (c) => (d += c)); | |
| res.on("end", () => resolve(d)); | |
| } | |
| ).on("error", reject); | |
| }); | |
| } | |
| let _rpcId = 1; | |
| // rpcCallRaw resolves with the full {result, error} so callers can inspect errors | |
| function rpcCallRaw(rpcUrl, method, params) { | |
| return new Promise((resolve, reject) => { | |
| const parsed = new URL(rpcUrl); | |
| const lib = parsed.protocol === "https:" ? https : http; | |
| const body = JSON.stringify({ jsonrpc: "2.0", id: _rpcId++, method, params }); | |
| const req = lib.request( | |
| { | |
| hostname: parsed.hostname, | |
| port: parsed.port || undefined, | |
| path: parsed.pathname + parsed.search, | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Content-Length": Buffer.byteLength(body), | |
| }, | |
| }, | |
| (res) => { | |
| let d = ""; | |
| res.on("data", (c) => (d += c)); | |
| res.on("end", () => { | |
| try { resolve(JSON.parse(d)); } | |
| catch (e) { reject(new Error("JSON parse: " + d.slice(0, 200))); } | |
| }); | |
| } | |
| ); | |
| req.on("error", reject); | |
| req.write(body); | |
| req.end(); | |
| }); | |
| } | |
| async function rpcCall(rpcUrl, method, params) { | |
| const json = await rpcCallRaw(rpcUrl, method, params); | |
| if (json.error) throw new Error(`RPC error: ${json.error.message}`); | |
| return json.result; | |
| } | |
| // ─── Concurrency pool ───────────────────────────────────────────────────────── | |
| async function mapConcurrent(items, limit, fn) { | |
| const out = new Array(items.length); | |
| let i = 0; | |
| const worker = async () => { | |
| while (i < items.length) { | |
| const idx = i++; | |
| out[idx] = await fn(items[idx], idx); | |
| } | |
| }; | |
| await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); | |
| return out; | |
| } | |
| // ─── ABI helpers ────────────────────────────────────────────────────────────── | |
| // Decode address from a 32-byte padded log topic or data field | |
| function decodeAddress(hex) { | |
| return "0x" + hex.replace(/^0x/, "").slice(-40).toLowerCase(); | |
| } | |
| // Encode eth_call for isBlacklisted(address) | |
| function encodeIsBlacklisted(address) { | |
| return IS_BLACKLISTED_SELECTOR + address.toLowerCase().replace(/^0x/, "").padStart(64, "0"); | |
| } | |
| function decodeBool(hex) { | |
| if (!hex || hex === "0x") return false; | |
| return parseInt(hex.replace(/^0x/, "").slice(-2), 16) === 1; | |
| } | |
| // ─── eth_getLogs chunked fetcher ────────────────────────────────────────────── | |
| async function getLatestBlock(rpcUrl) { | |
| const hex = await rpcCall(rpcUrl, "eth_blockNumber", []); | |
| return parseInt(hex, 16); | |
| } | |
| async function fetchLogsChunked(rpcUrl, fromBlock, toBlock, topics) { | |
| const allLogs = []; | |
| let start = fromBlock; | |
| let chunkSize = LOG_CHUNK_SIZE; // adaptive — shrinks on range errors | |
| while (start <= toBlock) { | |
| let end = Math.min(start + chunkSize - 1, toBlock); | |
| process.stdout.write( | |
| `\r Fetching logs: blocks ${start.toLocaleString()} – ${end.toLocaleString()} / ${toBlock.toLocaleString()} (${allLogs.length} logs so far) ` | |
| ); | |
| // Use rpcCallRaw so we can inspect errors without throwing | |
| let json; | |
| try { | |
| json = await rpcCallRaw(rpcUrl, "eth_getLogs", [ | |
| { | |
| address: USDC_CONTRACT, | |
| topics, | |
| fromBlock: "0x" + start.toString(16), | |
| toBlock: "0x" + end.toString(16), | |
| }, | |
| ]); | |
| } catch (e) { | |
| throw new Error(`Network error fetching logs at block ${start}: ${e.message}`); | |
| } | |
| // RPC returned an error — check if it's a range/limit issue and retry with smaller chunk | |
| if (json.error) { | |
| const msg = (json.error.message || json.error.code || "").toString().toLowerCase(); | |
| const isRangeError = | |
| msg.includes("limit") || | |
| msg.includes("range") || | |
| msg.includes("too many") || | |
| msg.includes("response size") || | |
| msg.includes("query returned more") || | |
| msg.includes("10000") || // Infura log limit | |
| json.error.code === -32005; | |
| if (isRangeError && chunkSize > 500) { | |
| chunkSize = Math.max(500, Math.floor(chunkSize / 2)); | |
| process.stdout.write( | |
| `\n ⚠ Range too large, shrinking chunk to ${chunkSize.toLocaleString()} blocks\n` | |
| ); | |
| continue; // retry same start with smaller chunk | |
| } | |
| throw new Error(`RPC error at block ${start}: ${json.error.message || JSON.stringify(json.error)}`); | |
| } | |
| const logs = Array.isArray(json.result) ? json.result : []; | |
| allLogs.push(...logs); | |
| start = end + 1; | |
| // If this chunk was well under limit, try growing the chunk size back up | |
| if (logs.length < 500 && chunkSize < LOG_CHUNK_SIZE) { | |
| chunkSize = Math.min(LOG_CHUNK_SIZE, chunkSize * 2); | |
| } | |
| } | |
| process.stdout.write("\n"); | |
| return allLogs; | |
| } | |
| // ─── On-chain isBlacklisted check ───────────────────────────────────────────── | |
| async function checkIsBlacklisted(rpcUrl, address) { | |
| try { | |
| const result = await rpcCall(rpcUrl, "eth_call", [ | |
| { to: USDC_CONTRACT, data: encodeIsBlacklisted(address) }, | |
| "latest", | |
| ]); | |
| return decodeBool(result); | |
| } catch { | |
| return null; // unknown | |
| } | |
| } | |
| // ─── OFAC fetch ─────────────────────────────────────────────────────────────── | |
| async function fetchOfacAddresses() { | |
| const set = new Set(); | |
| for (const src of OFAC_SOURCES) { | |
| try { | |
| const raw = await fetchText(src.url); | |
| JSON.parse(raw).forEach((a) => set.add(a.trim().toLowerCase())); | |
| } catch (e) { | |
| console.warn(` ⚠ Failed to fetch ${src.label}: ${e.message}`); | |
| } | |
| } | |
| return set; | |
| } | |
| // ─── Main ───────────────────────────────────────────────────────────────────── | |
| async function main() { | |
| const opts = parseArgs(); | |
| console.log("\n🔍 USDC Blacklist Verifier\n"); | |
| if (!opts.rpc) { | |
| console.error("❌ --rpc <URL> is required.\n"); | |
| console.error(" node getSanctionedAddresses.js --rpc https://mainnet.infura.io/v3/YOUR_KEY"); | |
| process.exit(1); | |
| } | |
| const masked = opts.rpc.replace(/\/[a-zA-Z0-9]{20,}/, "/***"); | |
| console.log(`🔗 RPC: ${masked}`); | |
| console.log("─".repeat(60)); | |
| // ── 1. Fetch all Blacklisted + UnBlacklisted events ─────────────────────── | |
| console.log("\n[1/3] Fetching full USDC event history via eth_getLogs..."); | |
| const latestBlock = await getLatestBlock(opts.rpc); | |
| console.log(` Latest block: ${latestBlock.toLocaleString()}`); | |
| console.log(` Scanning from block ${opts.fromBlock.toLocaleString()} (USDC deploy)`); | |
| const [blacklistedLogs, unblacklistedLogs] = await Promise.all([ | |
| fetchLogsChunked( | |
| opts.rpc, | |
| opts.fromBlock, | |
| latestBlock, | |
| [TOPIC_BLACKLISTED_CORRECT] | |
| ), | |
| fetchLogsChunked( | |
| opts.rpc, | |
| opts.fromBlock, | |
| latestBlock, | |
| [TOPIC_UNBLACKLISTED_CORRECT] | |
| ), | |
| ]); | |
| console.log(` Blacklisted events : ${blacklistedLogs.length}`); | |
| console.log(` UnBlacklisted events : ${unblacklistedLogs.length}`); | |
| // ── 2. Build event-derived state ────────────────────────────────────────── | |
| // addr → { blacklistCount, unblacklistCount, lastBlacklistBlock, lastUnblacklistBlock } | |
| const eventState = new Map(); | |
| for (const log of blacklistedLogs) { | |
| const addr = decodeAddress(log.topics[1] || log.data); | |
| if (!eventState.has(addr)) eventState.set(addr, { blacklistCount: 0, unblacklistCount: 0, lastBlacklistBlock: 0, lastUnblacklistBlock: 0 }); | |
| const s = eventState.get(addr); | |
| s.blacklistCount++; | |
| s.lastBlacklistBlock = Math.max(s.lastBlacklistBlock, parseInt(log.blockNumber, 16)); | |
| } | |
| for (const log of unblacklistedLogs) { | |
| const addr = decodeAddress(log.topics[1] || log.data); | |
| if (!eventState.has(addr)) eventState.set(addr, { blacklistCount: 0, unblacklistCount: 0, lastBlacklistBlock: 0, lastUnblacklistBlock: 0 }); | |
| const s = eventState.get(addr); | |
| s.unblacklistCount++; | |
| s.lastUnblacklistBlock = Math.max(s.lastUnblacklistBlock, parseInt(log.blockNumber, 16)); | |
| } | |
| // Derive expected state: net blacklisted if last blacklist event is more recent than last unblacklist | |
| const addresses = [...eventState.keys()]; | |
| console.log(`\n Unique addresses seen in events: ${addresses.length}`); | |
| const expectedBlacklisted = addresses.filter((a) => { | |
| const s = eventState.get(a); | |
| return s.lastBlacklistBlock > s.lastUnblacklistBlock; | |
| }); | |
| const expectedUnblacklisted = addresses.filter((a) => { | |
| const s = eventState.get(a); | |
| return s.lastUnblacklistBlock >= s.lastBlacklistBlock && s.unblacklistCount > 0; | |
| }); | |
| console.log(` Expected still blacklisted (by events) : ${expectedBlacklisted.length}`); | |
| console.log(` Expected cleared (UnBlacklisted event) : ${expectedUnblacklisted.length}`); | |
| // ── 3. Verify on-chain ──────────────────────────────────────────────────── | |
| console.log(`\n[2/3] Verifying all ${addresses.length} addresses on-chain (concurrency=${opts.concurrency})...`); | |
| let done = 0; | |
| const verifiedResults = await mapConcurrent(addresses, opts.concurrency, async (addr) => { | |
| const onChain = await checkIsBlacklisted(opts.rpc, addr); | |
| done++; | |
| if (done % 25 === 0 || done === addresses.length) | |
| process.stdout.write(`\r Progress: ${done}/${addresses.length} `); | |
| const evState = eventState.get(addr); | |
| const evExpects = evState.lastBlacklistBlock > evState.lastUnblacklistBlock; | |
| const mismatch = onChain !== null && onChain !== evExpects; | |
| return { | |
| address: addr, | |
| onChainBlacklisted: onChain, | |
| eventExpected: evExpects, | |
| mismatch, | |
| blacklistCount: evState.blacklistCount, | |
| unblacklistCount: evState.unblacklistCount, | |
| lastBlacklistBlock: evState.lastBlacklistBlock, | |
| lastUnblacklistBlock: evState.lastUnblacklistBlock, | |
| }; | |
| }); | |
| process.stdout.write("\n"); | |
| // ── 4. OFAC cross-reference (optional) ─────────────────────────────────── | |
| let ofacSet = new Set(); | |
| if (opts.withOfac) { | |
| console.log("\n[3/3] Fetching OFAC sanctioned addresses..."); | |
| ofacSet = await fetchOfacAddresses(); | |
| console.log(` OFAC addresses loaded: ${ofacSet.size}`); | |
| } | |
| // Attach OFAC flag | |
| const results = verifiedResults.map((r) => ({ | |
| ...r, | |
| ofacSanctioned: ofacSet.size > 0 ? ofacSet.has(r.address) : null, | |
| })); | |
| // ── 5. Analysis ─────────────────────────────────────────────────────────── | |
| const stillBlacklisted = results.filter((r) => r.onChainBlacklisted === true); | |
| const confirmedCleared = results.filter((r) => r.onChainBlacklisted === false && r.unblacklistCount > 0); | |
| const mismatches = results.filter((r) => r.mismatch); | |
| const multiBlacklisted = results.filter((r) => r.blacklistCount > 1); | |
| const rpcErrors = results.filter((r) => r.onChainBlacklisted === null); | |
| console.log("\n─".repeat(60)); | |
| console.log("📊 Results"); | |
| console.log("─".repeat(60)); | |
| console.log(` Total addresses in event history : ${results.length}`); | |
| console.log(` ✅ Currently blacklisted on-chain : ${stillBlacklisted.length}`); | |
| console.log(` 🔓 Confirmed reversed (UnBlacklisted) : ${confirmedCleared.length}`); | |
| console.log(` ⚠️ Event/on-chain state mismatches : ${mismatches.length}`); | |
| console.log(` 🔁 Blacklisted more than once : ${multiBlacklisted.length}`); | |
| console.log(` ❓ RPC errors (could not verify) : ${rpcErrors.length}`); | |
| if (ofacSet.size > 0) { | |
| const ofacAndBlacklisted = results.filter((r) => r.ofacSanctioned && r.onChainBlacklisted); | |
| const ofacButCleared = results.filter((r) => r.ofacSanctioned && r.onChainBlacklisted === false); | |
| console.log(`\n OFAC sanctioned + still blacklisted : ${ofacAndBlacklisted.length}`); | |
| console.log(` OFAC sanctioned but CLEARED on-chain : ${ofacButCleared.length}`); | |
| } | |
| // Print reversed addresses | |
| if (confirmedCleared.length > 0) { | |
| console.log(`\n🔓 Reversed addresses (had Blacklisted event, now clear):`); | |
| confirmedCleared.slice(0, 20).forEach((r) => { | |
| console.log( | |
| ` ${r.address} ` + | |
| `[blacklisted ${r.blacklistCount}x, unblacklisted ${r.unblacklistCount}x]` + | |
| (r.ofacSanctioned ? " ⚠️ OFAC" : "") | |
| ); | |
| }); | |
| if (confirmedCleared.length > 20) | |
| console.log(` ... and ${confirmedCleared.length - 20} more`); | |
| } | |
| if (mismatches.length > 0) { | |
| console.log(`\n⚠️ State mismatches (event says X but chain says Y):`); | |
| mismatches.forEach((r) => { | |
| console.log( | |
| ` ${r.address} event=${r.eventExpected ? "blacklisted" : "cleared"} ` + | |
| `chain=${r.onChainBlacklisted ? "blacklisted" : "cleared"}` | |
| ); | |
| }); | |
| } | |
| // ── Single address check ────────────────────────────────────────────────── | |
| if (opts.check) { | |
| console.log("\n─".repeat(60)); | |
| const hit = results.find((r) => r.address === opts.check); | |
| if (hit) { | |
| console.log(`\n🔎 ${opts.check}`); | |
| console.log(` On-chain blacklisted : ${hit.onChainBlacklisted}`); | |
| console.log(` Blacklist events : ${hit.blacklistCount}`); | |
| console.log(` UnBlacklist events : ${hit.unblacklistCount}`); | |
| console.log(` Last blacklist block : ${hit.lastBlacklistBlock}`); | |
| console.log(` Last unblacklist blk : ${hit.lastUnblacklistBlock || "n/a"}`); | |
| if (hit.ofacSanctioned !== null) | |
| console.log(` OFAC sanctioned : ${hit.ofacSanctioned}`); | |
| } else { | |
| console.log(`\n Address not found in event history. Running direct on-chain check...`); | |
| const live = await checkIsBlacklisted(opts.rpc, opts.check); | |
| console.log(` On-chain isBlacklisted: ${live}`); | |
| } | |
| return; | |
| } | |
| // ── Print currently blacklisted ─────────────────────────────────────────── | |
| if (opts.printBlacklisted) { | |
| console.log(`\n${"─".repeat(60)}`); | |
| console.log(`✅ Currently blacklisted addresses (${stillBlacklisted.length} total):\n`); | |
| stillBlacklisted.forEach((r) => { | |
| const ofac = r.ofacSanctioned ? " [OFAC]" : ""; | |
| const flipped = r.unblacklistCount > 0 ? " [re-blacklisted]" : ""; | |
| console.log(` ${r.address}${ofac}${flipped}`); | |
| }); | |
| console.log(); | |
| return; | |
| } | |
| // ── Output ──────────────────────────────────────────────────────────────── | |
| if (opts.output === "json") { | |
| const outPath = path.resolve("sanctioned.json"); | |
| fs.writeFileSync(outPath, JSON.stringify(results, null, 2)); | |
| console.log(`\n💾 Saved → ${outPath}`); | |
| } else if (opts.output === "csv") { | |
| const outPath = path.resolve("sanctioned.csv"); | |
| const header = "address,onChainBlacklisted,eventExpected,mismatch,blacklistCount,unblacklistCount,lastBlacklistBlock,lastUnblacklistBlock,ofacSanctioned"; | |
| const rows = results.map((r) => | |
| [ | |
| r.address, | |
| r.onChainBlacklisted, | |
| r.eventExpected, | |
| r.mismatch, | |
| r.blacklistCount, | |
| r.unblacklistCount, | |
| r.lastBlacklistBlock, | |
| r.lastUnblacklistBlock || "", | |
| r.ofacSanctioned ?? "", | |
| ].join(",") | |
| ); | |
| fs.writeFileSync(outPath, [header, ...rows].join("\n")); | |
| console.log(`\n💾 Saved → ${outPath}`); | |
| } else { | |
| console.log("\n💡 Commands:"); | |
| console.log(" node getSanctionedAddresses.js --rpc <URL> --print-blacklisted"); | |
| console.log(" node getSanctionedAddresses.js --rpc <URL> --output json"); | |
| console.log(" node getSanctionedAddresses.js --rpc <URL> --output csv"); | |
| console.log(" node getSanctionedAddresses.js --rpc <URL> --with-ofac --output json"); | |
| console.log(" node getSanctionedAddresses.js --rpc <URL> --check 0xde787f...\n"); | |
| } | |
| } | |
| main().catch((err) => { | |
| console.error("\nFatal:", err.message); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment