#!/usr/bin/env bash # Audit live gas-native rollout prerequisites and print the next deploy/config steps. # Usage: # bash scripts/verify/check-gas-rollout-deployment-matrix.sh # bash scripts/verify/check-gas-rollout-deployment-matrix.sh --json set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" export PROJECT_ROOT # shellcheck source=/dev/null source "$PROJECT_ROOT/scripts/lib/load-project-env.sh" >/dev/null 2>&1 || true OUTPUT_JSON=0 for arg in "$@"; do case "$arg" in --json) OUTPUT_JSON=1 ;; *) echo "Unknown argument: $arg" >&2 exit 2 ;; esac done command -v node >/dev/null 2>&1 || { echo "[FAIL] Missing required command: node" >&2 exit 1 } command -v cast >/dev/null 2>&1 || { echo "[FAIL] Missing required command: cast" >&2 exit 1 } OUTPUT_JSON="$OUTPUT_JSON" node <<'NODE' const path = require('path'); const { execFileSync } = require('child_process'); const loader = require(path.join(process.env.PROJECT_ROOT, 'config/token-mapping-loader.cjs')); const contracts = require(path.join(process.env.PROJECT_ROOT, 'config/contracts-loader.cjs')); const outputJson = process.env.OUTPUT_JSON === '1'; const active = loader.loadGruTransportActiveJson() || {}; const families = loader.getGasAssetFamilies(); const pairs = loader.getActiveTransportPairs().filter((pair) => pair.assetClass === 'gas_native'); const reserveVerifiers = active.reserveVerifiers || {}; const deployedGenericVerifierAddress = contracts.getContractAddress(138, 'CWAssetReserveVerifier') || ''; const configuredGasPairs = Array.isArray(active.transportPairs) ? active.transportPairs.filter((pair) => pair && pair.assetClass === 'gas_native') : []; const deferredGasPairs = configuredGasPairs.filter((pair) => pair.active === false).length; const rpcEnvByChain = { 138: 'RPC_URL_138', 1: 'ETHEREUM_MAINNET_RPC', 10: 'OPTIMISM_MAINNET_RPC', 25: 'CRONOS_RPC_URL', 56: 'BSC_RPC_URL', 100: 'GNOSIS_MAINNET_RPC', 137: 'POLYGON_MAINNET_RPC', 42161: 'ARBITRUM_MAINNET_RPC', 42220: 'CELO_MAINNET_RPC', 43114: 'AVALANCHE_RPC_URL', 8453: 'BASE_MAINNET_RPC', 1111: 'WEMIX_RPC', }; function resolveConfigRef(ref) { if (!ref || typeof ref !== 'object') return ''; if (typeof ref.address === 'string' && ref.address.trim()) return ref.address.trim(); if (typeof ref.env === 'string' && process.env[ref.env]) return process.env[ref.env]; return ''; } function hasCode(address, rpcUrl) { if (!address || !/^0x[a-fA-F0-9]{40}$/.test(address) || !rpcUrl) return null; try { const out = execFileSync('cast', ['code', address, '--rpc-url', rpcUrl], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trim(); return out !== '' && out !== '0x'; } catch { return false; } } function callRead(address, rpcUrl, signature, args = []) { if (!address || !/^0x[a-fA-F0-9]{40}$/.test(address) || !rpcUrl) { return { ok: false, skipped: true, value: null, error: 'missing_rpc_or_address' }; } try { const out = execFileSync( 'cast', ['call', address, signature, ...args.map((arg) => String(arg)), '--rpc-url', rpcUrl], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], } ).trim(); return { ok: true, skipped: false, value: out, error: null }; } catch (error) { const stderr = error && error.stderr ? String(error.stderr).trim() : ''; const stdout = error && error.stdout ? String(error.stdout).trim() : ''; return { ok: false, skipped: false, value: null, error: stderr || stdout || (error && error.message ? String(error.message).trim() : 'cast_call_failed'), }; } } function parseDestinationConfig(rawValue) { if (typeof rawValue !== 'string') { return { receiverBridge: null, enabled: null }; } const match = rawValue.match(/^\((0x[a-fA-F0-9]{40}),\s*(true|false)\)$/); if (!match) { return { receiverBridge: null, enabled: null }; } return { receiverBridge: match[1], enabled: match[2] === 'true', }; } function classifyL1BridgeCapability(probe) { const adminReadable = probe.admin.ok; const destinationReadable = probe.destination.ok; const accountingReadable = probe.reserveVerifier.ok && probe.supportedCanonicalToken.ok && probe.maxOutstanding.ok && probe.outstandingMinted.ok && probe.totalOutstanding.ok && probe.lockedBalance.ok; if (adminReadable && destinationReadable && accountingReadable) return 'full_accounting'; if (adminReadable && destinationReadable) return 'partial_destination_only'; if (adminReadable) return 'admin_only'; if (probe.hasCode === false) return 'missing'; return 'unknown_or_incompatible'; } function probeL1Bridge(address, rpcUrl, canonicalAddress, destinationChainSelector) { const admin = callRead(address, rpcUrl, 'admin()(address)'); const destination = canonicalAddress && destinationChainSelector ? callRead(address, rpcUrl, 'destinations(address,uint64)((address,bool))', [ canonicalAddress, destinationChainSelector, ]) : { ok: false, skipped: true, value: null, error: 'missing_selector_or_token' }; const reserveVerifier = callRead(address, rpcUrl, 'reserveVerifier()(address)'); const supportedCanonicalToken = canonicalAddress ? callRead(address, rpcUrl, 'supportedCanonicalToken(address)(bool)', [canonicalAddress]) : { ok: false, skipped: true, value: null, error: 'missing_token' }; const maxOutstanding = canonicalAddress && destinationChainSelector ? callRead(address, rpcUrl, 'maxOutstanding(address,uint64)(uint256)', [canonicalAddress, destinationChainSelector]) : { ok: false, skipped: true, value: null, error: 'missing_selector_or_token' }; const outstandingMinted = canonicalAddress && destinationChainSelector ? callRead(address, rpcUrl, 'outstandingMinted(address,uint64)(uint256)', [ canonicalAddress, destinationChainSelector, ]) : { ok: false, skipped: true, value: null, error: 'missing_selector_or_token' }; const totalOutstanding = canonicalAddress ? callRead(address, rpcUrl, 'totalOutstanding(address)(uint256)', [canonicalAddress]) : { ok: false, skipped: true, value: null, error: 'missing_token' }; const lockedBalance = canonicalAddress ? callRead(address, rpcUrl, 'lockedBalance(address)(uint256)', [canonicalAddress]) : { ok: false, skipped: true, value: null, error: 'missing_token' }; const destinationConfig = parseDestinationConfig(destination.value); const readableViews = [ ['admin', admin.ok], ['destinations', destination.ok], ['reserveVerifier', reserveVerifier.ok], ['supportedCanonicalToken', supportedCanonicalToken.ok], ['maxOutstanding', maxOutstanding.ok], ['outstandingMinted', outstandingMinted.ok], ['totalOutstanding', totalOutstanding.ok], ['lockedBalance', lockedBalance.ok], ] .filter(([, readable]) => readable) .map(([label]) => label); return { capability: classifyL1BridgeCapability({ hasCode: hasCode(address, rpcUrl), admin, destination, reserveVerifier, supportedCanonicalToken, maxOutstanding, outstandingMinted, totalOutstanding, lockedBalance, }), readableViews, destinationConfigured: destinationConfig.enabled, destinationReceiverBridge: destinationConfig.receiverBridge, reserveVerifier, supportedCanonicalToken, maxOutstanding, outstandingMinted, totalOutstanding, lockedBalance, }; } function describeMirrorName(pair) { if (pair.familyKey === 'eth_mainnet') return 'Wrapped cETH Mainnet'; if (pair.familyKey === 'eth_l2') return 'Wrapped cETHL2'; return `Wrapped ${pair.canonicalSymbol}`; } function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } const rows = pairs.map((pair) => { const destinationRpcEnv = rpcEnvByChain[pair.destinationChainId] || null; const destinationRpcUrl = destinationRpcEnv ? process.env[destinationRpcEnv] || '' : ''; const chain138RpcUrl = process.env[rpcEnvByChain[138]] || ''; const reserveVerifier = pair.reserveVerifierKey ? reserveVerifiers[pair.reserveVerifierKey] || null : null; const reserveVerifierEnvKey = reserveVerifier && reserveVerifier.verifierRef && typeof reserveVerifier.verifierRef.env === 'string' && reserveVerifier.verifierRef.env.trim() ? reserveVerifier.verifierRef.env.trim() : ''; const l1BridgeAddress = pair.runtimeL1BridgeAddress || resolveConfigRef(pair.peer?.l1Bridge); const l2BridgeAddress = pair.runtimeL2BridgeAddress || resolveConfigRef(pair.peer?.l2Bridge); const reserveVerifierAddress = pair.runtimeReserveVerifierAddress || resolveConfigRef(reserveVerifier?.verifierRef); const reserveVaultAddress = pair.runtimeReserveVaultAddress || resolveConfigRef(reserveVerifier?.vaultRef); const destinationChainSelector = typeof pair.destinationChainSelector === 'string' ? pair.destinationChainSelector : ''; const canonicalCodeLive = hasCode(pair.canonicalAddress, chain138RpcUrl); const mirroredCodeLive = hasCode(pair.mirroredAddress, destinationRpcUrl); const l1BridgeLive = hasCode(l1BridgeAddress, chain138RpcUrl); const l2BridgeLive = hasCode(l2BridgeAddress, destinationRpcUrl); const reserveVerifierLive = hasCode(reserveVerifierAddress, chain138RpcUrl); const reserveVaultLive = reserveVaultAddress ? hasCode(reserveVaultAddress, chain138RpcUrl) : null; const l1BridgeProbe = probeL1Bridge(l1BridgeAddress, chain138RpcUrl, pair.canonicalAddress, destinationChainSelector); const destinationReceiverMatchesRuntimeL2 = !!l1BridgeProbe.destinationReceiverBridge && !!l2BridgeAddress && l1BridgeProbe.destinationReceiverBridge.toLowerCase() === l2BridgeAddress.toLowerCase(); const actions = []; if (canonicalCodeLive !== true) { actions.push({ type: 'deployCanonical', command: `GAS_FAMILY=${shellQuote(pair.familyKey)} forge script script/deploy/DeployGasCanonicalTokens.s.sol:DeployGasCanonicalTokens --rpc-url "$RPC_URL_138" --broadcast`, }); } if (mirroredCodeLive !== true) { actions.push({ type: 'deployMirror', command: `CW_BRIDGE_ADDRESS="$${pair.peer?.l2Bridge?.env || 'CW_BRIDGE_MISSING'}" ` + `CW_TOKEN_NAME=${shellQuote(describeMirrorName(pair))} ` + `CW_TOKEN_SYMBOL=${shellQuote(pair.mirroredSymbol)} ` + `CW_TOKEN_DECIMALS=18 ` + `forge script script/deploy/DeploySingleCWToken.s.sol:DeploySingleCWToken --rpc-url "$${destinationRpcEnv || 'RPC_MISSING'}" --broadcast`, }); } if (!l1BridgeAddress) { actions.push({ type: 'setEnv', command: 'export CHAIN138_L1_BRIDGE=' }); } if (!l2BridgeAddress) { actions.push({ type: 'setEnv', command: `export ${pair.peer?.l2Bridge?.env || 'CW_BRIDGE_TARGET'}=` }); } if (destinationChainSelector && l1BridgeProbe.destinationConfigured !== true && l2BridgeAddress) { actions.push({ type: 'configureL1Destination', command: `cast send "$CHAIN138_L1_BRIDGE" "configureDestination(address,uint64,address,bool)" ` + `${pair.canonicalAddress} ${destinationChainSelector} ${l2BridgeAddress} true ` + '--rpc-url "$RPC_URL_138" --private-key "$PRIVATE_KEY"', }); } if (!reserveVerifierAddress && deployedGenericVerifierAddress && reserveVerifierEnvKey) { actions.push({ type: 'setVerifierEnv', command: `export ${reserveVerifierEnvKey}=${deployedGenericVerifierAddress}`, }); } else if (!reserveVerifierAddress) { actions.push({ type: 'deployVerifier', command: 'CW_ASSET_RESERVE_VAULT= CW_ASSET_RESERVE_SYSTEM= ' + 'forge script script/DeployCWAssetReserveVerifier.s.sol:DeployCWAssetReserveVerifier --rpc-url "$RPC_URL_138" --broadcast', }); } if (!pair.runtimeMaxOutstandingValue) { actions.push({ type: 'setCap', command: `export ${pair.maxOutstanding?.env || 'CW_MAX_OUTSTANDING_TARGET'}=` }); } if (!pair.runtimeOutstandingValue || !pair.runtimeEscrowedValue || pair.runtimeTreasuryBackedValue == null) { actions.push({ type: 'setSupplyAccounting', command: 'export CW_GAS_OUTSTANDING_*=... CW_GAS_ESCROWED_*=... CW_GAS_TREASURY_BACKED_*=...' }); } if (l1BridgeProbe.capability !== 'full_accounting' && l1BridgeLive === true) { actions.push({ type: 'auditL1BridgeAbi', command: `cast call "$CHAIN138_L1_BRIDGE" "admin()(address)" --rpc-url "$RPC_URL_138" && ` + `cast call "$CHAIN138_L1_BRIDGE" "destinations(address,uint64)((address,bool))" ${pair.canonicalAddress} ${destinationChainSelector || 0} --rpc-url "$RPC_URL_138"`, }); } return { key: pair.key, familyKey: pair.familyKey, chainId: pair.destinationChainId, chainName: pair.destinationChainName, destinationChainSelector: destinationChainSelector || null, canonicalSymbol: pair.canonicalSymbol, mirroredSymbol: pair.mirroredSymbol, canonicalAddress: pair.canonicalAddress, mirroredAddress: pair.mirroredAddress, l1BridgeAddress, l2BridgeAddress, reserveVerifierAddress, deployedGenericVerifierAddress: deployedGenericVerifierAddress || null, reserveVerifierEnvKey: reserveVerifierEnvKey || null, reserveVaultAddress, canonicalCodeLive, mirroredCodeLive, l1BridgeLive, l2BridgeLive, reserveVerifierLive, reserveVaultLive, l1BridgeCapability: l1BridgeProbe.capability, l1BridgeReadableViews: l1BridgeProbe.readableViews, l1BridgeProbeErrors: { reserveVerifier: l1BridgeProbe.reserveVerifier.ok ? null : l1BridgeProbe.reserveVerifier.error, supportedCanonicalToken: l1BridgeProbe.supportedCanonicalToken.ok ? null : l1BridgeProbe.supportedCanonicalToken.error, maxOutstanding: l1BridgeProbe.maxOutstanding.ok ? null : l1BridgeProbe.maxOutstanding.error, outstandingMinted: l1BridgeProbe.outstandingMinted.ok ? null : l1BridgeProbe.outstandingMinted.error, totalOutstanding: l1BridgeProbe.totalOutstanding.ok ? null : l1BridgeProbe.totalOutstanding.error, lockedBalance: l1BridgeProbe.lockedBalance.ok ? null : l1BridgeProbe.lockedBalance.error, }, destinationConfiguredOnL1: l1BridgeProbe.destinationConfigured, destinationReceiverBridgeOnL1: l1BridgeProbe.destinationReceiverBridge, destinationReceiverMatchesRuntimeL2, runtimeReady: pair.runtimeReady === true, runtimeMissingRequirements: pair.runtimeMissingRequirements || [], actions, }; }); const uniqueL1BridgeRows = Array.from( new Map( rows .filter((row) => row.l1BridgeAddress) .map((row) => [String(row.l1BridgeAddress).toLowerCase(), row]) ).values() ); const summary = { gasFamilies: families.length, transportPairs: rows.length, configuredTransportPairs: configuredGasPairs.length, deferredTransportPairs: deferredGasPairs, canonicalContractsLive: rows.filter((row) => row.canonicalCodeLive === true).length, mirroredContractsLive: rows.filter((row) => row.mirroredCodeLive === true).length, l1BridgeRefsLoaded: rows.filter((row) => row.l1BridgeAddress).length, l2BridgeRefsLoaded: rows.filter((row) => row.l2BridgeAddress).length, verifierRefsLoaded: rows.filter((row) => row.reserveVerifierAddress).length, runtimeReadyPairs: rows.filter((row) => row.runtimeReady).length, pairsWithL1DestinationConfigured: rows.filter((row) => row.destinationConfiguredOnL1 === true).length, pairsWithL1ReceiverMatchingRuntimeL2: rows.filter((row) => row.destinationReceiverMatchesRuntimeL2 === true).length, l1BridgesObserved: uniqueL1BridgeRows.length, l1BridgesWithFullAccountingIntrospection: uniqueL1BridgeRows.filter( (row) => row.l1BridgeCapability === 'full_accounting' ).length, l1BridgesWithPartialDestinationIntrospection: uniqueL1BridgeRows.filter( (row) => row.l1BridgeCapability === 'partial_destination_only' ).length, deployedGenericVerifierAddress: deployedGenericVerifierAddress || null, }; if (outputJson) { console.log(JSON.stringify({ summary, rows }, null, 2)); process.exit(0); } console.log('=== Gas Rollout Deployment Matrix ==='); console.log(`Gas families: ${summary.gasFamilies}`); console.log(`Active transport pairs: ${summary.transportPairs}`); console.log(`Deferred transport pairs: ${summary.deferredTransportPairs}`); console.log(`Canonical contracts live on 138: ${summary.canonicalContractsLive}/${summary.transportPairs}`); console.log(`Mirrored contracts live on destination chains: ${summary.mirroredContractsLive}/${summary.transportPairs}`); console.log(`Loaded L1 bridge refs: ${summary.l1BridgeRefsLoaded}/${summary.transportPairs}`); console.log(`Loaded L2 bridge refs: ${summary.l2BridgeRefsLoaded}/${summary.transportPairs}`); console.log(`Loaded verifier refs: ${summary.verifierRefsLoaded}/${summary.transportPairs}`); console.log(`Runtime-ready pairs: ${summary.runtimeReadyPairs}/${summary.transportPairs}`); console.log(`L1 destinations configured: ${summary.pairsWithL1DestinationConfigured}/${summary.transportPairs}`); console.log( `L1 destination receivers matching runtime L2 bridges: ${summary.pairsWithL1ReceiverMatchingRuntimeL2}/${summary.transportPairs}` ); console.log( `Observed L1 bridges with full accounting introspection: ${summary.l1BridgesWithFullAccountingIntrospection}/${summary.l1BridgesObserved}` ); console.log( `Observed L1 bridges with destination-only introspection: ${summary.l1BridgesWithPartialDestinationIntrospection}/${summary.l1BridgesObserved}` ); if (summary.deployedGenericVerifierAddress) { console.log(`Deployed generic verifier on 138: ${summary.deployedGenericVerifierAddress}`); } for (const row of rows) { const parts = [ `${row.chainId} ${row.chainName}`, `${row.familyKey}`, `${row.canonicalSymbol}->${row.mirroredSymbol}`, `selector=${row.destinationChainSelector || 'unset'}`, `canonical=${row.canonicalCodeLive === true ? 'live' : row.canonicalCodeLive === false ? 'missing' : 'unknown'}`, `mirror=${row.mirroredCodeLive === true ? 'live' : row.mirroredCodeLive === false ? 'missing' : 'unknown'}`, `l1=${row.l1BridgeAddress ? 'set' : 'unset'}`, `l2=${row.l2BridgeAddress ? 'set' : 'unset'}`, `verifier=${row.reserveVerifierAddress ? 'set' : 'unset'}`, `l1cap=${row.l1BridgeCapability}`, `l1dest=${row.destinationConfiguredOnL1 === true ? 'wired' : row.destinationConfiguredOnL1 === false ? 'missing' : 'unknown'}`, ]; console.log(`- ${parts.join(' | ')}`); if (row.destinationReceiverBridgeOnL1) { console.log( ` l1 destination receiver: ${row.destinationReceiverBridgeOnL1}${row.destinationReceiverMatchesRuntimeL2 ? ' (matches runtime L2)' : ' (differs from runtime L2)'}` ); } if (row.l1BridgeReadableViews.length > 0) { console.log(` l1 readable views: ${row.l1BridgeReadableViews.join(', ')}`); } const failedProbeViews = Object.entries(row.l1BridgeProbeErrors) .filter(([, error]) => typeof error === 'string' && error) .map(([label]) => label); if (failedProbeViews.length > 0) { console.log(` l1 probe gaps: ${failedProbeViews.join(', ')}`); } if (row.runtimeMissingRequirements.length > 0) { console.log(` missing: ${row.runtimeMissingRequirements.join(', ')}`); } for (const action of row.actions.slice(0, 3)) { console.log(` next ${action.type}: ${action.command}`); } } NODE