241 lines
8.5 KiB
JavaScript
Executable File
241 lines
8.5 KiB
JavaScript
Executable File
#!/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 || "<missing-symbol>"} ${token.chainId || "<missing-chain>"} ${token.address || "<missing-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).`);
|