#!/usr/bin/env bash # Audit deployer balances plus deployer access posture for Chain 138 c* and public-chain cW* tokens. # # Exports: # - balances JSON # - balances CSV # - access JSON # - access CSV # # Usage: # bash scripts/verify/audit-deployer-token-access.sh # bash scripts/verify/audit-deployer-token-access.sh --output-dir reports/deployer-token-audit set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" export PROJECT_ROOT OUTPUT_DIR="${PROJECT_ROOT}/reports/deployer-token-audit" while [[ $# -gt 0 ]]; do case "$1" in --output-dir) [[ $# -ge 2 ]] || { echo "Missing value for --output-dir" >&2; exit 2; } OUTPUT_DIR="$2" shift 2 ;; *) echo "Unknown argument: $1" >&2 exit 2 ;; esac done source "${PROJECT_ROOT}/scripts/lib/load-project-env.sh" command -v cast >/dev/null 2>&1 || { echo "Missing required command: cast" >&2; exit 1; } command -v node >/dev/null 2>&1 || { echo "Missing required command: node" >&2; exit 1; } if [[ -n "${DEPLOYER_ADDRESS:-}" ]]; then AUDIT_DEPLOYER_ADDRESS="$DEPLOYER_ADDRESS" else [[ -n "${PRIVATE_KEY:-}" ]] || { echo "Missing PRIVATE_KEY or DEPLOYER_ADDRESS in environment" >&2 exit 1 } AUDIT_DEPLOYER_ADDRESS="$(cast wallet address "$PRIVATE_KEY")" fi [[ -n "$AUDIT_DEPLOYER_ADDRESS" ]] || { echo "Failed to derive deployer address" >&2 exit 1 } mkdir -p "$OUTPUT_DIR" export OUTPUT_DIR export AUDIT_DEPLOYER_ADDRESS export AUDIT_TIMESTAMP_UTC="$(date -u +%Y%m%dT%H%M%SZ)" node <<'NODE' const fs = require('fs'); const path = require('path'); const util = require('util'); const { execFile } = require('child_process'); const execFileAsync = util.promisify(execFile); const projectRoot = process.env.PROJECT_ROOT; const outputDir = path.resolve(process.env.OUTPUT_DIR); const generatedAt = process.env.AUDIT_TIMESTAMP_UTC; const deployerAddress = process.env.AUDIT_DEPLOYER_ADDRESS; const roleHashes = { defaultAdmin: '0x' + '0'.repeat(64), minter: null, burner: null, pauser: null, bridge: null, governance: null, jurisdictionAdmin: null, regulator: null, supervisor: null, emergencyAdmin: null, supplyAdmin: null, metadataAdmin: null, }; const publicChainMeta = { '1': { chainKey: 'MAINNET', walletEnv: 'DEST_FUND_WALLET_ETHEREUM', rpcEnvCandidates: ['ETH_MAINNET_RPC_URL', 'ETHEREUM_MAINNET_RPC'], fallbackRpc: 'https://eth.llamarpc.com', fallbackLabel: 'fallback:eth.llamarpc.com', spenderEnv: 'CW_BRIDGE_MAINNET', }, '10': { chainKey: 'OPTIMISM', walletEnv: 'DEST_FUND_WALLET_OPTIMISM', rpcEnvCandidates: ['OPTIMISM_MAINNET_RPC', 'OPTIMISM_RPC_URL'], fallbackRpc: 'https://mainnet.optimism.io', fallbackLabel: 'fallback:mainnet.optimism.io', spenderEnv: 'CW_BRIDGE_OPTIMISM', }, '25': { chainKey: 'CRONOS', walletEnv: 'DEST_FUND_WALLET_CRONOS', rpcEnvCandidates: ['CRONOS_CW_VERIFY_RPC_URL', 'CRONOS_RPC_URL', 'CRONOS_RPC'], fallbackRpc: 'https://evm.cronos.org', fallbackLabel: 'fallback:evm.cronos.org', spenderEnv: 'CW_BRIDGE_CRONOS', }, '56': { chainKey: 'BSC', walletEnv: 'DEST_FUND_WALLET_BSC', rpcEnvCandidates: ['BSC_RPC_URL', 'BSC_MAINNET_RPC'], fallbackRpc: 'https://bsc-dataseed.binance.org', fallbackLabel: 'fallback:bsc-dataseed.binance.org', spenderEnv: 'CW_BRIDGE_BSC', }, '100': { chainKey: 'GNOSIS', walletEnv: 'DEST_FUND_WALLET_GNOSIS', rpcEnvCandidates: ['GNOSIS_MAINNET_RPC', 'GNOSIS_RPC_URL', 'GNOSIS_RPC'], fallbackRpc: 'https://rpc.gnosischain.com', fallbackLabel: 'fallback:rpc.gnosischain.com', spenderEnv: 'CW_BRIDGE_GNOSIS', }, '137': { chainKey: 'POLYGON', walletEnv: 'DEST_FUND_WALLET_POLYGON', rpcEnvCandidates: ['POLYGON_MAINNET_RPC', 'POLYGON_RPC_URL'], fallbackRpc: 'https://polygon-rpc.com', fallbackLabel: 'fallback:polygon-rpc.com', spenderEnv: 'CW_BRIDGE_POLYGON', }, '42220': { chainKey: 'CELO', walletEnv: 'DEST_FUND_WALLET_CELO', rpcEnvCandidates: ['CELO_RPC', 'CELO_MAINNET_RPC'], fallbackRpc: 'https://forno.celo.org', fallbackLabel: 'fallback:forno.celo.org', spenderEnv: 'CW_BRIDGE_CELO', }, '43114': { chainKey: 'AVALANCHE', walletEnv: 'DEST_FUND_WALLET_AVALANCHE', rpcEnvCandidates: ['AVALANCHE_RPC_URL', 'AVALANCHE_MAINNET_RPC', 'AVALANCHE_RPC'], fallbackRpc: 'https://api.avax.network/ext/bc/C/rpc', fallbackLabel: 'fallback:api.avax.network/ext/bc/C/rpc', spenderEnv: 'CW_BRIDGE_AVALANCHE', }, '8453': { chainKey: 'BASE', walletEnv: 'DEST_FUND_WALLET_BASE', rpcEnvCandidates: ['BASE_MAINNET_RPC', 'BASE_RPC_URL'], fallbackRpc: 'https://mainnet.base.org', fallbackLabel: 'fallback:mainnet.base.org', spenderEnv: 'CW_BRIDGE_BASE', }, '42161': { chainKey: 'ARBITRUM', walletEnv: 'DEST_FUND_WALLET_ARBITRUM', rpcEnvCandidates: ['ARBITRUM_MAINNET_RPC', 'ARBITRUM_RPC_URL'], fallbackRpc: 'https://arb1.arbitrum.io/rpc', fallbackLabel: 'fallback:arb1.arbitrum.io/rpc', spenderEnv: 'CW_BRIDGE_ARBITRUM', }, }; function quoteCsv(value) { const stringValue = value == null ? '' : String(value); return `"${stringValue.replace(/"/g, '""')}"`; } function formatUnits(rawValue, decimals) { if (rawValue == null) return null; const raw = BigInt(rawValue); const base = 10n ** BigInt(decimals); const whole = raw / base; const fraction = raw % base; if (fraction === 0n) return whole.toString(); const padded = fraction.toString().padStart(decimals, '0').replace(/0+$/, ''); return `${whole.toString()}.${padded}`; } function normalizeAddress(value) { if (!value) return null; const trimmed = String(value).trim(); if (!trimmed) return null; return trimmed; } function stripQuotes(value) { if (value == null) return null; const trimmed = String(value).trim(); if (trimmed.startsWith('"') && trimmed.endsWith('"')) { return trimmed.slice(1, -1); } return trimmed; } function parseBigIntOutput(value) { if (value == null) return null; const trimmed = String(value).trim(); if (!trimmed) return null; const token = trimmed.split(/\s+/)[0]; return BigInt(token); } async function runCast(args, { allowFailure = false } = {}) { try { const { stdout } = await execFileAsync('cast', args, { encoding: 'utf8', timeout: 20000, maxBuffer: 1024 * 1024, }); return stdout.trim(); } catch (error) { if (allowFailure) { return null; } const stderr = error.stderr ? `\n${String(error.stderr).trim()}` : ''; throw new Error(`cast ${args.join(' ')} failed${stderr}`); } } async function buildRoleHashes() { const keys = Object.keys(roleHashes).filter((key) => key !== 'defaultAdmin'); await Promise.all(keys.map(async (key) => { const labelMap = { minter: 'MINTER_ROLE', burner: 'BURNER_ROLE', pauser: 'PAUSER_ROLE', bridge: 'BRIDGE_ROLE', governance: 'GOVERNANCE_ROLE', jurisdictionAdmin: 'JURISDICTION_ADMIN_ROLE', regulator: 'REGULATOR_ROLE', supervisor: 'SUPERVISOR_ROLE', emergencyAdmin: 'EMERGENCY_ADMIN_ROLE', supplyAdmin: 'SUPPLY_ADMIN_ROLE', metadataAdmin: 'METADATA_ADMIN_ROLE', }; roleHashes[key] = await runCast(['keccak', labelMap[key]]); })); } function resolvePublicChain(chainId, chainName) { const meta = publicChainMeta[chainId]; if (!meta) { return { chainId: Number(chainId), chainName, walletAddress: deployerAddress, walletSource: 'AUDIT_DEPLOYER_ADDRESS', rpcUrl: null, rpcSource: 'missing', spenderAddress: null, spenderLabel: null, }; } let rpcUrl = null; let rpcSource = 'missing'; for (const key of meta.rpcEnvCandidates) { if (process.env[key]) { rpcUrl = process.env[key]; rpcSource = key; break; } } if (!rpcUrl && meta.fallbackRpc) { rpcUrl = meta.fallbackRpc; rpcSource = meta.fallbackLabel; } const walletAddress = normalizeAddress(process.env[meta.walletEnv]) || deployerAddress; const walletSource = process.env[meta.walletEnv] ? meta.walletEnv : 'AUDIT_DEPLOYER_ADDRESS'; const spenderAddress = normalizeAddress(process.env[meta.spenderEnv]); return { chainId: Number(chainId), chainKey: meta.chainKey, chainName, walletAddress, walletSource, rpcUrl, rpcSource, spenderAddress, spenderLabel: meta.spenderEnv, }; } async function callUint(contract, rpcUrl, signature, extraArgs = []) { const output = await runCast(['call', contract, signature, ...extraArgs, '--rpc-url', rpcUrl], { allowFailure: true }); if (output == null || output === '') return null; return parseBigIntOutput(output); } async function callBool(contract, rpcUrl, signature, extraArgs = []) { const output = await runCast(['call', contract, signature, ...extraArgs, '--rpc-url', rpcUrl], { allowFailure: true }); if (output == null || output === '') return null; if (output === 'true') return true; if (output === 'false') return false; return null; } async function callAddress(contract, rpcUrl, signature, extraArgs = []) { const output = await runCast(['call', contract, signature, ...extraArgs, '--rpc-url', rpcUrl], { allowFailure: true }); if (output == null || output === '') return null; const address = normalizeAddress(stripQuotes(output)); return address && /^0x[a-fA-F0-9]{40}$/.test(address) ? address : null; } async function callString(contract, rpcUrl, signature, extraArgs = []) { const output = await runCast(['call', contract, signature, ...extraArgs, '--rpc-url', rpcUrl], { allowFailure: true }); if (output == null || output === '') return null; return stripQuotes(output); } async function mapLimit(items, limit, iterator) { const results = new Array(items.length); let nextIndex = 0; async function worker() { for (;;) { const current = nextIndex; nextIndex += 1; if (current >= items.length) return; results[current] = await iterator(items[current], current); } } const workerCount = Math.min(limit, items.length || 1); await Promise.all(Array.from({ length: workerCount }, () => worker())); return results; } function roleColumns(row) { return { default_admin: row.roles.defaultAdmin, minter: row.roles.minter, burner: row.roles.burner, pauser: row.roles.pauser, bridge: row.roles.bridge, governance: row.roles.governance, jurisdiction_admin: row.roles.jurisdictionAdmin, regulator: row.roles.regulator, supervisor: row.roles.supervisor, emergency_admin: row.roles.emergencyAdmin, supply_admin: row.roles.supplyAdmin, metadata_admin: row.roles.metadataAdmin, }; } async function auditToken(token) { const rpcUrl = token.rpcUrl; const balanceRowBase = { category: token.category, chain_id: token.chainId, chain_name: token.chainName, token_key: token.tokenKey, token_address: token.tokenAddress, wallet_address: token.walletAddress, wallet_source: token.walletSource, rpc_source: token.rpcSource, }; const accessRowBase = { category: token.category, chain_id: token.chainId, chain_name: token.chainName, token_key: token.tokenKey, token_address: token.tokenAddress, wallet_address: token.walletAddress, wallet_source: token.walletSource, rpc_source: token.rpcSource, spender_label: token.spenderLabel, spender_address: token.spenderAddress, }; if (!rpcUrl) { return { balance: { ...balanceRowBase, decimals: null, balance_raw: null, balance_formatted: null, query_status: 'missing_rpc', }, access: { ...accessRowBase, owner: null, owner_matches_wallet: null, allowance_raw: null, allowance_formatted: null, query_status: 'missing_rpc', roles: Object.fromEntries(Object.keys(roleHashes).map((key) => [key, null])), }, }; } const decimalsPromise = callUint(token.tokenAddress, rpcUrl, 'decimals()(uint8)'); const balancePromise = callUint(token.tokenAddress, rpcUrl, 'balanceOf(address)(uint256)', [token.walletAddress]); const ownerPromise = callAddress(token.tokenAddress, rpcUrl, 'owner()(address)'); const symbolPromise = callString(token.tokenAddress, rpcUrl, 'symbol()(string)'); const allowancePromise = token.spenderAddress ? callUint(token.tokenAddress, rpcUrl, 'allowance(address,address)(uint256)', [token.walletAddress, token.spenderAddress]) : Promise.resolve(null); const roleNames = Object.keys(roleHashes); const rolePairs = await mapLimit(roleNames, 4, async (roleName) => { const roleValue = await callBool( token.tokenAddress, rpcUrl, 'hasRole(bytes32,address)(bool)', [roleHashes[roleName], token.walletAddress], ); return [roleName, roleValue]; }); const roles = Object.fromEntries(rolePairs); const [decimalsRaw, balanceRaw, owner, symbol, allowanceRaw] = await Promise.all([ decimalsPromise, balancePromise, ownerPromise, symbolPromise, allowancePromise, ]); const decimals = decimalsRaw == null ? null : Number(decimalsRaw); const effectiveDecimals = decimals == null ? 18 : decimals; const balanceFormatted = balanceRaw == null ? null : formatUnits(balanceRaw, effectiveDecimals); const allowanceFormatted = allowanceRaw == null ? null : formatUnits(allowanceRaw, effectiveDecimals); const ownerMatchesWallet = owner == null ? null : owner.toLowerCase() === token.walletAddress.toLowerCase(); return { balance: { ...balanceRowBase, token_symbol: symbol || token.tokenKey, decimals, balance_raw: balanceRaw == null ? null : balanceRaw.toString(), balance_formatted: balanceFormatted, query_status: balanceRaw == null ? 'query_failed' : 'ok', }, access: { ...accessRowBase, token_symbol: symbol || token.tokenKey, owner, owner_matches_wallet: ownerMatchesWallet, allowance_raw: allowanceRaw == null ? null : allowanceRaw.toString(), allowance_formatted: allowanceFormatted, query_status: 'ok', roles, }, }; } function balanceCsv(rows) { const header = [ 'category', 'chain_id', 'chain_name', 'token_key', 'token_symbol', 'token_address', 'wallet_address', 'wallet_source', 'rpc_source', 'decimals', 'balance_raw', 'balance_formatted', 'query_status', ]; const lines = [header.join(',')]; for (const row of rows) { lines.push([ row.category, row.chain_id, row.chain_name, row.token_key, row.token_symbol, row.token_address, row.wallet_address, row.wallet_source, row.rpc_source, row.decimals, row.balance_raw, row.balance_formatted, row.query_status, ].map(quoteCsv).join(',')); } return `${lines.join('\n')}\n`; } function accessCsv(rows) { const header = [ 'category', 'chain_id', 'chain_name', 'token_key', 'token_symbol', 'token_address', 'wallet_address', 'wallet_source', 'rpc_source', 'owner', 'owner_matches_wallet', 'spender_label', 'spender_address', 'allowance_raw', 'allowance_formatted', 'default_admin', 'minter', 'burner', 'pauser', 'bridge', 'governance', 'jurisdiction_admin', 'regulator', 'supervisor', 'emergency_admin', 'supply_admin', 'metadata_admin', 'query_status', ]; const lines = [header.join(',')]; for (const row of rows) { const roles = roleColumns(row); lines.push([ row.category, row.chain_id, row.chain_name, row.token_key, row.token_symbol, row.token_address, row.wallet_address, row.wallet_source, row.rpc_source, row.owner, row.owner_matches_wallet, row.spender_label, row.spender_address, row.allowance_raw, row.allowance_formatted, roles.default_admin, roles.minter, roles.burner, roles.pauser, roles.bridge, roles.governance, roles.jurisdiction_admin, roles.regulator, roles.supervisor, roles.emergency_admin, roles.supply_admin, roles.metadata_admin, row.query_status, ].map(quoteCsv).join(',')); } return `${lines.join('\n')}\n`; } async function main() { await buildRoleHashes(); const chain138Registry = JSON.parse( fs.readFileSync(path.join(projectRoot, 'config/smart-contracts-master.json'), 'utf8'), ); const deploymentStatus = JSON.parse( fs.readFileSync(path.join(projectRoot, 'cross-chain-pmm-lps/config/deployment-status.json'), 'utf8'), ); const chain138Contracts = (((chain138Registry || {}).chains || {})['138'] || {}).contracts || {}; const cStarTokens = Object.entries(chain138Contracts) .filter(([name, address]) => name.startsWith('c') && !name.includes('_Pool_') && typeof address === 'string') .map(([tokenKey, tokenAddress]) => ({ category: 'cstar', chainId: 138, chainName: 'Chain 138', tokenKey, tokenAddress, walletAddress: deployerAddress, walletSource: 'AUDIT_DEPLOYER_ADDRESS', rpcUrl: process.env.RPC_URL_138 || process.env.CHAIN138_RPC_URL || process.env.RPC_URL || null, rpcSource: process.env.RPC_URL_138 ? 'RPC_URL_138' : process.env.CHAIN138_RPC_URL ? 'CHAIN138_RPC_URL' : process.env.RPC_URL ? 'RPC_URL' : 'missing', spenderAddress: null, spenderLabel: null, })); const cWTokens = []; for (const [chainId, chainInfo] of Object.entries(deploymentStatus.chains || {})) { const cwTokens = chainInfo.cwTokens || {}; const resolved = resolvePublicChain(chainId, chainInfo.name); for (const [tokenKey, tokenAddress] of Object.entries(cwTokens)) { cWTokens.push({ category: 'cw', chainId: Number(chainId), chainName: chainInfo.name, tokenKey, tokenAddress, walletAddress: resolved.walletAddress, walletSource: resolved.walletSource, rpcUrl: resolved.rpcUrl, rpcSource: resolved.rpcSource, spenderAddress: resolved.spenderAddress, spenderLabel: resolved.spenderLabel, }); } } const audited = await mapLimit([...cStarTokens, ...cWTokens], 6, auditToken); const balanceRows = audited.map((row) => row.balance); const accessRows = audited.map((row) => row.access); balanceRows.sort((a, b) => (a.chain_id - b.chain_id) || a.token_key.localeCompare(b.token_key)); accessRows.sort((a, b) => (a.chain_id - b.chain_id) || a.token_key.localeCompare(b.token_key)); const balancesJson = { generatedAt, deployerAddress, summary: { tokensChecked: balanceRows.length, cStarTokensChecked: balanceRows.filter((row) => row.category === 'cstar').length, cWTokensChecked: balanceRows.filter((row) => row.category === 'cw').length, nonZeroBalances: balanceRows.filter((row) => row.balance_raw != null && row.balance_raw !== '0').length, queryFailures: balanceRows.filter((row) => row.query_status !== 'ok').length, }, balances: balanceRows, }; const accessJson = { generatedAt, deployerAddress, summary: { tokensChecked: accessRows.length, tokensWithOwnerFunction: accessRows.filter((row) => row.owner != null).length, tokensWithBridgeAllowanceChecks: accessRows.filter((row) => row.spender_address != null).length, nonZeroAllowances: accessRows.filter((row) => row.allowance_raw != null && row.allowance_raw !== '0').length, deployerDefaultAdminCount: accessRows.filter((row) => row.roles.defaultAdmin === true).length, deployerMinterCount: accessRows.filter((row) => row.roles.minter === true).length, deployerBurnerCount: accessRows.filter((row) => row.roles.burner === true).length, }, access: accessRows, }; const balanceJsonPath = path.join(outputDir, `deployer-token-balances-${generatedAt}.json`); const balanceCsvPath = path.join(outputDir, `deployer-token-balances-${generatedAt}.csv`); const accessJsonPath = path.join(outputDir, `deployer-token-access-${generatedAt}.json`); const accessCsvPath = path.join(outputDir, `deployer-token-access-${generatedAt}.csv`); fs.writeFileSync(balanceJsonPath, JSON.stringify(balancesJson, null, 2)); fs.writeFileSync(balanceCsvPath, balanceCsv(balanceRows)); fs.writeFileSync(accessJsonPath, JSON.stringify(accessJson, null, 2)); fs.writeFileSync(accessCsvPath, accessCsv(accessRows)); console.log(JSON.stringify({ generatedAt, deployerAddress, outputDir, files: { balancesJson: balanceJsonPath, balancesCsv: balanceCsvPath, accessJson: accessJsonPath, accessCsv: accessCsvPath, }, summary: { cStarTokensChecked: balancesJson.summary.cStarTokensChecked, cWTokensChecked: balancesJson.summary.cWTokensChecked, nonZeroBalances: balancesJson.summary.nonZeroBalances, nonZeroAllowances: accessJson.summary.nonZeroAllowances, deployerDefaultAdminCount: accessJson.summary.deployerDefaultAdminCount, deployerMinterCount: accessJson.summary.deployerMinterCount, deployerBurnerCount: accessJson.summary.deployerBurnerCount, }, }, null, 2)); } main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); NODE