Audience: New contributors, integrators, and reviewers who want a single document that explains how NotifyChain fits together before they dig into the per-component docs.
This guide gives you a high-level mental model of the system: what each layer is responsible for, how data moves between them, and which files in the repository implement each piece. It does not replace the deeper subsystem docs linked at the end — read it first, then dive into the linked material as needed.
NotifyChain is an event monitoring and notification platform for Stellar (Soroban) smart contracts. It exists to make on-chain activity visible and actionable to humans and external systems without requiring continuous RPC polling from every consumer.
The system solves three problems:
- Discoverability — turning low-level Soroban contract events into a structured, queryable feed.
- Delivery — pushing that feed to Discord webhooks, scheduled notifications, and HTTP API consumers.
- Insight — rendering the feed through a React dashboard for human operators.
The project is structured so that each of these concerns lives in a separate layer that can be developed, tested, and deployed independently.
┌──────────────────────────────────────────────┐
│ NotifyChain │
│ │
On-chain │ ┌────────────────────┐ │
┌────────────┐ │ │ Soroban Contracts │ emit ┌───────────┐ │
│ Users / │ │ │ (TaskBounty, │ ─────▶ │ Stellar │ │
│ dApps │ │ │ AutoShare, …) │ events │ Network │ │
└─────┬──────┘ │ └────────────────────┘ └─────┬─────┘ │
│ invoke│ │ │
▼ │ ▼ │
┌────────────┐ │ ┌────────────────────────────────────────┐ │
│ Contract │ │ │ Listener Service │ │
│ Calls │ │ │ (Node.js + TypeScript off-chain) │ │
└────────────┘ │ │ │ │
│ │ EventSubscriber ─▶ Deduplicator │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────┐ ┌───────────────┐ │ │
│ │ │ Notification │ │ REST API │ │ │
│ │ │ Dispatcher │ │ /api/events │ │ │
│ │ └──────┬───────┘ └───────┬───────┘ │ │
│ └─────────┼──────────────────┼───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ Discord / │ │ Dashboard │ │
│ │ Webhook / │ │ (React + Vite) │ │
│ │ Email target │ │ │ │
│ └────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────┘
| Layer | Where it lives | Tech | One-line responsibility |
|---|---|---|---|
| Smart Contracts | contract/, Documents/Task Bounty/ |
Soroban / Rust | Execute business logic; emit one structured event per state change |
| Listener Service | listener/ |
Node.js + TypeScript | Poll the network, deduplicate events, dispatch notifications, expose HTTP API |
| Dashboard | dashboard/ |
React + Vite | Render the events feed for human operators |
| Documentation | *.md, Documents/, issues/ |
Markdown | Onboarding, architecture, contracts, ops runbooks |
The on-chain layer is the canonical source of truth. Every state transition is recorded in contract storage and published as a typed Soroban event. The listener never infers state from anything other than those events.
Two reference contracts ship with the repository:
| Contract | Path | Purpose |
|---|---|---|
| TaskBounty | Documents/Task Bounty/ |
Decentralized task + reward board. Users create tasks with escrowed rewards, submit work, approve/reject submissions, raise disputes, and trigger payouts. |
| AutoShare | contract/contracts/hello-world/ |
Subscription and group management. Handles group creation, member management, subscription payments, usage tracking, admin controls, and version exposure. |
Both contracts follow the same skeleton:
contracts/<name>/
├── src/
│ ├── base/
│ │ ├── errors.rs # Error variants and codes
│ │ ├── events.rs # Soroban event types emitted on-chain
│ │ └── types.rs # Data structures (structs, enums)
│ ├── interfaces/ # Optional trait-style abstractions
│ ├── tests/ # Soroban test harness using Env::default()
│ ├── <name>_logic.rs # Core business logic
│ └── lib.rs # Contract entry point (#[contract])
├── Cargo.toml
└── Makefile
The repo deliberately ships two contracts with different shapes so the event-emission patterns stay general:
- TaskBounty uses an explicit lifecycle (
Open → InProgress → Completed / Cancelled / Disputed) and emits per-lifecycle events. It's a good model for any "entity with stages" pattern. - AutoShare uses a smaller CRUD surface (create group, add member, pay
subscription) and emits a uniform
Actionevent tagged with an enum. It's a good model for "actions against a singleton resource" pattern.
When you add a new contract, decide which shape it fits before writing the event types — the listener's deduplicator keys on event topic hash, so adding a new contract is additive, but the event schema should be stable across versions.
The contract layer commits to a single rule:
Anything the listener needs to know is published as a Soroban event.
If a state change is not published as an event, the listener will never see it. Conversely, the listener treats off-chain state (its own database, its in-memory cache) as a derivable cache — it can be rebuilt from the event stream at any time by replaying the chain.
This boundary keeps contracts simple and the listener replaceable.
The listener is a long-running Node.js process that turns the raw Soroban event stream into three concrete things:
- A deduplicated, queryable event log (exposed via
GET /api/events). - Outbound notifications to Discord, webhooks, and (configurable) any HTTP target.
- A scheduler for future-dated notifications, with at-least-once delivery semantics backed by SQLite locks.
Stellar RPC
│ getEvents()
▼
┌────────────────────────────────────┐
│ EventSubscriber │
│ - Poll on interval │
│ - Cursor persisted to SQLite │
│ - Detect reorgs from ledger nums │
└────────────────┬───────────────────┘
│ raw events
▼
┌────────────────────────────────────────┐
│ Persistent Deduplication Layer │
│ - EventDeduplicationService │
│ - Check processed_events table │
│ - Mark reorg duplicates │
│ - Track polling cursors │
│ - (Prevents reorg-induced dups) │
└────────────────┬───────────────────────┘
│
▼
┌────────────────────────────────────┐
│ In-Memory Deduplicator │
│ - NotificationDeduplicator (LRU) │
│ - Event Registry │
│ - (Short-term cache layer) │
└────────────────┬───────────────────┘
│ normalized events
┌─────────┴──────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Notification │ │ REST API │
│ Dispatcher │ │ events-server.ts │
│ - Discord │ │ - /api/events │
│ - Webhook │ │ - /api/schedule │
│ - Email │ │ - /api/stats │
└──────────────────┘ └──────────────────┘
▲
│ GET
│
┌──────────┴──────────┐
│ Dashboard (React) │
└─────────────────────┘
To handle blockchain reorganizations (reorgs), NotifyChain employs two-layer deduplication:
- Service:
EventDeduplicationServiceinlistener/src/services/ - Storage:
processed_eventsandpolling_cursorsSQLite tables - Guarantees:
- Permanent record of all processed events
- Detects reorg duplicates by ledger number comparison
- Persists cursor positions for each contract
- Survives service restarts
- Service:
NotificationDeduplicator(existing) - Storage: In-memory LRU map (60-second default window)
- Purpose: Catch recent duplicates without DB hits
- Complement: Works alongside persistent layer
How Reorg Detection Works:
- Each polling cycle, compare event ledger with last known
polling_cursors.ledger - If new ledger < last ledger → reorg detected
- Increment
polling_cursors.reorg_detection_count - When same event re-appears, it's marked as
is_reorg_duplicate = true - Application skips duplicate notification and Discord send
Example Reorg Scenario:
Normal flow: Events: e1(L100), e2(L105), e3(L110)
Cursor: L110
Reorg occurs: Ledger drops to L95
Cursor detects: 95 < 110 → REORG!
Recovery: Re-fetch e1(L100), e2(L105)
Both detected as duplicates
Notifications skipped (already sent)
For detailed monitoring, troubleshooting, and operational guidance, see:
REORG-DEDUPLICATION-MONITORING.md— Metrics, alerts, and best practiceslistener/src/services/event-deduplication-service.ts— Implementation
| Path | Role |
|---|---|
listener/src/index.ts |
Process entry point. Wires the subscriber, the dispatcher, and the HTTP server. |
listener/src/services/ |
Domain services (event subscriber, scheduler, dispatcher). |
listener/src/store/ |
Persistence layer — SQLite-backed event + schedule repositories. |
listener/src/database/ |
SQLite migrations and schema. |
listener/src/api/ |
HTTP routes (events server, scheduler endpoints). |
listener/src/scripts/ |
Operational scripts (migrate, batch-validate). |
listener/src/types/ |
Shared TypeScript types mirroring on-chain event shapes. |
listener/src/utils/ |
Cross-cutting helpers (logging, batching, fixture builders). |
listener/src/test-utils/ |
Reusable test fixtures and builders. |
listener/src/__tests__/ and listener/src/tests/ |
Unit and integration tests (Jest). |
listener/src/examples/ |
Runnable example consumers of the public API. |
The scheduler is the part of the listener that handles future-dated notifications. It is the most operationally complex piece because it has to deal with at-least-once delivery across crashes and multi-instance deployments.
┌──────────┐ schedule ┌──────────────┐ store ┌──────────────┐
│ Caller │ ─────────▶ │ Notification │ ───────▶ │ SQLite │
└──────────┘ │ API │ │ (scheduled_) │
└──────────────┘ │ notifications│
└──────┬───────┘
│ tick (10s)
▼
┌────────────────────────┐
│ Background Scheduler │
│ 1. Recover stale lock │
│ 2. Fetch PENDING due │
│ 3. Atomic lock UPDATE │
│ 4. Dispatch │
└────────────────────────┘
The atomicity guarantee comes from a single SQL pattern:
UPDATE scheduled_notifications
SET status = 'PROCESSING',
processor_id = ?,
lock_expires_at = NOW() + 60s
WHERE id = ?
AND status = 'PENDING'The WHERE status = 'PENDING' predicate makes the lock acquisition
race-free across multiple listener instances. Only one worker can flip a
row to PROCESSING; the others see changedRows === 0 and skip it.
For a deeper view of the scheduler's lifecycle, retry strategy, and operational troubleshooting, see:
listener/ARCHITECTURE-DIAGRAM.md— Scheduler-specific diagrams.NOTIFICATION_FAILURE_RECOVERY.md— Retry semantics and recovery procedures.SCHEDULED-NOTIFICATIONS-DELIVERY.md— End-to-end delivery semantics.
The listener reads its config from environment variables (see
listener/src/config.ts). The minimum required to run it locally:
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
LISTENER_DATABASE_PATH=./listener.db
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
LISTENER_PORT=3000The full list — including scheduler intervals, batch sizes, retry budgets,
and feature flags — lives in listener/src/config.ts. New contributors
should read this file before proposing changes to defaults.
The dashboard is a single-page React app served from dashboard/. It is a
strict read-only consumer of the listener's HTTP API. It never talks to
Stellar directly and never writes back to the listener.
dashboard/
├── src/
│ ├── pages/ # Top-level routes (Events, Schedules, Stats)
│ ├── components/ # Reusable UI primitives (cards, lists, search)
│ ├── hooks/ # Custom React hooks for API calls
│ ├── services/ # HTTP client wrappers
│ ├── store/ # State management
│ ├── types/ # TypeScript types shared with the listener API
│ ├── utils/ # Helpers (formatters, dates, validators)
│ └── benchmark/ # Performance regression suite
├── index.html
├── vite.config.ts
└── package.json
Keeping the dashboard downstream of the listener keeps the trust boundary
clean: the dashboard cannot pollute the event stream or skip the
deduplicator. If a new feature requires writing to the listener (e.g.
"resend this notification"), the right pattern is to add a new endpoint
under listener/src/api/ and call it from the dashboard — never bypass.
The dashboard surfaces search and filter controls on top of GET /api/events. Filter parameters are passed through to the listener's
SQLite query layer, which keeps indexes on the hot columns. See
PR #122 (search bar with debounced autocomplete) for the canonical
implementation pattern when adding new filters.
This is the canonical happy-path trace, from contract invocation to dashboard render.
① User calls a contract method (e.g. `task_bounty.create_task(...)`)
│
▼
② Contract mutates storage and emits a Soroban event
│ topics: [("task_bounty", task_id), ...]
│ data: (TaskPayload)
▼
③ Listener's EventSubscriber polls the RPC, receives the event
│
▼
④ Deduplicator checks the in-memory + DB cache
│ first time seen → mark seen, continue
│ already seen → drop
▼
⑤ Event is normalized to the listener's internal schema and
persisted to the SQLite `events` table
│
├──────────────┐
▼ ▼
⑥a Notification ⑥b REST API
Dispatcher (events-server.ts)
│ │
▼ ▼
⑦a Webhook / ⑦b Dashboard fetches
Discord / /api/events and
Email target renders new entry
│
▼
⑧ Human sees the notification; clicks through to dashboard;
sees full event history including this one
If any step between ③ and ⑦ fails, the event is still in the SQLite store (step ⑤), so the system can recover by replaying. The listener's transaction boundary is the SQLite write — not the network call.
Notify-Chain/
├── contract/ Soroban workspace
│ ├── contracts/
│ │ └── hello-world/ AutoShare contract (Soroban)
│ └── Cargo.toml Workspace manifest
├── dashboard/ React + Vite dashboard (read-only)
├── listener/ Node.js + TypeScript listener
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── Documents/ Reference docs and contract variants
│ ├── Task Bounty/ TaskBounty contract + its own ARCHITECTURE.md
│ └── Stellar-save/ Archived design specs (read-only context)
├── issues/ Issue templates + workflow specs
├── .github/ Workflows, PR templates, issue forms
├── *.md Repo-root docs (README, this guide, runbooks)
└── ARCHITECTURE_OVERVIEW.md ← you are here
When you start working on a component:
- Touching a contract? Start in
contract/contracts/<name>/src/and re-readDocuments/Task Bounty/ARCHITECTURE.mdto keep event-schemas aligned with the listener. - Touching the listener? Start in
listener/src/index.ts, then follow the module map in §4.2. - Touching the dashboard? Start in
dashboard/src/pages/and check the listener API contract inlistener/src/api/before changing request shapes. - Writing or editing docs? Update this guide (and the linked subsystem docs) in the same PR so the architecture stays accurate.
A practical first-day checklist:
- Read this file end-to-end (≈ 15 minutes).
- Skim the README.md at the repo root — it links to the runbooks and the local-dev guide.
- Set up the local dev environment following
SETUP.md(or the equivalent section inDocuments/Task Bounty/SETUP.md). - Pick a
good first issuefrom the issue tracker. Issues labeledMaybe Rewarded+GrantFox OSSare actively funded and have an assigned reviewer. - Read the relevant subsystem doc (links in §9 below) before writing any code.
- Run the test suite for the layer you're touching — the listener
uses Jest, the contracts use
cargo test, the dashboard uses Jest. - Open a draft PR early so CI feedback starts flowing. Do not wait until "everything is done" to push.
If you get stuck, file an issue or drop a comment on the relevant PR — maintainers track open questions actively.
Documents/Task Bounty/ARCHITECTURE.md— TaskBounty contract lifecycle, state machines, and event schema.listener/ARCHITECTURE-DIAGRAM.md— Scheduler subsystem diagrams.Documents/Task Bounty/PROJECT_OVERVIEW.md— High-level project pitch for the TaskBounty variant.
README.md— Top-level project intro and quick links.CONTRIBUTING.md— Contribution workflow, DCO, PR conventions.TROUBLESHOOTING.md— Common failure modes and fixes.NOTIFICATION_FAILURE_RECOVERY.md— Notification retry semantics.NOTIFICATION_PAYLOAD_SCHEMA.md— Canonical event payload fields.SCHEDULED-NOTIFICATIONS-DELIVERY.md— Delivery guarantees and edge cases.
Documents/Stellar-save/.kiro/specs/global-state-management/— Design notes for the listener's state-management layer. Read-only.
issues/— Issue templates and labeled workflows..github/— GitHub Actions, PR templates, CODEOWNERS.
| Term | Definition |
|---|---|
| Soroban | Stellar's smart-contract runtime. Compiles to WebAssembly. |
| Event | A structured log emitted by a contract on a state change. Has topics (filterable) and data (opaque payload). |
| Cursor | The listener's persisted "last seen ledger sequence" pointer, used to resume polling after restart. |
| Deduplicator | The in-listener component that drops events the system has already processed. Backed by an LRU cache + SQLite index. |
| Dispatcher | The in-listener component that turns a deduplicated event into outbound side effects (webhook, Discord, etc.). |
| Scheduled notification | A future-dated notification registered through POST /api/schedule. Backed by SQLite with atomic row-level locks. |
| PENDING / PROCESSING / COMPLETED / FAILED | The four terminal/intermediate states of a scheduled notification. See SCHEDULED-NOTIFICATIONS-DELIVERY.md. |
| DCO | Developer Certificate of Origin. All commits in this repo require a Signed-off-by: trailer. |
| GrantFox | The OSS campaign platform that funds Maybe Rewarded issues in this repo. Payouts are handled post-merge via the GrantFox dashboard. |
This guide is intentionally high-level. If you find a topic that deserves
its own deep-dive doc, please open an issue with the label
documentation and a short proposal.
Known gaps:
- A consolidated deployment guide (currently split across
SETUP.md,SCHEDULED-NOTIFICATIONS-DELIVERY.md, and the listener's README). - A security model doc covering auth boundaries, replay protection, and notification-target allowlists.
- An observability doc covering log schema, metrics, and tracing.
Last reviewed: 2026-06-21. Maintained as part of issue #137.