Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/token-list.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 4 additions & 23 deletions .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 34 additions & 5 deletions configs/token-lists/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions configs/token-lists/token-entry.template.json
Original file line number Diff line number Diff line change
@@ -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"
}
134 changes: 134 additions & 0 deletions tools/token-list-validator/test_validate_assets.py
Original file line number Diff line number Diff line change
@@ -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",
'<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="g"/></defs><path fill="url(#g)"/></svg>',
)
validate_svg(path)

def test_rejects_external_css_import(self) -> None:
path = self.write(
"external.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><style>@import url("https://example.com/a.css");</style></svg>',
)
with self.assertRaises(ValueError):
validate_svg(path)

def test_rejects_external_style_url(self) -> None:
path = self.write(
"external.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><path style="fill:url(https://example.com/a.svg)"/></svg>',
)
with self.assertRaises(ValueError):
validate_svg(path)

def test_rejects_css_escaped_url_function(self) -> None:
path = self.write(
"escaped-url.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><path style="fill:u\\72l(evil.svg)"/></svg>',
)
with self.assertRaises(ValueError):
validate_svg(path)

def test_rejects_css_escaped_external_url(self) -> None:
path = self.write(
"escaped-external.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><path style="fill:u\\72l(h\\74tps\\3a\\2f\\2f example.com/a.svg)"/></svg>',
)
with self.assertRaises(ValueError):
validate_svg(path)

def test_rejects_css_escaped_import(self) -> None:
path = self.write(
"escaped-import.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><style>@im\\70ort "evil.css"</style></svg>',
)
with self.assertRaises(ValueError):
validate_svg(path)

def test_rejects_css_escaped_fill_attribute(self) -> None:
path = self.write(
"escaped-fill.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><path fill="u\\72l(evil.svg)"/></svg>',
)
with self.assertRaises(ValueError):
validate_svg(path)

def test_rejects_css_escaped_filter_attribute(self) -> None:
path = self.write(
"escaped-filter.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><path filter="u\\72l(h\\74tps\\3a\\2f\\2f example.com/f.svg#x)"/></svg>',
)
with self.assertRaises(ValueError):
validate_svg(path)

def test_rejects_css_comment_obfuscated_url(self) -> None:
path = self.write(
"comment-url.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><path fill="u/**/rl(evil.svg)"/></svg>',
)
with self.assertRaises(ValueError):
validate_svg(path)

def test_rejects_css_escape_generated_comment(self) -> None:
path = self.write(
"escaped-comment.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><path fill="u\\2f\\2a x\\2a\\2f rl(evil.svg)"/></svg>',
)
with self.assertRaises(ValueError):
validate_svg(path)

def test_rejects_xml_stylesheet_processing_instruction(self) -> None:
path = self.write(
"processing-instruction.svg",
'<?xml-stylesheet type="text/css" href="https://example.com/evil.css"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg"><path/></svg>',
)
with self.assertRaises(ValueError):
validate_svg(path)

def test_rejects_event_handler(self) -> None:
path = self.write(
"event.svg",
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/>',
)
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()
Loading
Loading