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())