Monorepo for Aesthetic.Computer aesthetic.computer
at main 330 lines 9.5 kB view raw
1#!/usr/bin/env node 2 3import fs from 'node:fs'; 4import path from 'node:path'; 5import process from 'node:process'; 6import { fileURLToPath } from 'node:url'; 7 8const __filename = fileURLToPath(import.meta.url); 9const __dirname = path.dirname(__filename); 10const ROOT_DIR = path.resolve(__dirname, '..'); 11const REPORTS_DIR = path.join(ROOT_DIR, 'reports'); 12 13const ADMIN_ENTRYPOINTS = new Set([ 14 'set_administrator', 15 'set_contract_metadata', 16 'lock_contract_metadata', 17 'set_keep_fee', 18 'set_treasury', 19 'set_royalty_split', 20 'pause', 21 'unpause', 22 'withdraw_fees', 23]); 24 25function getArg(flag, fallback = null) { 26 const prefix = `${flag}=`; 27 for (const arg of process.argv.slice(2)) { 28 if (arg.startsWith(prefix)) return arg.slice(prefix.length); 29 } 30 return fallback; 31} 32 33function hasFlag(flag) { 34 return process.argv.slice(2).includes(flag); 35} 36 37function num(value, fallback = 0) { 38 const parsed = Number(value); 39 return Number.isFinite(parsed) ? parsed : fallback; 40} 41 42function pct(part, total) { 43 if (!total) return 0; 44 return (part / total) * 100; 45} 46 47function tzktBase(network) { 48 return network === 'mainnet' ? 'https://api.tzkt.io' : `https://api.${network}.tzkt.io`; 49} 50 51function loadDefaultContract(network) { 52 const candidate = path.join(__dirname, `contract-address-${network}.txt`); 53 if (fs.existsSync(candidate)) { 54 return fs.readFileSync(candidate, 'utf8').trim(); 55 } 56 const legacy = path.join(__dirname, 'contract-address.txt'); 57 if (fs.existsSync(legacy)) { 58 return fs.readFileSync(legacy, 'utf8').trim(); 59 } 60 return null; 61} 62 63async function fetchJson(url) { 64 const response = await fetch(url); 65 if (!response.ok) { 66 throw new Error(`Request failed ${response.status}: ${url}`); 67 } 68 return response.json(); 69} 70 71function sortByCountDesc(a, b) { 72 return b.count - a.count; 73} 74 75function buildAlerts({ 76 storage, 77 contractMeta, 78 topHolders, 79 recentAdminOps, 80 expectedAdmin, 81 expectedCodeHash, 82 expectedTypeHash, 83}) { 84 const alerts = []; 85 86 if (storage?.paused === true) { 87 alerts.push({ 88 severity: 'warning', 89 message: 'Contract is paused (new keeps + metadata edits are disabled).', 90 }); 91 } 92 93 if (storage?.contract_metadata_locked !== true) { 94 alerts.push({ 95 severity: 'warning', 96 message: 'Contract metadata is not locked.', 97 }); 98 } 99 100 if (expectedAdmin && storage?.administrator !== expectedAdmin) { 101 alerts.push({ 102 severity: 'critical', 103 message: `Administrator mismatch: expected ${expectedAdmin}, observed ${storage?.administrator || 'unset'}.`, 104 }); 105 } 106 107 if (Number.isFinite(expectedCodeHash) && num(contractMeta?.codeHash, -1) !== expectedCodeHash) { 108 alerts.push({ 109 severity: 'critical', 110 message: `codeHash mismatch: expected ${expectedCodeHash}, observed ${contractMeta?.codeHash}.`, 111 }); 112 } 113 114 if (Number.isFinite(expectedTypeHash) && num(contractMeta?.typeHash, -1) !== expectedTypeHash) { 115 alerts.push({ 116 severity: 'critical', 117 message: `typeHash mismatch: expected ${expectedTypeHash}, observed ${contractMeta?.typeHash}.`, 118 }); 119 } 120 121 if (topHolders.length > 0) { 122 const top = topHolders[0]; 123 if (top.sharePct >= 50) { 124 alerts.push({ 125 severity: 'warning', 126 message: `Holder concentration is high: top holder controls ${top.sharePct.toFixed(2)}%.`, 127 }); 128 } 129 } 130 131 if (recentAdminOps.length > 0) { 132 for (const op of recentAdminOps.slice(0, 5)) { 133 alerts.push({ 134 severity: expectedAdmin && op.sender !== expectedAdmin ? 'critical' : 'warning', 135 message: `Recent admin entrypoint call: ${op.entrypoint} by ${op.sender} at ${op.timestamp}.`, 136 }); 137 } 138 } 139 140 return alerts; 141} 142 143function toMarkdown({ 144 generatedAt, 145 network, 146 contract, 147 contractMeta, 148 storage, 149 mintedCount, 150 ownersCount, 151 topHolders, 152 recentOps, 153 recentAdminOps, 154 alerts, 155}) { 156 const alertLines = alerts.length === 0 157 ? ['- none'] 158 : alerts.map((alert) => `- [${alert.severity}] ${alert.message}`); 159 160 const topHolderLines = topHolders.length === 0 161 ? ['- none'] 162 : topHolders.slice(0, 10).map((holder) => { 163 const alias = holder.alias ? ` (${holder.alias})` : ''; 164 return `- ${holder.owner}${alias}: ${holder.count} tokens (${holder.sharePct.toFixed(2)}%)`; 165 }); 166 167 const recentOpLines = recentOps.length === 0 168 ? ['- none'] 169 : recentOps.slice(0, 12).map((op) => `- ${op.timestamp} · ${op.entrypoint} · ${op.sender} · ${op.hash}`); 170 171 const adminOpLines = recentAdminOps.length === 0 172 ? ['- none'] 173 : recentAdminOps.map((op) => `- ${op.timestamp} · ${op.entrypoint} · ${op.sender} · ${op.hash}`); 174 175 return [ 176 `# Keeps Contract Audit (${network})`, 177 '', 178 `Generated: ${generatedAt}`, 179 '', 180 '## Snapshot', 181 `- Contract: ${contract}`, 182 `- codeHash: ${contractMeta?.codeHash ?? 'n/a'}`, 183 `- typeHash: ${contractMeta?.typeHash ?? 'n/a'}`, 184 `- Admin: ${storage?.administrator ?? 'n/a'}`, 185 `- Treasury: ${storage?.treasury_address ?? 'n/a'}`, 186 `- Keep fee: ${(num(storage?.keep_fee, 0) / 1_000_000).toFixed(6)} XTZ`, 187 `- Paused: ${storage?.paused === true ? 'true' : 'false'}`, 188 `- Contract metadata locked: ${storage?.contract_metadata_locked === true ? 'true' : 'false'}`, 189 `- Royalty split: artist ${storage?.artist_royalty_bps ?? 'n/a'} bps, platform ${storage?.platform_royalty_bps ?? 'n/a'} bps`, 190 `- Minted tokens (next_token_id): ${mintedCount}`, 191 `- Current owners: ${ownersCount}`, 192 '', 193 '## Alerts', 194 ...alertLines, 195 '', 196 '## Holder Concentration', 197 ...topHolderLines, 198 '', 199 '## Recent Contract Operations', 200 ...recentOpLines, 201 '', 202 '## Recent Admin Operations', 203 ...adminOpLines, 204 '', 205 '## Notes', 206 '- This report is chain-derived (TzKT).', 207 '- Use with the v11 moat checks in keep-mint for runtime enforcement.', 208 '', 209 ].join('\n'); 210} 211 212async function main() { 213 const network = getArg('--network', 'mainnet'); 214 const contract = getArg('--contract', loadDefaultContract(network)); 215 const limit = Math.max(10, Math.min(500, num(getArg('--limit', '120'), 120))); 216 const expectedAdmin = getArg('--expected-admin', null); 217 const expectedCodeHashRaw = getArg('--expected-code-hash', null); 218 const expectedTypeHashRaw = getArg('--expected-type-hash', null); 219 const expectedCodeHash = expectedCodeHashRaw === null ? NaN : num(expectedCodeHashRaw, NaN); 220 const expectedTypeHash = expectedTypeHashRaw === null ? NaN : num(expectedTypeHashRaw, NaN); 221 const noWrite = hasFlag('--no-write'); 222 const outArg = getArg('--out', null); 223 224 if (!contract) { 225 throw new Error('No contract address found. Use --contract=KT1... or set tezos/contract-address-<network>.txt'); 226 } 227 228 const apiBase = tzktBase(network); 229 const [ 230 contractMeta, 231 storage, 232 balances, 233 recentOpsRaw, 234 ] = await Promise.all([ 235 fetchJson(`${apiBase}/v1/contracts/${contract}`), 236 fetchJson(`${apiBase}/v1/contracts/${contract}/storage`), 237 fetchJson(`${apiBase}/v1/tokens/balances?token.contract=${contract}&balance.gt=0&limit=10000`), 238 fetchJson(`${apiBase}/v1/operations/transactions?target=${contract}&status=applied&limit=${limit}&sort.desc=level`), 239 ]); 240 241 const ownerMap = new Map(); 242 for (const row of balances) { 243 const owner = row?.account?.address; 244 if (!owner) continue; 245 const entry = ownerMap.get(owner) || { 246 owner, 247 alias: row?.account?.alias || null, 248 count: 0, 249 tokenIds: [], 250 }; 251 entry.count += 1; 252 entry.tokenIds.push(num(row?.token?.tokenId, -1)); 253 ownerMap.set(owner, entry); 254 } 255 256 const owners = [...ownerMap.values()].sort(sortByCountDesc); 257 const mintedCount = num(storage?.next_token_id, 0); 258 const ownersCount = owners.length; 259 const topHolders = owners.map((holder) => ({ 260 ...holder, 261 tokenIds: holder.tokenIds.sort((a, b) => a - b), 262 sharePct: pct(holder.count, mintedCount), 263 })); 264 265 const recentOps = recentOpsRaw.map((op) => ({ 266 level: num(op?.level, 0), 267 timestamp: op?.timestamp || 'n/a', 268 entrypoint: op?.parameter?.entrypoint || 'unknown', 269 sender: op?.sender?.address || 'unknown', 270 hash: op?.hash || 'n/a', 271 })); 272 273 const recentAdminOps = recentOps.filter((op) => ADMIN_ENTRYPOINTS.has(op.entrypoint)); 274 275 const alerts = buildAlerts({ 276 storage, 277 contractMeta, 278 topHolders, 279 recentAdminOps, 280 expectedAdmin, 281 expectedCodeHash, 282 expectedTypeHash, 283 }); 284 285 const generatedAt = new Date().toISOString(); 286 const markdown = toMarkdown({ 287 generatedAt, 288 network, 289 contract, 290 contractMeta, 291 storage, 292 mintedCount, 293 ownersCount, 294 topHolders, 295 recentOps, 296 recentAdminOps, 297 alerts, 298 }); 299 300 const dateLabel = generatedAt.slice(0, 10); 301 const outPath = outArg 302 ? path.resolve(process.cwd(), outArg) 303 : path.join(REPORTS_DIR, `${dateLabel}-keeps-audit-${network}.md`); 304 305 console.log(`Network: ${network}`); 306 console.log(`Contract: ${contract}`); 307 console.log(`Minted: ${mintedCount}`); 308 console.log(`Owners: ${ownersCount}`); 309 console.log(`Alerts: ${alerts.length}`); 310 311 if (!noWrite) { 312 fs.mkdirSync(path.dirname(outPath), { recursive: true }); 313 fs.writeFileSync(outPath, markdown, 'utf8'); 314 console.log(`Report written: ${outPath}`); 315 } 316 317 if (hasFlag('--print')) { 318 console.log('\n' + markdown); 319 } 320 321 if (alerts.some((alert) => alert.severity === 'critical')) { 322 process.exitCode = 2; 323 } 324} 325 326main().catch((error) => { 327 console.error(`Audit failed: ${error.message}`); 328 process.exit(1); 329}); 330