Files
proxmox/scripts/verify/audit-deployer-token-access.sh

691 lines
21 KiB
Bash
Raw Normal View History

#!/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