#!/usr/bin/env node /** * Validate DBIS token-list metadata conventions. * * This complements the Uniswap token-list schema validator. The schema checks * shape; this script checks the meaning of the compact tags/extensions we use * for fiat, cash-like, GRU, commodity, and wrapped-token presentation. */ import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; const repoRoot = resolve(new URL("../..", import.meta.url).pathname); const defaultTokenLists = [ "token-lists/lists/all-mainnet.tokenlist.json", "token-lists/lists/arbitrum.tokenlist.json", "token-lists/lists/avalanche.tokenlist.json", "token-lists/lists/cronos.tokenlist.json", "token-lists/lists/dbis-138.tokenlist.json", "token-lists/lists/ethereum-mainnet.tokenlist.json", "metamask-integration/config/token-list.json", "metamask-integration/provider/config/DUAL_CHAIN_TOKEN_LIST.tokenlist.json", "metamask-integration/docs/METAMASK_TOKEN_LIST.json", "smom-dbis-138/metamask/token-list.json", ]; const conventionTags = new Set(["fiat", "cash", "gru", "commodity"]); const protocolSymbols = new Set(["AUDA", "HYDX", "HYBX", "CHT"]); const cryptoCollateralStablecoins = new Set(["DAI"]); const fiatCurrencies = new Set(["USD", "EUR", "GBP", "AUD", "JPY", "CHF", "CAD"]); const allowedCategories = new Set([ "tokenized-fiat", "stablecoin", "wrapped-native", "defi-token", "dex-token", "utility-token", "commodity-token", ]); function parseArgs() { const args = process.argv.slice(2); if (args.includes("--help") || args.includes("-h")) { console.log(`Usage: node scripts/validation/validate-token-list-metadata.mjs [token-list ...]\n\nIf no token-list paths are supplied, validates the repo's canonical token-list files that exist.`); process.exit(0); } return args.length > 0 ? args : defaultTokenLists; } function isScalar(value) { return value === null || ["string", "number", "boolean"].includes(typeof value); } function tokenRef(file, index, token) { return `${file} tokens[${index}] ${token.symbol || ""} ${token.chainId || ""} ${token.address || ""}`; } function hasTag(token, tag) { return Array.isArray(token.tags) && token.tags.includes(tag); } function tagDefs(list) { return list.tags && typeof list.tags === "object" && !Array.isArray(list.tags) ? list.tags : {}; } function validateList(file, list) { const errors = []; const warnings = []; const tags = tagDefs(list); if (!Array.isArray(list.tokens)) { errors.push(`${file}: missing tokens[]`); return { errors, warnings }; } for (const conventionTag of conventionTags) { const used = list.tokens.some((token) => hasTag(token, conventionTag)); if (used && !tags[conventionTag]) { errors.push(`${file}: tag "${conventionTag}" is used but missing from top-level tags`); } } list.tokens.forEach((token, index) => { const ref = tokenRef(file, index, token); const tokenTags = Array.isArray(token.tags) ? token.tags : []; const extensions = token.extensions ?? {}; for (const tag of tokenTags) { if (typeof tag !== "string") { errors.push(`${ref}: tag values must be strings`); } else if (tag.length > 10) { errors.push(`${ref}: tag "${tag}" is longer than 10 characters`); } } if (token.extensions !== undefined) { if (!extensions || typeof extensions !== "object" || Array.isArray(extensions)) { errors.push(`${ref}: extensions must be an object when present`); } else { const keys = Object.keys(extensions); if (keys.length > 10) { errors.push(`${ref}: extensions has ${keys.length} keys; max is 10`); } for (const [key, value] of Object.entries(extensions)) { if (!isScalar(value)) { errors.push(`${ref}: extensions.${key} must be scalar/null, not ${Array.isArray(value) ? "array" : typeof value}`); } if (typeof value === "string" && value.length > 42) { errors.push(`${ref}: extensions.${key} is longer than 42 characters`); } } } } if (extensions.category && !allowedCategories.has(extensions.category)) { errors.push(`${ref}: extensions.category "${extensions.category}" is not in the allowed metadata category set`); } if (hasTag(token, "cash")) { if (!hasTag(token, "fiat")) { errors.push(`${ref}: cash tag requires fiat tag`); } if (extensions.category !== "tokenized-fiat") { errors.push(`${ref}: cash tag requires extensions.category=tokenized-fiat`); } if (extensions.cashLike !== true) { errors.push(`${ref}: cash tag requires extensions.cashLike=true`); } if (extensions.settlement !== "fiat") { errors.push(`${ref}: cash tag requires extensions.settlement=fiat`); } if (typeof extensions.backing !== "string" || !extensions.backing.includes("cash")) { errors.push(`${ref}: cash tag requires extensions.backing to include cash`); } } if (hasTag(token, "fiat")) { if (extensions.category !== "tokenized-fiat") { errors.push(`${ref}: fiat tag requires extensions.category=tokenized-fiat`); } if (!fiatCurrencies.has(extensions.currency)) { errors.push(`${ref}: fiat tag requires extensions.currency to be one of ${[...fiatCurrencies].join(",")}`); } if (extensions.settlement !== "fiat") { errors.push(`${ref}: fiat tag requires extensions.settlement=fiat`); } } if (hasTag(token, "gru")) { if (typeof extensions.gruVersion !== "string" || !/^v\d+$/.test(extensions.gruVersion)) { errors.push(`${ref}: gru tag requires extensions.gruVersion like v1 or v2`); } if (typeof extensions.gruFamily !== "string" || extensions.gruFamily.length === 0) { errors.push(`${ref}: gru tag requires extensions.gruFamily`); } } if (hasTag(token, "commodity")) { if (hasTag(token, "cash") || hasTag(token, "fiat")) { errors.push(`${ref}: commodity token must not be tagged cash or fiat`); } if (extensions.category !== "commodity-token") { errors.push(`${ref}: commodity tag requires extensions.category=commodity-token`); } if (extensions.cashLike !== false) { errors.push(`${ref}: commodity tag requires extensions.cashLike=false`); } if (extensions.backing !== "commodity-reserves") { errors.push(`${ref}: commodity tag requires extensions.backing=commodity-reserves`); } } if (protocolSymbols.has(token.symbol)) { if (hasTag(token, "cash") || hasTag(token, "fiat") || hasTag(token, "gru")) { errors.push(`${ref}: protocol token ${token.symbol} must not be tagged cash, fiat, or gru`); } if (extensions.category === "tokenized-fiat") { errors.push(`${ref}: protocol token ${token.symbol} must not use category tokenized-fiat`); } if (extensions.cashLike === true) { errors.push(`${ref}: protocol token ${token.symbol} must not be cashLike`); } } if (cryptoCollateralStablecoins.has(token.symbol)) { if (hasTag(token, "cash") || hasTag(token, "fiat")) { errors.push(`${ref}: ${token.symbol} must not be tagged cash or fiat`); } if (extensions.instrument !== "crypto-collateralized-stablecoin") { errors.push(`${ref}: ${token.symbol} requires extensions.instrument=crypto-collateralized-stablecoin`); } if (extensions.cashLike !== false) { errors.push(`${ref}: ${token.symbol} requires extensions.cashLike=false`); } } }); return { errors, warnings }; } const files = parseArgs(); const allErrors = []; const allWarnings = []; let validated = 0; for (const file of files) { const abs = resolve(repoRoot, file); if (!existsSync(abs)) { allWarnings.push(`${file}: missing; skipped`); continue; } let list; try { list = JSON.parse(readFileSync(abs, "utf8")); } catch (error) { allErrors.push(`${file}: invalid JSON: ${error.message}`); continue; } const { errors, warnings } = validateList(file, list); allErrors.push(...errors); allWarnings.push(...warnings); validated += 1; } for (const warning of allWarnings) { console.warn(`[WARN] ${warning}`); } if (allErrors.length > 0) { console.error(`[ERROR] Token-list metadata validation failed with ${allErrors.length} issue(s):`); for (const error of allErrors) { console.error(` - ${error}`); } process.exit(1); } console.log(`[OK] Token-list metadata conventions valid for ${validated} file(s).`);