346 lines
12 KiB
JavaScript
Executable File
346 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Validate the ALL Mainnet pool-creation matrix.
|
|
*
|
|
* This file is an operational dependency: pool rows are used to decide what can
|
|
* be created, funded, or promoted. The checks here keep the matrix internally
|
|
* consistent and cross-check token addresses against the repo token lists.
|
|
*/
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { basename, resolve } from "node:path";
|
|
|
|
const repoRoot = resolve(new URL("../..", import.meta.url).pathname);
|
|
const defaultMatrix = "config/all-mainnet-pool-creation-matrix.json";
|
|
const defaultTokenLists = [
|
|
"token-lists/lists/all-mainnet.tokenlist.json",
|
|
"token-lists/lists/dbis-138.tokenlist.json",
|
|
"token-lists/lists/ethereum-mainnet.tokenlist.json",
|
|
"token-lists/lists/arbitrum.tokenlist.json",
|
|
"token-lists/lists/avalanche.tokenlist.json",
|
|
"token-lists/lists/cronos.tokenlist.json",
|
|
"metamask-integration/config/token-list.json",
|
|
"smom-dbis-138/metamask/token-list.json",
|
|
];
|
|
|
|
const requiredVaultRoles = [
|
|
"treasury_reserve",
|
|
"bridge_liquidity",
|
|
"single_sided_inventory",
|
|
"protocol_adapter",
|
|
"emergency_withdraw",
|
|
];
|
|
|
|
const statusesRequiringPoolAddress = new Set(["created", "funded", "live_read", "canary_passed", "production"]);
|
|
const addressPattern = /^0x[a-fA-F0-9]{40}$/;
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
if (args.includes("--help") || args.includes("-h")) {
|
|
console.log(`Usage: node scripts/validation/validate-pool-creation-matrix.mjs [matrix-path]\n\nDefaults to ${defaultMatrix}.`);
|
|
process.exit(0);
|
|
}
|
|
return args[0] || defaultMatrix;
|
|
}
|
|
|
|
function readJson(file, errors) {
|
|
const abs = resolve(repoRoot, file);
|
|
if (!existsSync(abs)) {
|
|
errors.push(`${file}: missing`);
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(readFileSync(abs, "utf8"));
|
|
} catch (error) {
|
|
errors.push(`${file}: invalid JSON: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function tokenKey(chainId, symbol) {
|
|
return `${chainId}:${String(symbol).toUpperCase()}`;
|
|
}
|
|
|
|
function buildTokenIndex(warnings) {
|
|
const index = new Map();
|
|
|
|
for (const file of defaultTokenLists) {
|
|
const abs = resolve(repoRoot, file);
|
|
if (!existsSync(abs)) {
|
|
warnings.push(`${file}: token list missing; address cross-check skipped for that file`);
|
|
continue;
|
|
}
|
|
|
|
let list;
|
|
try {
|
|
list = JSON.parse(readFileSync(abs, "utf8"));
|
|
} catch (error) {
|
|
warnings.push(`${file}: invalid JSON; address cross-check skipped for that file: ${error.message}`);
|
|
continue;
|
|
}
|
|
|
|
if (!Array.isArray(list.tokens)) {
|
|
warnings.push(`${file}: missing tokens[]; address cross-check skipped for that file`);
|
|
continue;
|
|
}
|
|
|
|
for (const token of list.tokens) {
|
|
if (!token?.chainId || !token?.symbol || !addressPattern.test(String(token.address || ""))) {
|
|
continue;
|
|
}
|
|
const key = tokenKey(token.chainId, token.symbol);
|
|
if (!index.has(key)) {
|
|
index.set(key, new Map());
|
|
}
|
|
index.get(key).set(String(token.address).toLowerCase(), `${file}:${token.symbol}`);
|
|
}
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
function ref(row, index) {
|
|
return `rows[${index}] ${row.poolId || "<missing-poolId>"}`;
|
|
}
|
|
|
|
function slug(value) {
|
|
return String(value || "").toLowerCase();
|
|
}
|
|
|
|
function sortedStrings(values) {
|
|
return [...values].sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
function countBy(rows, key) {
|
|
const counts = {};
|
|
for (const row of rows) {
|
|
const value = row[key];
|
|
counts[value] = (counts[value] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function sameCounts(actual, expected) {
|
|
const keys = new Set([...Object.keys(actual || {}), ...Object.keys(expected || {})]);
|
|
for (const key of keys) {
|
|
if ((actual?.[key] || 0) !== (expected?.[key] || 0)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function validateAddress(value, path, errors, { allowNull = true } = {}) {
|
|
if (value === null && allowNull) {
|
|
return;
|
|
}
|
|
if (typeof value !== "string" || !addressPattern.test(value)) {
|
|
errors.push(`${path}: expected ${allowNull ? "null or " : ""}0x-prefixed 20-byte address`);
|
|
}
|
|
}
|
|
|
|
function validateTokenAddress(row, index, side, tokenIndex, errors, warnings) {
|
|
const token = row[side];
|
|
const rowRef = ref(row, index);
|
|
if (!token || typeof token !== "object") {
|
|
errors.push(`${rowRef}: ${side} must be an object`);
|
|
return;
|
|
}
|
|
if (typeof token.symbol !== "string" || token.symbol.length === 0) {
|
|
errors.push(`${rowRef}: ${side}.symbol is required`);
|
|
}
|
|
validateAddress(token.address, `${rowRef}: ${side}.address`, errors);
|
|
|
|
const known = tokenIndex.get(tokenKey(row.chainId, token.symbol));
|
|
if (!known || known.size !== 1) {
|
|
return;
|
|
}
|
|
|
|
const [knownAddress, source] = [...known.entries()][0];
|
|
if (token.address === null) {
|
|
if (row.status !== "planned" || row.publicRoutingEnabled === true) {
|
|
errors.push(`${rowRef}: ${side} ${token.symbol} address is missing but ${source} has ${knownAddress}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (String(token.address).toLowerCase() !== knownAddress) {
|
|
warnings.push(`${rowRef}: ${side} ${token.symbol} address ${token.address} differs from ${source} ${knownAddress}`);
|
|
}
|
|
}
|
|
|
|
function validateMatrix(file, matrix, tokenIndex) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
if (!matrix || typeof matrix !== "object" || Array.isArray(matrix)) {
|
|
errors.push(`${file}: root must be an object`);
|
|
return { errors, warnings };
|
|
}
|
|
|
|
if (typeof matrix.version !== "string" || matrix.version.length === 0) {
|
|
errors.push(`${file}: version is required`);
|
|
}
|
|
if (typeof matrix.generatedAt !== "string" || Number.isNaN(Date.parse(matrix.generatedAt))) {
|
|
errors.push(`${file}: generatedAt must be an ISO-like date string`);
|
|
}
|
|
if (!Array.isArray(matrix.rows) || matrix.rows.length === 0) {
|
|
errors.push(`${file}: rows[] is required`);
|
|
return { errors, warnings };
|
|
}
|
|
if (!Array.isArray(matrix.lifecycle) || matrix.lifecycle.length === 0) {
|
|
errors.push(`${file}: lifecycle[] is required`);
|
|
}
|
|
if (!Array.isArray(matrix.protocolRolloutOrder) || matrix.protocolRolloutOrder.length === 0) {
|
|
errors.push(`${file}: protocolRolloutOrder[] is required`);
|
|
}
|
|
|
|
const protocolCounts = countBy(matrix.rows, "protocol");
|
|
const statusCounts = countBy(matrix.rows, "status");
|
|
if (!sameCounts(protocolCounts, matrix.protocolCounts)) {
|
|
errors.push(`${file}: protocolCounts does not match rows (${JSON.stringify(protocolCounts)})`);
|
|
}
|
|
if (!sameCounts(statusCounts, matrix.statusCounts)) {
|
|
errors.push(`${file}: statusCounts does not match rows (${JSON.stringify(statusCounts)})`);
|
|
}
|
|
|
|
const lifecycle = new Set(matrix.lifecycle || []);
|
|
const rollout = new Set(matrix.protocolRolloutOrder || []);
|
|
const poolIds = new Set();
|
|
|
|
matrix.rows.forEach((row, index) => {
|
|
const rowRef = ref(row, index);
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
errors.push(`rows[${index}]: row must be an object`);
|
|
return;
|
|
}
|
|
|
|
if (typeof row.poolId !== "string" || row.poolId.length === 0) {
|
|
errors.push(`${rowRef}: poolId is required`);
|
|
} else if (poolIds.has(row.poolId)) {
|
|
errors.push(`${rowRef}: duplicate poolId`);
|
|
} else {
|
|
poolIds.add(row.poolId);
|
|
}
|
|
|
|
if (!Number.isInteger(row.chainId)) {
|
|
errors.push(`${rowRef}: chainId must be an integer`);
|
|
}
|
|
if (!rollout.has(row.protocol)) {
|
|
errors.push(`${rowRef}: protocol ${row.protocol} is not in protocolRolloutOrder`);
|
|
}
|
|
if (!lifecycle.has(row.status)) {
|
|
errors.push(`${rowRef}: status ${row.status} is not in lifecycle`);
|
|
}
|
|
|
|
const expectedPoolId = `${row.chainId}-${row.protocol}-${slug(row.baseToken?.symbol)}-${slug(row.quoteToken?.symbol)}`;
|
|
if (row.poolId && row.poolId !== expectedPoolId) {
|
|
errors.push(`${rowRef}: poolId should be ${expectedPoolId}`);
|
|
}
|
|
|
|
validateTokenAddress(row, index, "baseToken", tokenIndex, errors, warnings);
|
|
validateTokenAddress(row, index, "quoteToken", tokenIndex, errors, warnings);
|
|
|
|
validateAddress(row.factoryAddress, `${rowRef}: factoryAddress`, errors);
|
|
validateAddress(row.routerAddress, `${rowRef}: routerAddress`, errors);
|
|
validateAddress(row.poolAddress, `${rowRef}: poolAddress`, errors, {
|
|
allowNull: !statusesRequiringPoolAddress.has(row.status),
|
|
});
|
|
validateAddress(row.vaultAddress, `${rowRef}: vaultAddress`, errors);
|
|
|
|
if (statusesRequiringPoolAddress.has(row.status) && row.poolAddress === null) {
|
|
errors.push(`${rowRef}: status ${row.status} requires poolAddress`);
|
|
}
|
|
|
|
const shouldBeSingleSided = row.protocol === "single_sided_pmm" || row.poolType === "single_sided";
|
|
if (row.singleSided !== shouldBeSingleSided) {
|
|
errors.push(`${rowRef}: singleSided should be ${shouldBeSingleSided}`);
|
|
}
|
|
|
|
if (!Array.isArray(row.vaultAssignments)) {
|
|
errors.push(`${rowRef}: vaultAssignments[] is required`);
|
|
} else {
|
|
const roles = row.vaultAssignments.map((assignment) => assignment?.role);
|
|
const roleSet = new Set(roles);
|
|
const expectedRoles = new Set(requiredVaultRoles);
|
|
if (roleSet.size !== expectedRoles.size || requiredVaultRoles.some((role) => !roleSet.has(role))) {
|
|
errors.push(`${rowRef}: vaultAssignments roles must be ${requiredVaultRoles.join(",")}`);
|
|
}
|
|
for (const assignment of row.vaultAssignments) {
|
|
if (!assignment || typeof assignment !== "object") {
|
|
errors.push(`${rowRef}: vaultAssignments entries must be objects`);
|
|
continue;
|
|
}
|
|
validateAddress(assignment.vaultAddress, `${rowRef}: vaultAssignments.${assignment.role || "<missing-role>"}.vaultAddress`, errors);
|
|
if (typeof assignment.requiredBeforeFunding !== "boolean") {
|
|
errors.push(`${rowRef}: vaultAssignments.${assignment.role || "<missing-role>"}.requiredBeforeFunding must be boolean`);
|
|
}
|
|
}
|
|
|
|
const actualMissing = sortedStrings(
|
|
row.vaultAssignments
|
|
.filter((assignment) => assignment?.requiredBeforeFunding === true && assignment.vaultAddress === null)
|
|
.map((assignment) => assignment.role),
|
|
);
|
|
const declaredMissing = sortedStrings(row.missingRequiredVaultRoles || []);
|
|
if (actualMissing.join("|") !== declaredMissing.join("|")) {
|
|
errors.push(`${rowRef}: missingRequiredVaultRoles should be [${actualMissing.join(", ")}]`);
|
|
}
|
|
const expectedStatus = actualMissing.length > 0 ? "missing_required_vaults" : "ready";
|
|
if (row.vaultAssignmentStatus !== expectedStatus) {
|
|
errors.push(`${rowRef}: vaultAssignmentStatus should be ${expectedStatus}`);
|
|
}
|
|
}
|
|
|
|
const tiers = row.fundingTiersUsd;
|
|
if (!tiers || typeof tiers !== "object") {
|
|
errors.push(`${rowRef}: fundingTiersUsd is required`);
|
|
} else if (!(tiers.seed > 0 && tiers.smoke >= tiers.seed && tiers.productionMinimum >= tiers.smoke)) {
|
|
errors.push(`${rowRef}: fundingTiersUsd must satisfy seed > 0, smoke >= seed, productionMinimum >= smoke`);
|
|
}
|
|
|
|
const policy = row.policy;
|
|
if (!policy || typeof policy !== "object") {
|
|
errors.push(`${rowRef}: policy is required`);
|
|
} else {
|
|
for (const key of ["maxPriceImpactBps", "minReserveUsd", "refillTriggerBps"]) {
|
|
if (typeof policy[key] !== "number" || policy[key] < 0) {
|
|
errors.push(`${rowRef}: policy.${key} must be a non-negative number`);
|
|
}
|
|
}
|
|
if (typeof policy.pauseOnReserveReadFailure !== "boolean") {
|
|
errors.push(`${rowRef}: policy.pauseOnReserveReadFailure must be boolean`);
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(row.notes)) {
|
|
errors.push(`${rowRef}: notes[] is required`);
|
|
}
|
|
});
|
|
|
|
return { errors, warnings };
|
|
}
|
|
|
|
const matrixPath = parseArgs();
|
|
const bootstrapErrors = [];
|
|
const bootstrapWarnings = [];
|
|
const matrix = readJson(matrixPath, bootstrapErrors);
|
|
const tokenIndex = buildTokenIndex(bootstrapWarnings);
|
|
const { errors, warnings } = matrix ? validateMatrix(matrixPath, matrix, tokenIndex) : { errors: [], warnings: [] };
|
|
const allErrors = [...bootstrapErrors, ...errors];
|
|
const allWarnings = [...bootstrapWarnings, ...warnings];
|
|
|
|
for (const warning of allWarnings) {
|
|
console.warn(`[WARN] ${warning}`);
|
|
}
|
|
|
|
if (allErrors.length > 0) {
|
|
console.error(`[ERROR] Pool-creation matrix validation failed with ${allErrors.length} issue(s):`);
|
|
for (const error of allErrors) {
|
|
console.error(` - ${error}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`[OK] ${basename(matrixPath)} valid: ${matrix.rows.length} row(s), ${Object.keys(matrix.protocolCounts || {}).length} protocol(s).`);
|