Skip to content
Open
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
38 changes: 38 additions & 0 deletions .github/workflows/validate-contract-events.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Contract Event Documentation Drift Check

on:
pull_request:
paths:
- "contract/**/events.rs"
- "docs/CONTRACT_EVENTS.md"
- "scripts/validate-contract-events.py"
push:
branches: [main]
paths:
- "contract/**/events.rs"
- "docs/CONTRACT_EVENTS.md"

concurrency:
group: event-docs-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
validate-events:
name: check-event-documentation-drift
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Validate Contract Event Documentation Drift
run: |
python3 scripts/validate-contract-events.py
196 changes: 196 additions & 0 deletions docs/CONTRACT_EVENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# 📡 Soroban Smart Contract Event Catalog

This document defines the comprehensive event taxonomy emitted by the NotifyChain Soroban smart contracts (Issue #714).

---

## 1. Event Category & Priority Topics

All events publish indexed `NotificationCategory` and `NotificationPriority` topics for off-chain listener filtering:

* **Category**: `Group (0)`, `Admin (1)`, `Financial (2)`, `Notification (3)`, `System (4)`
* **Priority**: `Low (0)`, `Medium (1)`, `High (2)`, `Critical (3)`

---

## 2. Event Taxonomy

### `AutoshareCreated`
Emitted when a new AutoShare group is created.
* **Topics**: `creator: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `id: BytesN<32>`

### `AutoshareUpdated`
Emitted when an AutoShare group's member list or parameters are modified.
* **Topics**: `updater: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `id: BytesN<32>`

### `GroupDeactivated`
Emitted when an AutoShare group is deactivated.
* **Topics**: `creator: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `id: BytesN<32>`

### `GroupActivated`
Emitted when an AutoShare group is re-activated.
* **Topics**: `creator: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `id: BytesN<32>`

### `CategoryRegistered`
Emitted when a new notification category is registered.
* **Topics**: `admin: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `category_id: u32`

### `ChannelPreferenceUpdated`
Emitted when user notification channel preferences are updated.
* **Topics**: `user: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `channel_id: BytesN<32>`

### `ContractPaused`
Emitted when an administrator pauses the contract.
* **Topics**: `admin: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `paused_at: u64`

### `ContractUnpaused`
Emitted when an administrator unpauses the contract.
* **Topics**: `admin: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `unpaused_at: u64`

### `AdminTransferred`
Emitted when administrative ownership is transferred.
* **Topics**: `old_admin: Address`, `new_admin: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `timestamp: u64`

### `AuthorizationFailure`
Emitted upon unauthorized invocation attempts.
* **Topics**: `caller: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `action: String`

### `Withdrawal`
Emitted when protocol funds are withdrawn.
* **Topics**: `recipient: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `amount: i128`

### `NotificationScheduled`
Emitted when a notification is queued for delivery.
* **Topics**: `sender: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `notification_id: BytesN<32>`

### `NotificationExpired`
Emitted when a notification exceeds its TTL without acknowledgment.
* **Topics**: `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `notification_id: BytesN<32>`

### `ScheduledNotificationCancelled`
Emitted when a scheduled notification is cancelled prior to delivery.
* **Topics**: `sender: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `notification_id: BytesN<32>`

### `NotificationDelivered`
Emitted when a notification is successfully received by off-chain endpoints.
* **Topics**: `recipient: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `notification_id: BytesN<32>`

### `NotificationRecalled`
Emitted when a pending notification is recalled by sender.
* **Topics**: `sender: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `notification_id: BytesN<32>`

### `NotificationRevoked`
Emitted when a delivered or pending notification is revoked.
* **Topics**: `revoker: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `notification_id: BytesN<32>`

### `NotificationExtended`
Emitted when a notification lifetime is extended.
* **Topics**: `updater: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `notification_id: BytesN<32>`

### `NotificationAcknowledged`
Emitted when a recipient acknowledges receipt.
* **Topics**: `recipient: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `notification_id: BytesN<32>`

### `SubscriptionCancelled`
Emitted when a channel subscription is cancelled.
* **Topics**: `subscriber: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `channel_id: BytesN<32>`

### `BatchNotificationsCreated`
Emitted when a batch of notifications is dispatched.
* **Topics**: `sender: Address`, `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `count: u32`

### `BatchProcessingCompleted`
Emitted when batch event processing finishes.
* **Topics**: `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `processed_count: u32`

### `AuditRecordAppended`
Emitted when an immutable audit trail entry is logged.
* **Topics**: `category: NotificationCategory`, `priority: NotificationPriority`
* **Data**: `record_id: u64`

### `OwnershipTransferInitiated`
Emitted when contract ownership transfer is queued.
* **Topics**: `current_owner: Address`, `pending_owner: Address`
* **Data**: `timestamp: u64`

### `OwnershipTransferred`
Emitted when contract ownership transfer is completed.
* **Topics**: `new_owner: Address`
* **Data**: `timestamp: u64`

### `ReputationUpdated`
Emitted when an account reputation score is updated.
* **Topics**: `account: Address`
* **Data**: `new_score: i128`

### `NotificationLimitsConfigured`
Emitted when global rate limits are configured.
* **Topics**: `admin: Address`
* **Data**: `limit: u32`

### `ReputationTierChanged`
Emitted when user reputation tier advances.
* **Topics**: `account: Address`
* **Data**: `tier: u32`

### `SchemaVersionSet`
Emitted when protocol schema version is updated.
* **Topics**: `version: u32`
* **Data**: `timestamp: u64`

### `NotificationAccessed`
Emitted upon reading secured notification content.
* **Topics**: `reader: Address`
* **Data**: `notification_id: BytesN<32>`

### `ChannelMetadataUpdated`
Emitted when channel metadata is modified.
* **Topics**: `channel_id: BytesN<32>`
* **Data**: `metadata_uri: String`

### `NotificationArchived`
Emitted when historical notifications are archived.
* **Topics**: `category: NotificationCategory`
* **Data**: `count: u32`

### `TemplateRegistered`
Emitted when a notification template is created.
* **Topics**: `template_id: BytesN<32>`
* **Data**: `name: String`

### `TemplateUpdated`
Emitted when a notification template is updated.
* **Topics**: `template_id: BytesN<32>`
* **Data**: `name: String`

---

## 3. Drift Validation

Run the documentation drift validation script:

```bash
python3 scripts/validate-contract-events.py
```
97 changes: 97 additions & 0 deletions scripts/validate-contract-events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
Contract Event Documentation Drift Validator (Issue #714)

Extracts #[contractevent] structs from Soroban Rust sources and validates them
against the documented event catalog in docs/CONTRACT_EVENTS.md.
"""

import os
import re
import sys
import argparse
from pathlib import Path

def extract_code_events(events_rs_path):
events = {}
if not events_rs_path.exists():
return events

content = events_rs_path.read_text(encoding='utf-8')

# Regex matching #[contractevent...] pub struct EventName { ... }
struct_matches = re.finditer(r'(?:#\[contractevent[^\]]*\]\s*)+(?:#\[derive[^\]]*\]\s*)*pub struct\s+([A-Za-z0-9_]+)\s*\{([^}]+)\}', content)

for match in struct_matches:
event_name = match.group(1)
body = match.group(2)
fields = []
topics = []

for line in body.splitlines():
line = line.strip()
if line.startswith('pub '):
# pub field_name: Type,
field_part = line.replace('pub ', '').split(':')[0].strip()
fields.append(field_part)

events[event_name] = {
'fields': fields,
}

return events

def extract_documented_events(docs_path):
documented = set()
if not docs_path.exists():
return documented

content = docs_path.read_text(encoding='utf-8')
# Match headers like `### AutoshareCreated` or `## AutoshareCreated`
matches = re.findall(r'###?\s+`?([A-Z][a-zA-Z0-9_]+)`?', content)
for m in matches:
documented.add(m)

return documented

def main():
parser = argparse.ArgumentParser(description="Validate contract events against documentation.")
parser.add_argument("--warn-only", action="store_true", help="Advisory mode")
args = parser.parse_args()

repo_root = Path(__file__).resolve().parent.parent
events_rs = repo_root / 'contract/contracts/hello-world/src/base/events.rs'
docs_md = repo_root / 'docs/CONTRACT_EVENTS.md'

print(f"🔍 Validating Contract Event Documentation Drift...\n")
print(f" Source: {events_rs}")
print(f" Docs: {docs_md}\n")

code_events = extract_code_events(events_rs)
doc_events = extract_documented_events(docs_md)

print(f"Found {len(code_events)} #[contractevent] structs in Rust implementation.")
print(f"Found {len(doc_events)} documented events in {docs_md.name}.\n")

missing_in_docs = []
for event_name in sorted(code_events.keys()):
if event_name not in doc_events:
missing_in_docs.append(event_name)

if missing_in_docs:
print(f"❌ Documentation Drift Detected! The following {len(missing_in_docs)} events are missing from docs:")
for name in missing_in_docs:
print(f" • {name} (Fields: {', '.join(code_events[name]['fields'])})")

if args.warn_only:
print("\n⚠️ Exiting 0 (warn-only mode enabled).")
sys.exit(0)
else:
print("\n❌ Please update docs/CONTRACT_EVENTS.md to resolve documentation drift.")
sys.exit(1)
else:
print("✅ All contract events are accurately documented with zero drift!")
sys.exit(0)

if __name__ == '__main__':
main()