Canonical documentation for how Notify-Chain turns on-chain contract events into off-chain deliveries, acknowledgments, retries, and archival.
Code paths referenced here live under
listener/src/. For on-chain event shapes and topic layouts, see CONTRACT_EVENT_REFERENCE.md. For retry configuration and failure recovery, see NOTIFICATION_FAILURE_RECOVERY.md.
This document covers:
- End-to-end step-by-step lifecycle (event detection → delivery → ack/archive)
- On-chain vs off-chain flow
- Roles of each system component
- The durable scheduled-notification path (API / SQLite)
- Acknowledgment, retry, and archival semantics
- High-Level Overview
- Full Cross-Layer Flow (Diagram)
- On-Chain Flow
- Off-Chain Flow — Real-Time Path
- Off-Chain Flow — Scheduled Path
- Component Roles
- Acknowledgment
- Retry and Failure Handling
- Completion and Archival
- Dashboard Visibility
- Developer Notes
- Troubleshooting
Notify-Chain has two delivery paths that share Discord delivery and preferences, but differ in durability:
| Path | Trigger | Durability | Primary code |
|---|---|---|---|
| Real-time (event-driven) | Contract event polled from Stellar RPC | In-memory registry + optional in-memory retry; persistent event dedup in SQLite | EventSubscriber → DiscordNotificationService |
| Scheduled (durable) | POST /api/schedule or NotificationAPI.scheduleNotification() |
SQLite scheduled_notifications + execution log + archive |
NotificationScheduler / RetryScheduler / ArchiveService |
Real-time path is the default reaction to blockchain activity. Scheduled path is used for deferred or manually scheduled deliveries.
End-to-end flow from contract emission through listener delivery, provider acknowledgment, and (for scheduled rows) archival:
flowchart TB
subgraph OnChain["On-chain (Soroban)"]
User["Users / dApps"]
Contract["Smart Contracts<br/>(AutoShare / TaskBounty)"]
User -->|invoke| Contract
Contract -->|emit typed events| Ledger["Stellar ledger events"]
end
subgraph OffChain["Off-chain (listener/src)"]
ES["EventSubscriber<br/>poll getEvents"]
Filter["Validate + event filter"]
EDedup["EventDeduplicationService<br/>(processed_events)"]
Registry["eventRegistry<br/>(in-memory)"]
Prefs["preferenceStore"]
Discord["DiscordNotificationService"]
NDedup["NotificationDeduplicator<br/>(in-memory fingerprint)"]
RQ["NotificationRetryQueue<br/>(in-memory)"]
API["Events HTTP API<br/>/api/events, /api/schedule"]
NAPI["NotificationAPI"]
Repo["ScheduledNotificationRepository"]
Sched["NotificationScheduler"]
Retry["RetryScheduler"]
Arch["ArchiveService"]
DB[("SQLite<br/>scheduled_notifications<br/>notification_execution_log<br/>notification_archive<br/>processed_events")]
end
subgraph Delivery["Delivery & consumers"]
WH["Discord webhook"]
Dash["React Dashboard"]
end
Ledger --> ES
ES --> Filter --> EDedup
EDedup -->|new event| Registry
Registry --> API
API -->|GET /api/events| Dash
EDedup -->|duplicate| Skip["Skip / record SKIPPED"]
Registry --> Prefs
Prefs -->|discord enabled| Discord
Discord --> NDedup
NDedup -->|unique| WH
WH -->|HTTP 2xx = ack| Discord
Discord -->|send failed| RQ
RQ -->|retry| Discord
NAPI -->|schedule| Repo --> DB
API -->|POST /api/schedule| NAPI
Sched --> Repo
Sched --> Discord
Retry --> Repo
Retry --> Discord
Arch -->|COMPLETED / FAILED / CANCELLED| DB
Scheduled-notification state machine (durable path only):
stateDiagram-v2
[*] --> PENDING: create schedule record
PENDING --> PROCESSING: fetch+lock due row
PROCESSING --> COMPLETED: provider delivery acknowledged
PROCESSING --> PENDING: failure + retries remaining
PROCESSING --> FAILED: failure + retries exhausted
PENDING --> CANCELLED: cancel before processing
COMPLETED --> ARCHIVED: archive cycle moves row
FAILED --> ARCHIVED: archive cycle moves row
CANCELLED --> ARCHIVED: archive cycle moves row
ARCHIVED --> PURGED: purge after retention window
- A user or dApp invokes a Soroban contract method (AutoShare, TaskBounty, or notification-scheduling helpers on-chain).
- The contract updates state and emits a typed event via the Soroban event
system (topics + data). Examples:
AutoshareCreated,TaskCreated,NotificationScheduled,NotificationExpired. - Events are recorded on the Stellar ledger. They are not delivered to Discord or the dashboard by the contract itself — the off-chain listener must observe them.
- Event names, topic layouts, categories, and priorities are documented in CONTRACT_EVENT_REFERENCE.md (including the Notification Lifecycle Events section for on-chain schedule/expire/cancel/revoke events).
Important: On-chain NotificationScheduled (and related) events are
ledger signals. The listener treats them like other contract events for the
real-time path (registry + optional Discord). They do not automatically insert
rows into scheduled_notifications. Durable off-chain scheduling uses the HTTP /
NotificationAPI path below.
Step-by-step from detection to delivery acknowledgment
(listener/src/services/event-subscriber.ts):
EventSubscriber loops on pollIntervalMs, calling server.getEvents() per
configured contract address, using a per-contract cursor when available
(otherwise startLedger: 1).
For each event:
validateEventPayload()— drop malformed payloadsmatchesEventFilter(eventName, contractConfig.events)— honor per-contract event allowlists
If EventDeduplicationService is wired:
- Optional reorg detection via ledger vs stored polling cursor
isDuplicate(event.id, contractAddress)againstprocessed_events- Duplicates are recorded as
SKIPPEDand not re-notified
If config.eventQueue is set, events are enqueued to EventProcessingQueue;
otherwise processEvent() runs inline.
eventRegistry.addFromInput(...) stores a display event in memory (with TTL /
cleanup when configured). This is what GET /api/events serves to the dashboard.
Before Discord send, preferenceStore.isCategoryEnabled(userId, 'discord') is
checked (userId from contractConfig.userId or 'global'). Disabled categories
skip delivery but the event remains in the registry.
DiscordNotificationService.sendEventNotification():
- Builds a fingerprint
(eventId, contractAddress)viaNotificationDeduplicator - Skips if already sent within the in-memory dedup window (returns success)
- Formats a Discord embed and POSTs the webhook
- Treats
response.okas delivery acknowledgment - On success, marks the fingerprint as sent
If the initial Discord send returns false, NotificationRetryQueue enqueues the
event for exponential-backoff retries (separate from DB-backed scheduled retries).
EventDeduplicationService.recordProcessedEvent(...) persists processing outcome
(PROCESSED / ERROR, whether notification was sent).
Durable deferred deliveries (independent of the real-time subscriber path).
Entrypoints:
POST /api/scheduleinlistener/src/api/events-server.tsNotificationAPI.scheduleNotification()inlistener/src/services/notification-api.ts
Required fields: executeAt, payload, targetRecipient.
Optional: notificationType (default discord), maxRetries, priority,
eventId, contractAddress, metadata.
Validation: executeAt must be a valid future Date; payload must be an
object; targetRecipient required. Failures throw — no DB row is created.
Optional idempotency via IdempotencyKeyService when initialized with a key.
Example:
{
"payload": {
"event": { "id": "evt_123" },
"contractConfig": { "address": "CA..." }
},
"notificationType": "discord",
"targetRecipient": "https://discord.com/api/webhooks/...",
"executeAt": "2026-07-01T09:00:00.000Z",
"maxRetries": 3,
"priority": 5,
"eventId": "evt_123",
"contractAddress": "CA...",
"metadata": { "source": "manual" }
}ScheduledNotificationRepository.create() inserts into scheduled_notifications
with status PENDING.
NotificationScheduler (when SCHEDULER_ENABLED):
recoverStaleLocks()— expiredPROCESSINGlocks →PENDINGorFAILEDfetchAndLockPendingNotifications()— due rows (execute_at <= now) locked withstatus=PROCESSING,processor_id,lock_expires_at- Batch payload validation via
BatchValidationService/BatchValidator
For notificationType === discord, calls
DiscordNotificationService.sendEventNotification(...).
Declared but not implemented in the scheduler today: webhook, email, sms.
| Result | Status / log |
|---|---|
Provider ack (response.ok) |
COMPLETED + execution log SUCCESS |
| Failure, retries left | back to PENDING + log RETRY |
| Failure, retries exhausted | FAILED + log FAILED |
RetryScheduler picks rows where status = PENDING, retry_count > 0, and
next_retry_at is due. Backoff uses
calculateBackoffDelay(attempt, baseDelayMs, multiplier, maxDelayMs, jitter).
Terminal rows (COMPLETED, FAILED, CANCELLED) are later moved by
ArchiveService into notification_archive, then optionally purged after
retention. See Completion and Archival.
Sequence (scheduled path):
sequenceDiagram
participant Client
participant API as Events API (/api/schedule)
participant NAPI as NotificationAPI
participant Repo as ScheduledNotificationRepository
participant Sched as NotificationScheduler
participant Discord as DiscordNotificationService
participant DB as SQLite
Client->>API: POST /api/schedule
API->>NAPI: scheduleNotification(input)
NAPI->>Repo: create(...)
Repo->>DB: INSERT scheduled_notifications (PENDING)
DB-->>Repo: id
Repo-->>API: id
API-->>Client: 201 { id }
loop poll every SCHEDULER_POLL_INTERVAL_MS
Sched->>Repo: recoverStaleLocks()
Sched->>Repo: fetchAndLockPendingNotifications()
Repo->>DB: UPDATE ... SET PROCESSING + lock
Repo-->>Sched: locked rows
Sched->>Discord: sendEventNotification(...)
alt success
Sched->>Repo: markAsCompleted(id)
Sched->>Repo: logExecution(status=SUCCESS)
else failed
Sched->>Repo: markAsFailedOrRetry(...)
Sched->>Repo: logExecution(status=RETRY or FAILED)
end
end
| Component | Layer | Role |
|---|---|---|
Smart contracts (contract/, Task Bounty) |
On-chain | Business logic; emit typed Soroban events that the listener observes |
| EventSubscriber | Off-chain | Polls Stellar RPC, filters events, drives registry + Discord, owns reconnect/cursor |
| EventDeduplicationService | Off-chain | Persistent (event_id, contract_address) dedup + reorg/cursor helpers (processed_events) |
| NotificationDeduplicator | Off-chain | Short-window in-memory fingerprint dedup inside Discord sends |
| eventRegistry | Off-chain | In-memory event feed for GET /api/events |
| preferenceStore | Off-chain | Per-user category gates (e.g. disable Discord) |
| DiscordNotificationService | Off-chain | Formats embeds, POSTs webhook, interprets HTTP ack, local retry loop |
| NotificationRetryQueue | Off-chain | In-memory exponential backoff for real-time Discord failures |
| NotificationAPI + /api/schedule | Off-chain | Validates and creates durable scheduled notifications |
| NotificationScheduler | Off-chain | Polls due PENDING rows, locks, delivers, marks complete/retry/fail |
| RetryScheduler | Off-chain | Processes delayed durable retries (next_retry_at) |
| ArchiveService / ArchiveStore | Off-chain | Moves terminal scheduled rows to archive; purges after retention |
| WorkerManager | Off-chain | Coordinates graceful shutdown of background workers |
Dashboard (dashboard/) |
UI | Polls /api/events (and related health/search APIs) to show activity |
Acknowledgment is provider-based, not end-user/manual.
- Provider HTTP acknowledgment —
DiscordNotificationService/sendWebhook():response.ok === truemeans delivered. Scheduler marksCOMPLETEDand logsSUCCESS. - Provider failure — non-2xx or transport error → real-time path may enqueue
NotificationRetryQueue; scheduled path usesmarkAsFailedOrRetry()and logsRETRY/FAILED.
Not implemented: no POST /api/notifications/:id/ack (or similar) for downstream
consumer acknowledgment.
markAsFailedOrRetry()incrementsretry_count- Retries remaining →
PENDINGwithnext_retry_at - Exhausted →
FAILED+processing_completed_at recoverStaleLocks()returns expiredPROCESSINGtoPENDING/FAILED
NotificationRetryQueuewith exponential backoff + optional jitter- Lost on process restart (unlike SQLite-backed scheduled retries)
DiscordNotificationService also retries within a single
sendEventNotification() call (retryCount / backoffBaseSeconds from config)
before returning failure to the caller.
Details: NOTIFICATION_FAILURE_RECOVERY.md.
Applies to the scheduled path terminal states (COMPLETED, FAILED,
CANCELLED):
- Rows remain in
scheduled_notificationsfor an active retention window. ArchiveServiceperiodically moves old terminal rows tonotification_archive.- Optional purge deletes archived rows after a second retention window.
Defaults from ArchiveConfig:
| Setting | Default | Env |
|---|---|---|
| Archive cycle interval | 6 hours | ARCHIVE_INTERVAL_MS |
| Archive-after threshold | 7 days | ARCHIVE_AFTER_MS |
| Purge-after threshold | 90 days | ARCHIVE_DELETE_AFTER_MS |
| Batch size | 500 | ARCHIVE_BATCH_SIZE |
APIs: GET /api/archive, GET /api/archive/:id, POST /api/archive/run.
Real-time path events age out of eventRegistry via TTL / CleanupService; they
are not moved into notification_archive.
The React dashboard (dashboard/) does not talk to Stellar RPC directly for the
main feed. It:
- Calls the listener Events API (
VITE_EVENTS_API_URL, typicallyhttp://localhost:8787/api/events) - Renders events previously written by
EventSubscriberintoeventRegistry - May also surface notification health / search against listener HTTP endpoints
So the dashboard sits after off-chain ingestion: contract → listener → API → UI.
- Scheduled and subscriber flows are intentionally separate; one does not replace the other.
- Scheduled retries are durable (SQLite). Subscriber retries are in-memory.
notification_execution_logis the authoritative audit trail for scheduled delivery attempts.- Two dedup layers exist: persistent event dedup (
EventDeduplicationService) and short-window notification dedup (NotificationDeduplicator). - Worker shutdown uses
WorkerManagerso schedulers/archive cycles can finish in-flight work gracefully. - Batch validation:
POST /api/notifications/validate-batchplus scheduler pre-process batch checks.
SCHEDULER_ENABLED=trueexecute_atis due and valid- Scheduler logs show polling and lock acquisition
GET /api/schedule/<id>,GET /api/schedule/stats
- Discord webhook validity/reachability
retry_count,next_retry_at,last_erroron the row- Rows in
notification_execution_log
- Listener running and RPC reachable
- Contract address + event name in config filters
EventDeduplicationService/NotificationDeduplicatorskip (already processed)preferenceStoreDiscord category enabled- Discord config present on the listener
Terminal and older than retention may already be archived:
GET /api/archive?status=COMPLETED / FAILED.
Not implemented. Delivery ack is inferred from the Discord webhook HTTP response.