From 50d20751702b3fa76d1f34e21ef2aafed21c46bf Mon Sep 17 00:00:00 2001 From: hunter-baddie Date: Sat, 17 Oct 2026 18:31:41 +0100 Subject: [PATCH] feat:Contract Event Naming Convention Tests --- .../workflows/validate-contract-events.yml | 11 ++ docs/CONTRACT_EVENTS.md | 15 ++ ...contract_event_conventions.cpython-314.pyc | Bin 0 -> 7702 bytes ...contract_event_conventions.cpython-314.pyc | Bin 0 -> 6213 bytes scripts/check_contract_event_conventions.py | 142 ++++++++++++++++++ scripts/test_contract_event_conventions.py | 102 +++++++++++++ 6 files changed, 270 insertions(+) create mode 100644 scripts/__pycache__/check_contract_event_conventions.cpython-314.pyc create mode 100644 scripts/__pycache__/test_contract_event_conventions.cpython-314.pyc create mode 100644 scripts/check_contract_event_conventions.py create mode 100644 scripts/test_contract_event_conventions.py diff --git a/.github/workflows/validate-contract-events.yml b/.github/workflows/validate-contract-events.yml index 7f1d91be..f5411519 100644 --- a/.github/workflows/validate-contract-events.yml +++ b/.github/workflows/validate-contract-events.yml @@ -4,13 +4,19 @@ on: pull_request: paths: - "contract/**/events.rs" + - "contract/**/*.rs" - "docs/CONTRACT_EVENTS.md" - "scripts/validate-contract-events.py" + - "scripts/check_contract_event_conventions.py" + - "scripts/test_contract_event_conventions.py" push: branches: [main] paths: - "contract/**/events.rs" + - "contract/**/*.rs" - "docs/CONTRACT_EVENTS.md" + - "scripts/check_contract_event_conventions.py" + - "scripts/test_contract_event_conventions.py" concurrency: group: event-docs-${{ github.ref }} @@ -36,3 +42,8 @@ jobs: - name: Validate Contract Event Documentation Drift run: | python3 scripts/validate-contract-events.py + + - name: Test contract event naming and topic conventions + run: | + python3 -m unittest discover -s scripts -p 'test_contract_event_conventions.py' + python3 scripts/check_contract_event_conventions.py diff --git a/docs/CONTRACT_EVENTS.md b/docs/CONTRACT_EVENTS.md index 3f2c3a5c..b1015d70 100644 --- a/docs/CONTRACT_EVENTS.md +++ b/docs/CONTRACT_EVENTS.md @@ -194,3 +194,18 @@ Run the documentation drift validation script: ```bash python3 scripts/validate-contract-events.py ``` + +## 4. Naming and Topic Conventions + +- Declare event structs with unique `PascalCase` names. Soroban exposes the generated event name in `snake_case`. +- Use `snake_case` for event fields. +- Mark indexed fields with `#[topic]` and place all indexed fields before unindexed data fields. +- Declare no more than four indexed fields per event. +- When both `category` and `priority` are indexed, keep them as the final two topics in that order. + +Run the naming and topic convention tests and validator: + +```bash +python3 -m unittest discover -s scripts -p 'test_contract_event_conventions.py' +python3 scripts/check_contract_event_conventions.py +``` diff --git a/scripts/__pycache__/check_contract_event_conventions.cpython-314.pyc b/scripts/__pycache__/check_contract_event_conventions.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf85cf2343d66102849b7c0ba9b815d0ee8beef0 GIT binary patch literal 7702 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 S-kp7C_SYBxp^{*aROH`43G-Y4 literal 0 HcmV?d00001 diff --git a/scripts/check_contract_event_conventions.py b/scripts/check_contract_event_conventions.py new file mode 100644 index 00000000..98f5fd7c --- /dev/null +++ b/scripts/check_contract_event_conventions.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Validate Soroban contract event names and indexed topic layouts.""" + +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +EVENT_PATTERN = re.compile( + r"#\[contractevent(?:[^\]]*)\]\s*(?:#\[derive[^\]]*\]\s*)*" + r"pub struct\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([^}]*)\}" +) +FIELD_PATTERN = re.compile(r"pub\s+([A-Za-z_][A-Za-z0-9_]*)\s*:") +EVENT_NAME_PATTERN = re.compile(r"^[A-Z][a-zA-Z0-9]*$") +FIELD_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") +COMMENT_PATTERN = re.compile(r"//[^\n]*|/\*[\s\S]*?\*/") +MAX_TOPICS = 4 + + +@dataclass(frozen=True) +class ContractEvent: + name: str + fields: tuple[tuple[str, bool], ...] + path: str + line: int + + +def extract_contract_events(source: str, path: str = "") -> list[ContractEvent]: + source_without_comments = COMMENT_PATTERN.sub( + lambda match: "\n" * match.group(0).count("\n"), source + ) + events = [] + + for match in EVENT_PATTERN.finditer(source_without_comments): + body = match.group(2) + fields = [] + topic_pending = False + for line in body.splitlines(): + stripped = line.strip() + if stripped == "#[topic]": + topic_pending = True + continue + + field_match = FIELD_PATTERN.search(stripped) + if field_match: + fields.append((field_match.group(1), topic_pending)) + topic_pending = False + + events.append( + ContractEvent( + name=match.group(1), + fields=tuple(fields), + path=path, + line=source_without_comments.count("\n", 0, match.start()) + 1, + ) + ) + + return events + + +def validate_event_definitions(events: list[ContractEvent]) -> list[str]: + errors = [] + seen_names = {} + + for event in events: + location = f"{event.path}:{event.line}" + if not EVENT_NAME_PATTERN.fullmatch(event.name): + errors.append( + f"{location}: event {event.name!r} must use PascalCase " + "(for example, NotificationScheduled)" + ) + + previous = seen_names.get(event.name) + if previous: + errors.append( + f"{location}: event name {event.name!r} is also defined at {previous}; " + "rename one event to keep generated event names unique" + ) + else: + seen_names[event.name] = location + + topic_names = [name for name, is_topic in event.fields if is_topic] + if len(topic_names) > MAX_TOPICS: + errors.append( + f"{location}: event {event.name} declares {len(topic_names)} indexed fields; " + f"Soroban contract events support at most {MAX_TOPICS} (excluding the event name)" + ) + + data_field_seen = False + for name, is_topic in event.fields: + if not FIELD_NAME_PATTERN.fullmatch(name): + errors.append( + f"{location}: field {name!r} in event {event.name} must use snake_case" + ) + if is_topic and data_field_seen: + errors.append( + f"{location}: topic field {name!r} in event {event.name} appears after a data field; " + "move all #[topic] fields before unindexed fields" + ) + if not is_topic: + data_field_seen = True + + if "priority" in topic_names and ( + len(topic_names) < 2 or topic_names[-2:] != ["category", "priority"] + ): + errors.append( + f"{location}: event {event.name} indexes priority without category immediately before it; " + "place category and priority as the final two topics" + ) + + return errors + + +def main() -> int: + repo_root = Path(__file__).resolve().parent.parent + contract_root = repo_root / "contract" + events = [] + + for source_path in sorted(contract_root.rglob("*.rs")): + relative_path = source_path.relative_to(repo_root).as_posix() + events.extend( + extract_contract_events(source_path.read_text(encoding="utf-8"), relative_path) + ) + + if not events: + print(f"No #[contractevent] definitions found under {contract_root}", file=sys.stderr) + return 1 + + errors = validate_event_definitions(events) + if errors: + print("Contract event convention violations:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + print(f"Validated naming and topic conventions for {len(events)} contract events.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/scripts/test_contract_event_conventions.py b/scripts/test_contract_event_conventions.py new file mode 100644 index 00000000..2535d19d --- /dev/null +++ b/scripts/test_contract_event_conventions.py @@ -0,0 +1,102 @@ +import unittest + +from check_contract_event_conventions import ( + extract_contract_events, + validate_event_definitions, +) + + +class ContractEventConventionTests(unittest.TestCase): + def test_valid_event_name_and_four_topics_pass(self): + source = """ +#[contractevent] +pub struct NotificationScheduled { + #[topic] + pub creator: Address, + #[topic] + pub notification_id: BytesN<32>, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub payload_version: u32, +} +""" + events = extract_contract_events(source, "fixture.rs") + + self.assertEqual(validate_event_definitions(events), []) + + def test_pascal_case_and_snake_case_are_required(self): + source = """ +#[contractevent] +pub struct notification_scheduled { + #[topic] + pub creatorAddress: Address, +} +""" + errors = validate_event_definitions(extract_contract_events(source, "fixture.rs")) + + self.assertTrue(any("must use PascalCase" in error for error in errors)) + self.assertTrue(any("must use snake_case" in error for error in errors)) + + def test_topics_must_precede_data_fields(self): + source = """ +#[contractevent] +pub struct NotificationScheduled { + pub payload_version: u32, + #[topic] + pub creator: Address, +} +""" + errors = validate_event_definitions(extract_contract_events(source, "fixture.rs")) + + self.assertTrue(any("appears after a data field" in error for error in errors)) + + def test_too_many_topics_are_reported(self): + source = """ +#[contractevent] +pub struct TooManyTopics { + #[topic] + pub first: Address, + #[topic] + pub second: Address, + #[topic] + pub third: Address, + #[topic] + pub fourth: Address, + #[topic] + pub fifth: Address, +} +""" + errors = validate_event_definitions(extract_contract_events(source, "fixture.rs")) + + self.assertTrue(any("at most 4" in error for error in errors)) + + def test_priority_requires_category_as_the_previous_topic(self): + source = """ +#[contractevent] +pub struct PriorityEvent { + #[topic] + pub creator: Address, + #[topic] + pub priority: NotificationPriority, +} +""" + errors = validate_event_definitions(extract_contract_events(source, "fixture.rs")) + + self.assertTrue(any("final two topics" in error for error in errors)) + + def test_duplicate_event_names_are_reported(self): + source = """ +#[contractevent] +pub struct DuplicateEvent { pub id: u32 } +#[contractevent] +pub struct DuplicateEvent { pub id: u64 } +""" + errors = validate_event_definitions(extract_contract_events(source, "fixture.rs")) + + self.assertTrue(any("also defined at" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file