|
| 1 | +/** |
| 2 | + * @file src/scripts/ccip/generate-token-report.ts |
| 3 | + * @description Script to generate a report of CCIP tokens added per quarter. |
| 4 | + * |
| 5 | + * This script scans the git history of the token configuration files to determine |
| 6 | + * when each token was added. It then generates a markdown report grouping |
| 7 | + * the tokens by the quarter of their introduction. |
| 8 | + */ |
| 9 | + |
| 10 | +import fs from "fs" |
| 11 | +import path from "path" |
| 12 | +import { execFileSync } from "child_process" |
| 13 | +import { pino } from "pino" |
| 14 | +import { TokensConfig, LanesConfig, Environment, Version } from "../../config/data/ccip/types.js" |
| 15 | +import { loadReferenceData } from "../../config/data/ccip/data.js" |
| 16 | + |
| 17 | +// ============================== |
| 18 | +// CONFIGURATION |
| 19 | +// ============================== |
| 20 | + |
| 21 | +const SCRIPT_VERSION = "1.0.0" |
| 22 | +const ENVIRONMENT = Environment.Mainnet |
| 23 | +const VERSION = Version.V1_2_0 |
| 24 | +const START_DATE = new Date("2024-01-01") |
| 25 | +const OUTPUT_DIR = ".tmp" |
| 26 | +const OUTPUT_FILE = path.join(OUTPUT_DIR, "tokens-by-quarter.md") |
| 27 | + |
| 28 | +const logger = pino({ |
| 29 | + level: process.env.LOG_LEVEL || "info", |
| 30 | +}) |
| 31 | + |
| 32 | +// ============================== |
| 33 | +// HELPER FUNCTIONS |
| 34 | +// ============================== |
| 35 | + |
| 36 | +/** |
| 37 | + * Generate paths for the source files based on environment and version. |
| 38 | + */ |
| 39 | +function generateSourcePaths(environment: Environment, version: Version): { TOKENS_PATH: string; LANES_PATH: string } { |
| 40 | + const formatVersion = (v: string): string => `v${v.replace(/\./g, "_")}` |
| 41 | + const VERSION_PATH = formatVersion(version) |
| 42 | + const BASE_PATH = `src/config/data/ccip/${VERSION_PATH}/${environment}` |
| 43 | + return { |
| 44 | + TOKENS_PATH: `${BASE_PATH}/tokens.json`, |
| 45 | + LANES_PATH: `${BASE_PATH}/lanes.json`, |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +/** |
| 50 | + * Get file content from git history for a specific date. |
| 51 | + */ |
| 52 | +function getFileFromGitHistory(filePath: string, date: string): string | null { |
| 53 | + try { |
| 54 | + const commitHash = execFileSync( |
| 55 | + "git", |
| 56 | + ["log", `--before=${date}T23:59:59`, "-n", "1", "--format=%H", "--", filePath], |
| 57 | + { |
| 58 | + encoding: "utf8", |
| 59 | + } |
| 60 | + ).trim() |
| 61 | + |
| 62 | + if (!commitHash) { |
| 63 | + logger.warn({ filePath, date }, "No commit found for file before specified date") |
| 64 | + return null |
| 65 | + } |
| 66 | + |
| 67 | + return execFileSync("git", ["show", `${commitHash}:${filePath}`], { encoding: "utf8" }) |
| 68 | + } catch (error) { |
| 69 | + logger.error( |
| 70 | + { error: error instanceof Error ? error.message : String(error), filePath, date }, |
| 71 | + `Error getting file ${filePath} from git history` |
| 72 | + ) |
| 73 | + return null |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Build a map of token support across chains and lanes. |
| 79 | + */ |
| 80 | +function buildTokenSupportMap(tokensData: TokensConfig, lanesData: LanesConfig): Record<string, { lanes: string[] }> { |
| 81 | + const tokenSupport: Record<string, { lanes: string[] }> = {} |
| 82 | + |
| 83 | + Object.keys(tokensData).forEach((tokenSymbol) => { |
| 84 | + tokenSupport[tokenSymbol] = { lanes: [] } |
| 85 | + }) |
| 86 | + |
| 87 | + Object.keys(lanesData).forEach((sourceChain) => { |
| 88 | + Object.keys(lanesData[sourceChain]).forEach((destChain) => { |
| 89 | + const lane = `${sourceChain}-to-${destChain}` |
| 90 | + const supportedTokens = lanesData[sourceChain][destChain].supportedTokens || {} |
| 91 | + Object.keys(supportedTokens).forEach((tokenSymbol) => { |
| 92 | + if (tokenSupport[tokenSymbol]) { |
| 93 | + tokenSupport[tokenSymbol].lanes.push(lane) |
| 94 | + } |
| 95 | + }) |
| 96 | + }) |
| 97 | + }) |
| 98 | + |
| 99 | + return tokenSupport |
| 100 | +} |
| 101 | + |
| 102 | +/** |
| 103 | + * Gets the set of supported token symbols at a specific date. |
| 104 | + */ |
| 105 | +async function getSupportedTokensAtDate(date: string, tokensPath: string, lanesPath: string): Promise<Set<string>> { |
| 106 | + const tokensContent = getFileFromGitHistory(tokensPath, date) |
| 107 | + const lanesContent = getFileFromGitHistory(lanesPath, date) |
| 108 | + |
| 109 | + if (!tokensContent || !lanesContent) { |
| 110 | + return new Set() |
| 111 | + } |
| 112 | + |
| 113 | + try { |
| 114 | + const tokensData = JSON.parse(tokensContent) as TokensConfig |
| 115 | + const lanesData = JSON.parse(lanesContent) as LanesConfig |
| 116 | + const tokenSupportMap = buildTokenSupportMap(tokensData, lanesData) |
| 117 | + |
| 118 | + const supportedTokens = Object.keys(tokenSupportMap).filter( |
| 119 | + (token) => tokenSupportMap[token] && tokenSupportMap[token].lanes.length > 0 |
| 120 | + ) |
| 121 | + |
| 122 | + return new Set(supportedTokens) |
| 123 | + } catch (error) { |
| 124 | + logger.error( |
| 125 | + { error: error instanceof Error ? error.message : String(error), date }, |
| 126 | + "Failed to parse historical token data." |
| 127 | + ) |
| 128 | + return new Set() |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +/** |
| 133 | + * Gets the token name from the token symbol. |
| 134 | + */ |
| 135 | +function getTokenName(tokenSymbol: string, tokensData: TokensConfig): string { |
| 136 | + const tokenDetails = tokensData[tokenSymbol] |
| 137 | + if (!tokenDetails) return tokenSymbol |
| 138 | + const sampleChain = Object.keys(tokenDetails)[0] |
| 139 | + return sampleChain && tokenDetails[sampleChain] ? tokenDetails[sampleChain].name || tokenSymbol : tokenSymbol |
| 140 | +} |
| 141 | + |
| 142 | +// ============================== |
| 143 | +// MAIN SCRIPT LOGIC |
| 144 | +// ============================== |
| 145 | + |
| 146 | +/** |
| 147 | + * Generates and writes the token report. |
| 148 | + */ |
| 149 | +async function generateReport() { |
| 150 | + logger.info(`Starting token report generation (v${SCRIPT_VERSION})`) |
| 151 | + logger.info(`Environment: ${ENVIRONMENT}, Version: ${VERSION}`) |
| 152 | + |
| 153 | + if (!fs.existsSync(OUTPUT_DIR)) { |
| 154 | + fs.mkdirSync(OUTPUT_DIR) |
| 155 | + } |
| 156 | + |
| 157 | + const { TOKENS_PATH, LANES_PATH } = generateSourcePaths(ENVIRONMENT, VERSION) |
| 158 | + const { tokensReferenceData: currentTokensData } = loadReferenceData({ environment: ENVIRONMENT, version: VERSION }) |
| 159 | + |
| 160 | + const reportData: Record< |
| 161 | + string, |
| 162 | + { |
| 163 | + tokens: string[] |
| 164 | + startDate: string |
| 165 | + endDate: string |
| 166 | + } |
| 167 | + > = {} |
| 168 | + const allTokensFound = new Set<string>() |
| 169 | + |
| 170 | + // Use UTC dates to avoid timezone issues. |
| 171 | + let currentDate = new Date(START_DATE.toISOString().split("T")[0] + "T00:00:00Z") |
| 172 | + const now = new Date() |
| 173 | + |
| 174 | + while (currentDate <= now) { |
| 175 | + const year = currentDate.getUTCFullYear() |
| 176 | + // getUTCMonth() is 0-indexed (0-11), so we can calculate the quarter directly. |
| 177 | + const quarter = Math.floor(currentDate.getUTCMonth() / 3) + 1 |
| 178 | + const quarterKey = `Q${quarter}-${year}` |
| 179 | + |
| 180 | + const startOfQuarter = new Date(Date.UTC(year, (quarter - 1) * 3, 1)) |
| 181 | + const endOfQuarter = new Date(Date.UTC(year, quarter * 3, 0)) // Day 0 of next month is last day of current month |
| 182 | + |
| 183 | + const dateForGit = endOfQuarter > now ? now : endOfQuarter |
| 184 | + const dateStringForGit = dateForGit.toISOString().split("T")[0] |
| 185 | + |
| 186 | + logger.info(`Processing ${quarterKey} (up to ${dateStringForGit})...`) |
| 187 | + |
| 188 | + const tokensAtEndOfQuarter = await getSupportedTokensAtDate(dateStringForGit, TOKENS_PATH, LANES_PATH) |
| 189 | + const newTokens = new Set([...tokensAtEndOfQuarter].filter((x) => !allTokensFound.has(x))) |
| 190 | + |
| 191 | + if (newTokens.size > 0) { |
| 192 | + const newSortedTokens = Array.from(newTokens).sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" })) |
| 193 | + reportData[quarterKey] = { |
| 194 | + tokens: newSortedTokens, |
| 195 | + startDate: startOfQuarter.toISOString().split("T")[0], |
| 196 | + endDate: endOfQuarter.toISOString().split("T")[0], |
| 197 | + } |
| 198 | + newTokens.forEach((token) => allTokensFound.add(token)) |
| 199 | + } |
| 200 | + |
| 201 | + // Move to the first day of the next quarter in UTC |
| 202 | + currentDate = new Date(Date.UTC(year, quarter * 3, 1)) |
| 203 | + } |
| 204 | + |
| 205 | + // Generate Markdown report |
| 206 | + let reportContent = `# CCIP Tokens Added by Quarter\n\n` |
| 207 | + reportContent += `*Report generated on: ${new Date().toUTCString()}*\n` |
| 208 | + reportContent += `*Environment: ${ENVIRONMENT}, Version: ${VERSION}*\n\n` |
| 209 | + |
| 210 | + const sortedQuarters = Object.keys(reportData).sort((a, b) => { |
| 211 | + const [aQ, aY] = a.split("-") |
| 212 | + const [bQ, bY] = b.split("-") |
| 213 | + if (aY !== bY) return Number(aY) - Number(bY) |
| 214 | + return Number(aQ.substring(1)) - Number(bQ.substring(1)) |
| 215 | + }) |
| 216 | + |
| 217 | + for (const quarter of sortedQuarters) { |
| 218 | + const { tokens, startDate, endDate } = reportData[quarter] |
| 219 | + reportContent += `## ${quarter} (${tokens.length} tokens)\n\n` |
| 220 | + reportContent += `*Period: ${startDate} to ${endDate}*\n\n` |
| 221 | + for (const tokenSymbol of tokens) { |
| 222 | + const tokenName = getTokenName(tokenSymbol, currentTokensData) |
| 223 | + reportContent += `- **${tokenSymbol}**: ${tokenName}\n` |
| 224 | + } |
| 225 | + reportContent += `\n` |
| 226 | + } |
| 227 | + |
| 228 | + fs.writeFileSync(OUTPUT_FILE, reportContent) |
| 229 | + logger.info(`Report successfully generated at ${OUTPUT_FILE}`) |
| 230 | +} |
| 231 | + |
| 232 | +// ============================== |
| 233 | +// EXECUTION |
| 234 | +// ============================== |
| 235 | + |
| 236 | +generateReport().catch((err) => { |
| 237 | + logger.error(err, "An unexpected error occurred while generating the report.") |
| 238 | + process.exit(1) |
| 239 | +}) |
0 commit comments