How the pieces fit together. This document is the map a new contributor reads before opening a PR, the reviewer consults before approving one, and the grant committee reads to understand what is actually being built.
Where something ships today it is marked ✅; where it is planned it is marked 🛠️ and the milestone is named.
- System overview
- Component inventory & repo layout
- Event lifecycle
- The normalization layer
- Reconnection, backoff, and rate limits
- Webhook delivery internals
- React hook internals
- Trust boundaries and invariants
- Reference composition (
apps/web) - Phase 1 evolution
- File map
Orbital is three planes sharing one vocabulary — the normalized event:
- Subscription plane —
@orbital/pulse-coreopens and maintains a connection to Stellar (Horizon today, Stellar RPC + Soroban in Phase 1), normalizes raw operations into a typedNormalizedEventunion, and routes each event to per-addressWatchersubscribers. - Delivery plane —
@orbital/pulse-webhooksattaches to aWatcher, signs each event with HMAC-SHA256, and POSTs it to one or more HTTPS endpoints with retry, timeout, and SSRF safety. A second export (verifyWebhookEdge) lets receivers verify the signature on Cloudflare Workers, Vercel Edge, Deno, and browsers without Nodecrypto. - Consumption plane —
@orbital/pulse-notifyopens a browserEventSourceto a backend that re-emits the events as Server-Sent Events, and re-renders React components on each event.
flowchart LR
subgraph Stellar["Stellar network"]
Horizon["Horizon REST + SSE"]
RPC["Stellar RPC<br/>Soroban — 🛠️ Phase 1"]
end
subgraph Subscribe["Subscription plane<br/>@orbital/pulse-core"]
Engine["EventEngine"]
Normalize["Normalize<br/>13 op types → 21 events"]
Watcher["Watcher<br/>per-address pub/sub"]
end
subgraph Deliver["Delivery plane<br/>@orbital/pulse-webhooks"]
Sign["HMAC-SHA256<br/>+ retry + SSRF + timeout"]
Verify["verifyWebhook<br/>verifyWebhookEdge"]
end
subgraph Consume["Consumption plane<br/>@orbital/pulse-notify"]
Hooks["useStellarEvent<br/>useStellarPayment<br/>useStellarActivity"]
end
Horizon --> Engine
RPC -.-> Engine
Engine --> Normalize --> Watcher
Watcher --> Sign --> YourEndpoint["Your HTTPS endpoint"]
YourEndpoint --> Verify
Watcher --> SSE["Your SSE endpoint"]
SSE --> Hooks --> ReactApp["React app"]
Each plane is independently installable from npm (Phase 1 publish) and
independently composable. The reference composition that powers the marketing
demo at apps/web/app/api/events/[address]/route.ts shows all three wired
together inside a single Next.js route handler — about 50 lines of glue.
| Component | Role | Path | Status |
|---|---|---|---|
EventEngine |
Owns the Horizon SSE stream, the reconnection state machine, the watcher registry, and the normalization pipeline. | packages/pulse-core/src/EventEngine.ts |
✅ |
Watcher |
EventEmitter keyed by Stellar address. Subscribers add listeners; the engine routes each normalized event to the matching watcher(s). |
packages/pulse-core/src/Watcher.ts |
✅ |
| Normalizers | One method per Horizon operation type. Each returns a typed NormalizedEvent or null if the record is malformed. |
packages/pulse-core/src/EventEngine.ts (normalize*) |
✅ |
WebhookDelivery |
Attaches to a Watcher, signs each event, POSTs it with retry + timeout + concurrent-retry cap. Emits webhook.failed / webhook.dropped on terminal failure. |
packages/pulse-webhooks/src/index.ts |
✅ |
verifyWebhook |
Node-side HMAC verifier using crypto.timingSafeEqual. |
packages/pulse-webhooks/src/index.ts |
✅ |
verifyWebhookEdge |
Edge-runtime HMAC verifier using Web Crypto API + constant-time XOR. | packages/pulse-webhooks/src/edge.ts |
✅ |
useStellarEvent<T> |
React hook that opens an EventSource, parses incoming SSE messages, optionally filters by event type, and re-renders on each event. Stable dep-array via sorted eventKey. |
packages/pulse-notify/src/index.ts |
✅ |
useStellarPayment / useStellarActivity |
Convenience wrappers over useStellarEvent. |
packages/pulse-notify/src/index.ts |
✅ |
| Reference composition | A Next.js Node-runtime route handler that subscribes to an address and streams events as SSE; plus a webhook-sample route that returns an HMAC-signed payload for the demo. |
apps/web/app/api/events/[address]/route.ts, apps/web/app/api/webhook-sample/route.ts |
✅ |
| Soroban subscriber | Subscribes to Stellar RPC for contract events, decodes via the ABI Registry, normalizes into the same NormalizedEvent union. |
packages/pulse-core (planned) |
🛠️ Phase 1 |
| Cursor persistence | Pluggable durable store for the Horizon cursor so a process restart resumes from where it left off. | packages/pulse-core (planned) |
🛠️ Phase 1 |
| Replay adapters | Pluggable durable queues (Redis / Postgres / S3) for in-flight webhook retries. | packages/pulse-webhooks (planned) |
🛠️ Phase 1 |
End-to-end for a single payment event from on-chain to React render:
sequenceDiagram
participant Network as Stellar network
participant Engine as EventEngine
participant Normalizer as normalize()
participant Registry as Watcher registry
participant Webhooks as WebhookDelivery
participant Hook as useStellarEvent
participant Endpoint as Your HTTPS endpoint
participant App as React app
Network->>Engine: SSE operation record
Engine->>Normalizer: route record by type
Normalizer-->>Engine: NormalizedEvent (payment.received)
Engine->>Registry: route(event)
Registry->>Webhooks: watcher.emit("payment.received", event)
Registry->>Hook: SSE message → setState
Webhooks->>Endpoint: POST { x-orbital-signature, x-orbital-timestamp, body }
Endpoint-->>Webhooks: 200 OK
Hook->>App: re-render with new event
Steps in detail:
- Subscribe. Caller invokes
engine.subscribe(address). The engine creates a newWatcher(or returns an existing one), registers astopHandlerfor cleanup, and stores any optionalfilterpredicate. - Ingest. Horizon streams an operation record. The engine's
onmessagecallback updateslastEventAtand asks the normalizer to map the record to aNormalizedEvent. Records that don't match any known op type are dropped silently; records that fail field validation are dropped with a warn log. - Route. Each event type has a routing rule encoded in
EventEngine.route(). Payments route to bothfromandtowatchers; account merges to source and destination; trust-auth events to issuer and trustor; claimables to every claimant plus the sponsor. The same event never delivers to the same watcher twice. - Filter. If the subscriber passed a
filterpredicate, the engine calls it; afalsereturn suppresses delivery. A thrown predicate is treated asfalseand logged as a warning — the stream stays up. - Emit. The watcher emits the event under its discriminated type
(
"payment.received") and also under the wildcard"*". Consumers can listen to either or both. - Deliver. Any
WebhookDeliveryattached to the watcher signs the payload, POSTs it, and handles retry on failure. AnyuseStellarEventhook attached via SSE receives the event and updates React state.
Horizon's operation records are denormalized JSON keyed by operation type.
pulse-core exposes them as a single discriminated union so consumers can
exhaustively switch on event.type:
type NormalizedEvent =
| PaymentEvent
| AccountOptionsEvent
| AccountCreatedEvent
| TrustlineEvent
| AccountMergeEvent
| OfferEvent
| BumpSequenceEvent
| DataEvent
| ClaimableCreatedEvent
| ClaimableClaimedEvent
| LiquidityPoolDepositEvent
| LiquidityPoolWithdrawEvent
| TrustAuthEvent;The 13 Horizon operation types map to 21 normalized event types — the
difference comes from operations that fan out into multiple semantic events
(a change_trust becomes one of trustline.added, trustline.removed, or
trustline.updated depending on the new limit; a manage_sell_offer becomes
one of offer.created, offer.updated, or offer.deleted depending on the
offer ID and amount).
Asset encoding is normalized to a single string: "XLM" for native, or
"CODE:ISSUER" for credit assets. This sidesteps the Horizon convention of
asset_type + asset_code + asset_issuer triples in every operation.
The full taxonomy is documented in
packages/pulse-core/README.md
and the per-event TypeScript shapes live in
packages/pulse-core/src/index.ts.
Horizon's SSE stream is not durable. Network blips, process restarts, and
upstream maintenance windows all surface as onerror callbacks. The
reconnection state machine is the single most load-bearing piece of
pulse-core for production reliability.
The engine uses AWS Full Jitter: each reconnect delay is a uniform random
value between 0 and min(initialDelayMs × 2^(attempt-1), maxDelayMs). This
avoids the thundering-herd problem where every dropped client reconnects on
the same millisecond when Horizon comes back.
Defaults: initialDelayMs: 1000, maxDelayMs: 30000, maxRetries: ∞. All
three are overridable via CoreConfig.reconnect.
A 429 Too Many Requests response is not treated as a generic error. The
engine parses the Retry-After header (either integer seconds or an HTTP
date), schedules the reconnect for exactly that delay, and emits an
engine.rate_limited notification on every watcher so consumers can show
banners or pause publishers. If the header is missing or malformed, the
engine falls back to a 60-second delay (longer than the exponential default).
Every watcher receives lifecycle notifications alongside operation events:
| Notification | When |
|---|---|
engine.reconnecting |
Backoff delay started; will reconnect in delayMs |
engine.reconnected |
Reconnect succeeded on attempt N |
engine.rate_limited |
429 received; reconnect deferred by delayMs |
engine.stopped |
engine.stop() was called explicitly |
Consumers can subscribe to these via watcher.on("engine.reconnecting", …)
to surface UI banners or write structured logs.
WebhookDelivery is a thin wrapper around fetch with five concerns:
- Signing. HMAC-SHA256 over
${timestamp}.${payload}, wherepayloadis the JSON-serializedNormalizedEvent. The signature is hex-encoded and sent as thex-orbital-signatureheader. The timestamp prevents replay attacks; receivers should reject signatures older than a small window (recommended: 5 minutes). - Timeout. Each attempt has an
AbortControllercap (default 10 s, tunable viadeliveryTimeoutMs). A timed-out attempt counts as a failure and triggers retry. - Retry. On any non-2xx response, network error, or timeout, the
delivery schedules a retry after a jittered exponential delay (
2^(n-1) × 1000ms × Math.random()). The default cap is 3 attempts. - Concurrent-retry cap. If too many endpoints are slow, retries can
accumulate and exhaust memory. The cap (
maxConcurrentRetries, default 100) evicts the newest pending retry first when the limit is hit and emits a syntheticwebhook.droppedevent so consumers can route it to a dead-letter store. - Terminal failure. After all retries are exhausted, the delivery
emits
webhook.failedon the watcher with{ error, url, attempts, originalEvent }inraw. The watcher'son("webhook.failed", …)handler is the dead-letter integration point.
Delivery targets are validated at construction time and re-validated against
DNS resolution before each request. Loopback (127.0.0.0/8, ::1), private
RFC 1918 ranges, and link-local (169.254.0.0/16) addresses are blocked by
default. Setting allowPrivateNetworks: true opts out — useful for local
development, never appropriate for production.
verifyWebhookEdge is a parallel implementation of verifyWebhook that
uses Web Crypto's crypto.subtle.sign instead of Node's createHmac. It
runs on Cloudflare Workers, Vercel Edge, Deno, and browsers — anywhere with
the standard crypto.subtle API. The constant-time XOR comparison
substitutes for Node's timingSafeEqual.
This matters because the most common webhook receiver shape on Stellar today is a Cloudflare Worker or a Vercel Edge route, and the Node crypto verifier doesn't load there.
useStellarEvent<T> is intentionally thin: it acquires a pooled
EventSource connection and applies a per-hook event filter, with three
design choices worth noting.
Connection pooling. Hook instances with the same serverUrl, address,
and token share one browser EventSource; the pool closes that connection
when the last hook using the key unmounts.
Stable dep-array. An array literal passed as the event allowlist
would otherwise be a new reference every render and re-run the effect
continuously. The hook serializes the allowlist to a sorted string
(eventKey) and uses that as the dep instead.
Dual call signature. The hook accepts both a config object
(useStellarEvent({ serverUrl, address, event })) and a positional form
(useStellarEvent(serverUrl, address, { event })). The two are normalized
to the same four primitives before the effect runs.
Generic type narrowing. Passing a narrower union as T lets consumers
exhaustively switch on event.type without manual casts:
type WalletEvents = Extract<NormalizedEvent, { type: "payment.received" | "payment.sent" }>;
const { event } = useStellarEvent<WalletEvents>(url, address, { event: ["payment.received", "payment.sent"] });The hook is browser-only — EventSource doesn't exist in Node. In Next.js
App Router, mark consuming components with "use client". In Remix or Vite
SSR, gate the hook behind a client-only boundary.
| Boundary | Inside | Outside | Mitigation |
|---|---|---|---|
| Webhook signature | The signed ${timestamp}.${payload} body emitted by WebhookDelivery. |
Network, intermediate proxies, the receiver's logs. | HMAC-SHA256 with a shared secret; verifyWebhook uses timing-safe comparison. |
| Webhook target URL | The host configured by the operator. | The DNS resolver and the network it points at. | SSRF block-list on construction and re-validated post-DNS to defend against DNS rebinding. |
| SSE stream from Stellar | EventEngine → Watcher callbacks. |
Horizon, intermediate CDNs. | The engine treats every record as untrusted input — every field is typeof-checked before normalization; malformed records are dropped with a warn log, not thrown. |
| API key on the demo backend | A single process.env.NEXT_PUBLIC_NETWORK-validated singleton. |
Public internet, every visitor. | The demo enforces a 1-stream-per-IP concurrency cap and a 25s session timer; production self-hosters should swap in their own auth (the SDKs do not prescribe one). |
| HMAC secret | The operator's secret store. | Source control, logs, debug output. | The reference composition reads from env; consumers are expected to use a secrets manager. |
- An
EventEngineonly ever holds one upstream connection at a time.start()is idempotent (returnsfalseif already running, unlessstrict: trueis passed). - A
Watcheronly ever delivers to a given subscriber once per event. - A
WebhookDeliveryretries each URL independently; one slow endpoint does not block delivery to the others. - A stopped
Watcheraccepts no new listeners and emits no further events. Adding a listener afterstop()is a no-op with a warn log.
The marketing site doubles as the runnable reference for "how do I wire this together end-to-end?" Two API routes show the composition:
| Route | Powers | Cost-control |
|---|---|---|
GET /api/events/[address] |
The on-page Live Demo. Subscribes to the address, streams events as SSE, closes after the session cap. | 1 concurrent stream per IP; 25s max session. |
POST /api/webhook-sample |
The on-page Webhook Demo. Returns a real HMAC-SHA256-signed sample payload with x-orbital-* headers. |
1 signing request per IP per 20s. |
Both routes share a lazy globalThis-scoped EventEngine singleton
(apps/web/lib/engine.ts) that survives Next.js HMR. When the limits
trigger, the routes return a JSON envelope
({ error: "demo_limit_reached", reason, message, upgradeUrl }) that the
React components surface as an "Upgrade to Orbital Cloud" CTA.
For production self-hosting, copy the route handlers, strip the limit
checks in lib/demo-limits.ts, and swap the in-memory singleton for a
proper process manager. The whole composition is under 200 lines of TS.
Where Phase 1 (v1.0, Q2–Q3 2026) bolts on without changing the public
API:
- Soroban event subscription. A second source feeding into the same
normalization pipeline. New event types
(
contract.invoked,contract.emitted) join theNormalizedEventunion; existing consumers ignore them unless they subscribe. - ABI Registry client. Decodes Soroban event topics + data into typed,
human-readable JSON. Lives as a separate package (
@orbital/abi-registryTBD) so the registry data layer is independently versioned. - Cursor persistence. A pluggable
CursorStoreinterface stored onEventEngineconfig. Implementations: in-memory (default, current behavior), local file, Redis, Postgres, S3. On reconnect, the engine resumes from the stored cursor instead of"now". - Replay adapters. A pluggable
RetryQueueinterface onWebhookDelivery. Implementations: in-memory (current), Redis, Postgres, SQS. Pending retries survive process restarts. - Discriminated union refinement. Today the
NormalizedEventunion is discriminated bytypebut several fields areunknownfor type-safety reasons (the raw Horizon record). Phase 1 narrows these to typed shapes with the help of generated schemas from Horizon's OpenAPI.
The upgrade path for existing consumers is: pnpm update to v1.x,
optionally pass new config (cursorStore, retryQueue), keep their
existing on() handlers unchanged.
orbital_stellar/
├── packages/ # Published SDKs (MIT, npm)
│ ├── pulse-core/
│ │ ├── src/
│ │ │ ├── EventEngine.ts # ~1100 lines — engine + normalizers + routing
│ │ │ ├── Watcher.ts # EventEmitter wrapper with stop-handler hooks
│ │ │ ├── errors.ts # EngineAlreadyStartedError
│ │ │ └── index.ts # Public types + barrel exports
│ │ ├── test/ # Vitest suites (103 passing)
│ │ └── bench/throughput.ts # tsx benchmark harness
│ ├── pulse-webhooks/
│ │ ├── src/
│ │ │ ├── index.ts # WebhookDelivery + verifyWebhook (Node)
│ │ │ ├── edge.ts # verifyWebhookEdge (Web Crypto)
│ │ │ └── types.ts # WebhookConfig
│ │ └── test/ # Vitest suites (13 passing)
│ └── pulse-notify/
│ └── src/index.ts # ~120 lines — three React hooks
├── apps/
│ └── web/ # Marketing site + reference composition
│ ├── app/
│ │ ├── api/
│ │ │ ├── events/[address]/route.ts # Reference SSE handler
│ │ │ └── webhook-sample/route.ts # HMAC-signed sample
│ │ ├── docs/ # File-based MD docs with TOC
│ │ └── page.tsx # Marketing landing
│ ├── components/ # LiveDemo, WebhookDemo, Nav, Hero, …
│ ├── content/ # Markdown sources for /docs
│ └── lib/
│ ├── engine.ts # globalThis EventEngine singleton
│ ├── network.ts # NEXT_PUBLIC_NETWORK validation
│ └── demo-limits.ts # Per-IP + per-session caps
├── docs/ # Strategic + reference docs (this dir)
├── .github/workflows/ # CI, CodeQL, security, integration, release
├── PROGRESS.md # Phase 0 status snapshot
├── ROADMAP.md # Multi-year phase plan
├── CHANGELOG.md # Rolled-up release notes
└── CONTRIBUTING.md # Dev loop + PR conventions
PROGRESS.md— Phase 0 completion checklistROADMAP.md— Phase 0 → Phase 4 timelineCHANGELOG.md— Per-release notesdocs/proposal.md— SCF grant proposal- Per-package READMEs:
pulse-core,pulse-webhooks,pulse-notify