diff --git a/.github/PULL_REQUEST_TEMPLATE/token-list.md b/.github/PULL_REQUEST_TEMPLATE/token-list.md new file mode 100644 index 00000000..1711259d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/token-list.md @@ -0,0 +1,27 @@ +## Token + +- Network: DOS Chain Mainnet +- Chain ID: 7979 +- Contract address: +- Token name: +- Token symbol: +- Decimals: +- Official project URL: + +## Verification + +- [ ] The contract is deployed on the selected network and has non-empty bytecode. +- [ ] The address uses the EIP-55 checksum format. +- [ ] `name`, `symbol`, and `decimals` match the contract responses. +- [ ] The token is not already present in the network list. +- [ ] The icon is an original project asset with permission for public use. +- [ ] The icon is a non-empty SVG or PNG under `configs/token-icons/`. +- [ ] SVG icons contain no scripts, event handlers, or external references. +- [ ] `logoURI` uses the canonical raw GitHub URL. +- [ ] The token-list timestamp and semantic version were updated. +- [ ] Local validation and repository CI pass. + +## Evidence + +Provide the explorer contract URL and the command or RPC response used to verify +the on-chain metadata. diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 9efe96b2..e8ae1d09 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -51,31 +51,12 @@ jobs: -d configs/token-lists/mainnet.json jq -e ' (.tokens | length > 0) and + (([.tokens[].address | ascii_downcase] | unique | length) == (.tokens | length)) and ([.tokens[] | (.chainId == 7979) and + ((.address | ascii_downcase) != "0x0000000000000000000000000000000000000000") and (.logoURI | test("^https://raw\\.githubusercontent\\.com/DOS/DOScan-Frontend-Configs/main/configs/token-icons/[A-Za-z0-9._-]+\\.(svg|png)$")) ] | all) ' configs/token-lists/mainnet.json >/dev/null - icon_prefix="https://raw.githubusercontent.com/DOS/DOScan-Frontend-Configs/main/" - while IFS= read -r icon_url; do - icon_path="${icon_url#${icon_prefix}}" - test -s "${icon_path}" - if [[ "${icon_path}" == *.svg ]]; then - python3 - "${icon_path}" <<'PY' - import sys - import xml.etree.ElementTree as ET - - root = ET.parse(sys.argv[1]).getroot() - for element in root.iter(): - tag = element.tag.rsplit("}", 1)[-1].lower() - if tag == "script": - raise SystemExit("SVG scripts are not allowed") - for attribute, value in element.attrib.items(): - name = attribute.rsplit("}", 1)[-1].lower() - if name.startswith("on"): - raise SystemExit("SVG event handlers are not allowed") - if name == "href" and value and not value.startswith("#"): - raise SystemExit("External SVG references are not allowed") - PY - fi - done < <(jq --raw-output ".tokens[].logoURI" configs/token-lists/mainnet.json) + python3 -m unittest discover -s tools/token-list-validator -p "test_*.py" + python3 tools/token-list-validator/validate_assets.py configs/token-lists/mainnet.json diff --git a/configs/token-lists/README.md b/configs/token-lists/README.md index ee4b4a19..d5eb1c40 100644 --- a/configs/token-lists/README.md +++ b/configs/token-lists/README.md @@ -9,8 +9,37 @@ hosts its list at a stable HTTP(S) URL. Token icons belong in ## Adding a token -1. Add a permanent SVG or PNG under `configs/token-icons/`. -2. Add the token to the matching network JSON file. -3. Use the checksummed contract address and exact chain ID. -4. Increment the list version and timestamp. -5. Confirm the contract and its on-chain metadata before merging. +1. Copy `token-entry.template.json` and replace every placeholder. +2. Confirm the contract exists on the target network and has non-empty bytecode. +3. Read `name()`, `symbol()`, and `decimals()` from the contract. Do not infer + these values from a website or deployment script. Replace the template's + `decimals` value with the returned integer. +4. Add a permanent SVG or PNG under `configs/token-icons/`. Use the token symbol + as the file name unless that would collide with another token. +5. Add the completed entry to the matching network list. Use the EIP-55 + checksummed contract address and the exact chain ID. +6. Update the list timestamp and semantic version: + - increment `patch` when correcting metadata or an icon; + - increment `minor` when adding or removing tokens; + - increment `major` only for an incompatible list-policy change. +7. Open the PR with `.github/PULL_REQUEST_TEMPLATE/token-list.md` and attach the + explorer URL plus the RPC or command output used for verification. + +## Icon rules + +- Use SVG when available; PNG is accepted when no vector source exists. +- Keep the asset self-contained and permanent. +- SVG files must parse as XML and must not contain scripts, event handlers, or + references outside the same SVG. +- `logoURI` must use this canonical prefix: + `https://raw.githubusercontent.com/DOS/DOScan-Frontend-Configs/main/configs/token-icons/`. +- Do not use a mutable third-party CDN, project website, IPFS gateway, or data + URI as `logoURI`. + +## Review checklist + +- The address is not the zero address and is not duplicated in the list. +- The chain ID, name, symbol, and decimals match the deployed contract. +- The icon file exists, is non-empty, and passes the SVG safety checks. +- The list timestamp represents the change and its semantic version increased. +- The official Token Lists schema and all repository checks pass. diff --git a/configs/token-lists/token-entry.template.json b/configs/token-lists/token-entry.template.json new file mode 100644 index 00000000..8a990e93 --- /dev/null +++ b/configs/token-lists/token-entry.template.json @@ -0,0 +1,8 @@ +{ + "chainId": 7979, + "address": "REPLACE_WITH_CHECKSUMMED_CONTRACT_ADDRESS", + "name": "REPLACE_WITH_TOKEN_NAME", + "symbol": "REPLACE_WITH_SYMBOL", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/DOS/DOScan-Frontend-Configs/main/configs/token-icons/REPLACE_WITH_SYMBOL.svg" +} diff --git a/tools/token-list-validator/test_validate_assets.py b/tools/token-list-validator/test_validate_assets.py new file mode 100644 index 00000000..7b72fb34 --- /dev/null +++ b/tools/token-list-validator/test_validate_assets.py @@ -0,0 +1,134 @@ +import base64 +import tempfile +import unittest +from pathlib import Path + +from validate_assets import validate_png, validate_svg + + +VALID_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +) + + +class TokenIconValidationTest(unittest.TestCase): + def write(self, name: str, content: str | bytes) -> Path: + path = Path(self.directory.name) / name + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content, encoding="utf-8") + return path + + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory() + + def tearDown(self) -> None: + self.directory.cleanup() + + def test_accepts_internal_svg_fragment(self) -> None: + path = self.write( + "valid.svg", + '', + ) + validate_svg(path) + + def test_rejects_external_css_import(self) -> None: + path = self.write( + "external.svg", + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_rejects_external_style_url(self) -> None: + path = self.write( + "external.svg", + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_rejects_css_escaped_url_function(self) -> None: + path = self.write( + "escaped-url.svg", + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_rejects_css_escaped_external_url(self) -> None: + path = self.write( + "escaped-external.svg", + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_rejects_css_escaped_import(self) -> None: + path = self.write( + "escaped-import.svg", + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_rejects_css_escaped_fill_attribute(self) -> None: + path = self.write( + "escaped-fill.svg", + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_rejects_css_escaped_filter_attribute(self) -> None: + path = self.write( + "escaped-filter.svg", + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_rejects_css_comment_obfuscated_url(self) -> None: + path = self.write( + "comment-url.svg", + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_rejects_css_escape_generated_comment(self) -> None: + path = self.write( + "escaped-comment.svg", + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_rejects_xml_stylesheet_processing_instruction(self) -> None: + path = self.write( + "processing-instruction.svg", + '\n' + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_rejects_event_handler(self) -> None: + path = self.write( + "event.svg", + '', + ) + with self.assertRaises(ValueError): + validate_svg(path) + + def test_accepts_structurally_valid_png(self) -> None: + validate_png(self.write("valid.png", VALID_PNG)) + + def test_rejects_fake_png(self) -> None: + with self.assertRaises(ValueError): + validate_png(self.write("fake.png", b"not a png")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/token-list-validator/validate_assets.py b/tools/token-list-validator/validate_assets.py new file mode 100644 index 00000000..80eb7013 --- /dev/null +++ b/tools/token-list-validator/validate_assets.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +import json +import re +import struct +import sys +import xml.etree.ElementTree as ET +import zlib +from pathlib import Path +from urllib.parse import urlparse + +ICON_PREFIX = "https://raw.githubusercontent.com/DOS/DOScan-Frontend-Configs/main/" +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +MAX_PNG_DECOMPRESSED_BYTES = 32 * 1024 * 1024 +BLOCKED_SVG_TAGS = {"embed", "foreignobject", "iframe", "object", "script", "style"} +EXTERNAL_SCHEME = re.compile(r"(?:https?:|data:|file:|javascript:|//)", re.IGNORECASE) +CSS_URL = re.compile(r"url\(\s*(['\"]?)(.*?)\1\s*\)", re.IGNORECASE) +CSS_ESCAPE = re.compile( + r"\\(?:([0-9a-fA-F]{1,6})(?:\r\n|[ \t\r\n\f])?|(\r\n|[\n\r\f])|(.))", + re.DOTALL, +) + + +def fail(message: str) -> None: + raise ValueError(message) + + +def local_name(value: str) -> str: + return value.rsplit("}", 1)[-1].lower() + + +def decode_css_escapes(value: str) -> str: + def replace(match: re.Match[str]) -> str: + if match.group(1): + codepoint = int(match.group(1), 16) + if codepoint == 0 or codepoint > 0x10FFFF: + return "\uFFFD" + return chr(codepoint) + if match.group(2): + return "" + return match.group(3) or "" + + decoded = value + for _ in range(4): + next_value = CSS_ESCAPE.sub(replace, decoded) + if next_value == decoded: + break + decoded = next_value + return decoded + + +def validate_text_references(value: str, source: Path) -> None: + if "/*" in value or "*/" in value: + fail(f"CSS comments are not allowed in {source}") + value = decode_css_escapes(value) + if "/*" in value or "*/" in value: + fail(f"CSS comments are not allowed in {source}") + if "@import" in value.lower() or EXTERNAL_SCHEME.search(value): + fail(f"External SVG reference is not allowed in {source}") + for match in CSS_URL.finditer(value): + target = match.group(2).strip() + if target and not target.startswith("#"): + fail(f"Only fragment SVG URLs are allowed in {source}: {target}") + + +def validate_svg(path: Path) -> None: + for _event, _instruction in ET.iterparse(path, events=("pi",)): + fail(f"XML processing instructions are not allowed in {path}") + root = ET.parse(path).getroot() + for element in root.iter(): + if local_name(element.tag) in BLOCKED_SVG_TAGS: + fail(f"Active SVG element is not allowed in {path}: {local_name(element.tag)}") + if element.text: + validate_text_references(element.text, path) + if element.tail: + validate_text_references(element.tail, path) + for attribute, value in element.attrib.items(): + name = local_name(attribute) + if name.startswith("on"): + fail(f"SVG event handler is not allowed in {path}: {name}") + if name == "style": + fail(f"SVG style attributes are not allowed in {path}") + if name == "href" and value and not value.startswith("#"): + fail(f"External SVG href is not allowed in {path}: {value}") + validate_text_references(value, path) + + +def validate_png(path: Path) -> None: + data = path.read_bytes() + if not data.startswith(PNG_SIGNATURE): + fail(f"Invalid PNG signature: {path}") + + offset = len(PNG_SIGNATURE) + chunk_index = 0 + idat = bytearray() + saw_ihdr = False + saw_iend = False + + while offset < len(data): + if offset + 12 > len(data): + fail(f"Truncated PNG chunk: {path}") + length = struct.unpack(">I", data[offset : offset + 4])[0] + chunk_type = data[offset + 4 : offset + 8] + chunk_end = offset + 12 + length + if chunk_end > len(data): + fail(f"Truncated PNG payload: {path}") + + payload = data[offset + 8 : offset + 8 + length] + expected_crc = struct.unpack(">I", data[offset + 8 + length : chunk_end])[0] + actual_crc = zlib.crc32(chunk_type + payload) & 0xFFFFFFFF + if actual_crc != expected_crc: + fail(f"Invalid PNG chunk checksum: {path}") + + if chunk_index == 0: + if chunk_type != b"IHDR" or length != 13: + fail(f"PNG must start with a valid IHDR chunk: {path}") + width, height = struct.unpack(">II", payload[:8]) + if width == 0 or height == 0: + fail(f"PNG dimensions must be non-zero: {path}") + saw_ihdr = True + elif chunk_type == b"IDAT": + idat.extend(payload) + elif chunk_type == b"IEND": + if length != 0 or chunk_end != len(data): + fail(f"PNG must end at an empty IEND chunk: {path}") + saw_iend = True + break + + chunk_index += 1 + offset = chunk_end + + if not saw_ihdr or not idat or not saw_iend: + fail(f"PNG is missing IHDR, IDAT, or IEND: {path}") + + decompressor = zlib.decompressobj() + decompressed = decompressor.decompress(bytes(idat), MAX_PNG_DECOMPRESSED_BYTES + 1) + if len(decompressed) > MAX_PNG_DECOMPRESSED_BYTES or not decompressor.eof: + fail(f"PNG image data is invalid or exceeds the safety limit: {path}") + + +def validate_token_list(list_path: Path, repository_root: Path) -> None: + token_list = json.loads(list_path.read_text(encoding="utf-8")) + for token in token_list["tokens"]: + logo_uri = token["logoURI"] + parsed = urlparse(logo_uri) + if not logo_uri.startswith(ICON_PREFIX) or parsed.query or parsed.fragment: + fail(f"Non-canonical logoURI: {logo_uri}") + + asset_path = repository_root / logo_uri.removeprefix(ICON_PREFIX) + if not asset_path.is_file() or asset_path.stat().st_size == 0: + fail(f"Token icon is missing or empty: {asset_path}") + if asset_path.suffix.lower() == ".svg": + validate_svg(asset_path) + elif asset_path.suffix.lower() == ".png": + validate_png(asset_path) + else: + fail(f"Unsupported token icon type: {asset_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("usage: validate_assets.py TOKEN_LIST_JSON") + source = Path(sys.argv[1]).resolve() + validate_token_list(source, Path.cwd().resolve())