diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 409fc8fe..6ea1262a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,5 +119,7 @@ jobs: - run: pnpm install --frozen-lockfile - name: Build pulse-core run: pnpm tsc -p packages/pulse-core/tsconfig.json + - name: Build pulse-webhooks + run: pnpm tsc -p packages/pulse-webhooks/tsconfig.json - name: Typecheck apps/web run: pnpm tsc --noEmit -p apps/web/tsconfig.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80defe45..8154575b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,6 @@ jobs: with: node-version: 20 cache: pnpm - # registry-url is needed when the npm publish step is uncommented registry-url: https://registry.npmjs.org # ── Build and test gate ─────────────────────────────────────────────── @@ -41,8 +40,8 @@ jobs: - name: Build pulse-core run: pnpm tsc -p packages/pulse-core/tsconfig.json - - name: Typecheck pulse-webhooks - run: pnpm tsc --noEmit -p packages/pulse-webhooks/tsconfig.json + - name: Build pulse-webhooks + run: pnpm tsc -p packages/pulse-webhooks/tsconfig.json - name: Typecheck pulse-notify run: pnpm tsc --noEmit -p packages/pulse-notify/tsconfig.json @@ -64,10 +63,9 @@ jobs: prerelease: ${{ contains(github.ref_name, '-') }} # ── npm publish ─────────────────────────────────────────────────────── - # Uncomment when packages are ready to publish. - # Requires NPM_TOKEN to be added to GitHub secrets first. - # - # - name: Publish packages to npm - # run: pnpm -r --filter './packages/*' publish --access public --no-git-checks - # env: - # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file + # Packages are already live on npm under the @orbital-stellar scope; + # this step publishes each subsequent tagged version bump. + - name: Publish packages to npm + run: pnpm -r --filter './packages/*' publish --access public --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6227c64e..db5145f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). This file rolls up changes across the public packages: `@orbital-stellar/pulse-core`, -`@orbital-stellar/pulse-webhooks`, and `@orbital-stellar/pulse-notify`. Per-package changelogs -live in each package directory. +`@orbital-stellar/pulse-webhooks`, `@orbital-stellar/pulse-notify`, and `@orbital-stellar/abi-registry`. +Per-package changelogs live in each package directory. ## [Unreleased] @@ -113,7 +113,7 @@ in Phase 1 (Q2–Q3 2026). is now a single Next.js file rather than a separate Express server, so there is one runtime to deploy when self-hosting the SDKs end-to-end. -### Known limitations +### Known limitations (as of this release) - Soroban contract events (`invoke_host_function`) are not yet normalized — Phase 1. @@ -121,3 +121,9 @@ in Phase 1 (Q2–Q3 2026). Persistent retry queues ship in Phase 1 alongside cursor persistence. - Packages are not yet published to npm. Until `v0.1.0` is tagged and released, consume via `pnpm install` against the workspace. + +> **Update (2026-07-06):** all three limitations above have since been resolved — +> Soroban event subscription, durable retry queues, and cursor persistence shipped +> (see `ROADMAP.md` Wave 1.1–1.3), and all four packages are now published to npm +> under the `@orbital-stellar` scope (published out-of-band; the `release.yml` +> npm-publish step is now uncommented for future version bumps). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7cf9d29..4fa1770f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,9 +41,10 @@ That installs all workspace packages. No additional steps are needed to run the ``` orbital/ ├── packages/ -│ ├── pulse-core/ # EventEngine, Watcher, Horizon + RPC streaming +│ ├── pulse-core/ # EventEngine, Watcher, Horizon + Soroban RPC streaming │ ├── pulse-webhooks/ # HMAC delivery, retry, SSRF protection -│ └── pulse-notify/ # React hooks +│ ├── pulse-notify/ # React hooks +│ └── abi-registry/ # Soroban ABI client, schema helpers, registry publisher ├── apps/ │ └── web/ # Next.js marketing + documentation site ├── tsconfig.base.json # Shared TypeScript config diff --git a/PROGRESS.md b/PROGRESS.md index 28e80a33..2e72acb8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,16 +1,15 @@ # Orbital: Progress & Status Report -**Last Updated:** 2026-05-07 -**Project Status:** Phase 0 (SDK Foundation) — Complete ✅ -**Next Milestone:** Phase 1 — Production-grade `v1.0` (Q2–Q3 2026) +**Last Updated:** 2026-07-06 +**Project Status:** Core SDK family — Shipped ✅ --- ## Executive Summary -Orbital is **Stellar's open-source real-time event SDK family** — three MIT-licensed packages on npm that give any Stellar developer typed event subscriptions, signed webhook delivery, and React hooks without re-implementing the plumbing. +Orbital is **Stellar's open-source real-time event SDK family** — four MIT-licensed packages on npm that give any Stellar developer typed event subscriptions (Horizon and Soroban), signed webhook delivery with durable retry, typed ABI decoding, and React hooks, without re-implementing the plumbing. -**Current Status:** Phase 0 (Foundation) is complete. The full classic operation taxonomy is shipped, edge-runtime webhook verification works on Cloudflare Workers and Vercel Edge, and React hooks are in production-shape. Phase 1 (Soroban event subscription, ABI registry, `v1.0` stability pledge, npm publish) begins Q2 2026. +**Current Status:** The full classic operation taxonomy, Soroban contract event subscription, cursor persistence, durable webhook retry queues, the ABI registry client, and edge-runtime webhook verification are all shipped and tested. Next up is the Phase 2 SDK family (`@orbital-stellar/hooks`, `@orbital-stellar/payments`, `@orbital-stellar/auth`) — see [`ROADMAP.md`](ROADMAP.md). **OSS posture:** SDKs are MIT and free indefinitely. Production hosting is the separately-built **Orbital Cloud** managed runtime, in development. Until Cloud ships, the SDKs run great in any Node.js or edge backend you operate. @@ -18,22 +17,27 @@ Orbital is **Stellar's open-source real-time event SDK family** — three MIT-li ## What Has Been Completed -### Phase 0 — Foundation ✅ +### Core SDK family ✅ -All three packages are feature-complete for Phase 0 scope and ready for use against testnet today: +All four packages are feature-complete and ready for use against testnet and mainnet today: | Component | Status | Details | |---|---|---| | Classic operation event streaming via Horizon SSE | ✅ Done | Horizon subscription, automatic reconnection with AWS Full Jitter backoff | | Full classic operation taxonomy | ✅ Done | Payments (received/sent/self), account create/merge/bump-sequence, trustlines (change/allow/set_flags), DEX offers (created/updated/deleted), claimable balances (created/claimed), liquidity pools (deposit/withdraw), `manage_data` (set/cleared) | +| Soroban contract event subscription | ✅ Done | `engine.subscribeContract({ contractId, topics })` via Stellar RPC, `contract.invoked` / `contract.emitted` normalized events | +| Cursor persistence | ✅ Done | Pluggable `CursorStore` adapters — memory, file, Postgres, Redis, S3 — for resumable streams across restarts | +| ABI registry client | ✅ Done | `AbiRegistryClient` / `LocalAbiRegistryClient`, typed `decodedData` enrichment on `contract.emitted`, schema validation | | HMAC-signed webhook delivery | ✅ Done | Retry, exponential backoff, concurrent-retry caps, configurable timeout | +| Durable webhook retry queues | ✅ Done | Pluggable `RetryQueue` adapters — memory, Redis, SQS — survive process restarts | | Edge-runtime webhook verification | ✅ Done | `verifyWebhookEdge` for Cloudflare Workers and Vercel Edge (Web Crypto API) | -| React hooks (`useStellarEvent`, `useStellarPayment`, `useStellarActivity`) | ✅ Done | Type-narrowing generic on `useStellarEvent`, multi-event subscription, stable config rules | +| React hooks (`useStellarEvent`, `useContractEvent`, `useStellarPayment`, `useStellarActivity`, `useStellarAddresses`, `useStellarHistory`) | ✅ Done | Type-narrowing generic on `useStellarEvent`, multi-event subscription, stable config rules | | Custom Horizon URL override | ✅ Done | `CoreConfig.horizonUrl` for self-hosted nodes / regional mirrors / futurenet | | Engine lifecycle notifications | ✅ Done | `engine.reconnecting`, `engine.reconnected`, `engine.rate_limited`, `engine.stopped` | | Public marketing + documentation site (`apps/web`) | ✅ Done | Next.js 16, Tailwind CSS 4. Hosts the docs, the sandboxed `/api/events/[address]` SSE demo, and the `/api/webhook-sample` signing demo. | | Testnet + mainnet support | ✅ Done | Network selector via `network: "mainnet" \| "testnet"` | | CI/CD pipeline | ✅ Done | GitHub Actions on Node 20 and 22, CodeQL, Dependabot | +| npm publish | ✅ Done | All four packages live under the `@orbital-stellar` scope | | MIT License & open-source setup | ✅ Done | `LICENSE`, `CONTRIBUTING.md`, `SECURITY.md` | --- @@ -43,9 +47,10 @@ All three packages are feature-complete for Phase 0 scope and ready for use agai ``` orbital_stellar/ ├── packages/ # MIT-licensed SDKs published to npm -│ ├── pulse-core/ # Event engine — Horizon + Soroban subscription -│ ├── pulse-webhooks/ # HMAC webhook delivery + verification -│ └── pulse-notify/ # React hooks +│ ├── pulse-core/ # Event engine — Horizon + Soroban subscription, cursor persistence +│ ├── pulse-webhooks/ # HMAC webhook delivery + verification, durable retry queues +│ ├── pulse-notify/ # React hooks +│ └── abi-registry/ # Soroban ABI client, schema helpers, registry publisher ├── apps/ │ └── web/ # Marketing + docs site + sandboxed demo API routes (Vercel) ├── docs/ @@ -64,28 +69,36 @@ orbital_stellar/ ### 1. `@orbital-stellar/pulse-core` — Event Engine -Subscribes to Horizon SSE, normalizes raw operations into a typed `NormalizedEvent` taxonomy, and routes them to per-address `Watcher` instances. Handles reconnection, backoff, and rate-limit responses automatically. +Subscribes to Horizon SSE and Stellar RPC (Soroban), normalizes raw operations and contract events into a typed `NormalizedEvent` taxonomy, and routes them to per-address `Watcher` instances. Handles reconnection, backoff, rate-limit responses, and cursor persistence (memory, file, Postgres, Redis, S3 adapters) automatically. -**Status:** Production-ready for Phase 0 scope (full classic operation taxonomy). Soroban event subscription is Phase 1. +**Status:** Production-ready — full classic operation taxonomy plus Soroban contract event subscription and cursor persistence. See [`packages/pulse-core/README.md`](./packages/pulse-core/README.md) for the API and [`packages/pulse-core/CHANGELOG.md`](./packages/pulse-core/CHANGELOG.md) for the per-feature commit trail. ### 2. `@orbital-stellar/pulse-webhooks` — Webhook Delivery -Attaches to a `Watcher` and POSTs every event to one or more endpoints with HMAC-SHA256 signing, exponential backoff retry, configurable timeout, and SSRF hardening. `verifyWebhook` (Node) and `verifyWebhookEdge` (Web Crypto) are exported for the receiver side. +Attaches to a `Watcher` and POSTs every event to one or more endpoints with HMAC-SHA256 signing, exponential backoff retry, configurable timeout, SSRF hardening, and durable retry queues (memory, Redis, SQS adapters) that survive process restarts. `verifyWebhook` (Node) and `verifyWebhookEdge` (Web Crypto) are exported for the receiver side. -**Status:** Production-ready for Phase 0 scope. +**Status:** Production-ready. See [`packages/pulse-webhooks/README.md`](./packages/pulse-webhooks/README.md). ### 3. `@orbital-stellar/pulse-notify` — React Hooks -Browser-side React hooks (`useStellarEvent`, `useStellarPayment`, `useStellarActivity`) that open an SSE connection to your Orbital-powered backend and re-render on each event. Generic type narrowing supported on `useStellarEvent`. +Browser-side React hooks (`useStellarEvent`, `useContractEvent`, `useStellarPayment`, `useStellarActivity`, `useStellarAddresses`, `useStellarHistory`) that open an SSE connection to your Orbital-powered backend and re-render on each event. Generic type narrowing supported on `useStellarEvent`. -**Status:** Production-ready for Phase 0 scope. +**Status:** Production-ready. See [`packages/pulse-notify/README.md`](./packages/pulse-notify/README.md). +### 4. `@orbital-stellar/abi-registry` — ABI Registry Client + +Canonical client for fetching Soroban contract ABI specs (`AbiRegistryClient` over HTTP, `LocalAbiRegistryClient` for offline/self-hosted use), plus schema validation and `scval`/JS conversion helpers. Wired into `pulse-core`'s `EventEngine` to enrich `contract.emitted` events with typed `decodedData`. + +**Status:** Production-ready. + +See [`packages/abi-registry/README.md`](./packages/abi-registry/README.md). + --- ## Reference Composition: `apps/web` API routes @@ -128,15 +141,14 @@ Stellar Network (Horizon REST/SSE + Stellar RPC) │ ▼ @orbital-stellar/pulse-core -EventEngine · Watcher · Normalization · Reconnect · Backoff - │ - ┌────┴─────────────────┐ - ▼ ▼ -@orbital-stellar/pulse-webhooks @orbital-stellar/pulse-notify -HMAC delivery React hooks (browser SSE) -SSRF hardening useStellarEvent -Edge-runtime verify useStellarPayment - useStellarActivity +EventEngine · Watcher · Normalization · Reconnect · Backoff · Cursor persistence + │ ▲ + ┌────┴─────────────────┐ │ + ▼ ▼ │ +@orbital-stellar/pulse-webhooks @orbital-stellar/pulse-notify @orbital-stellar/abi-registry +HMAC delivery React hooks (browser SSE) ABI spec fetch + decode +Durable retry queues useStellarEvent / useContractEvent (wired into EventEngine +Edge-runtime verify useStellarPayment / useStellarActivity for contract.emitted) ``` --- @@ -152,39 +164,34 @@ Edge-runtime verify useStellarPayment - ✅ Security disclosure policy (`SECURITY.md`) - ✅ CodeQL static analysis on every PR - ✅ Dependabot for dependency CVE tracking - -### Phase 1 Scope -- 🔲 Cursor persistence (resumable streams) -- 🔲 Pluggable durable adapters (Redis, Postgres, S3) for replay -- 🔲 Soroban event subscription via Stellar RPC -- 🔲 ABI registry client for typed Soroban event decoding +- ✅ Cursor persistence (resumable streams) — `CursorStore` memory/file/Postgres/Redis/S3 adapters +- ✅ Durable retry queues — `RetryQueue` memory/Redis/SQS adapters +- ✅ Soroban event subscription via Stellar RPC +- ✅ ABI registry client for typed Soroban event decoding --- -## Phase 0 Scope Boundaries +## Scope Boundaries -These are **not** in Phase 0 and are tracked for Phase 1 or later: +Not yet in this repository, tracked for later phases: -1. **Soroban events** — contract event subscription via Stellar RPC. Phase 1. -2. **Cursor persistence** — resumable streams across process restarts. Phase 1. -3. **Webhook replay store** — durable retry adapters for Redis/Postgres/S3. Phase 1. -4. **Production hosting** — multi-region orchestration, persistent registries, leader election. Belongs in **Orbital Cloud** (separate closed product), not in this repository. -5. **`@orbital-stellar/hooks`, `@orbital-stellar/payments`, `@orbital-stellar/auth`** — Phase 2 SDK family. See [`ROADMAP.md`](./ROADMAP.md). -6. **`@orbital-stellar/x402`, `@orbital-stellar/agent-sdk`** — Phase 3. See [`ROADMAP.md`](./ROADMAP.md). +1. **Production hosting** — multi-region orchestration, persistent registries, leader election. Belongs in **Orbital Cloud** (separate closed product), not in this repository. +2. **`@orbital-stellar/hooks`, `@orbital-stellar/payments`, `@orbital-stellar/auth`** — Phase 2 SDK family. See [`ROADMAP.md`](./ROADMAP.md). +3. **`@orbital-stellar/x402`, `@orbital-stellar/agent-sdk`** — Phase 3. See [`ROADMAP.md`](./ROADMAP.md). +4. **`v1.0` stability pledge** — formal semver contract, tracked in [`ROADMAP.md`](./ROADMAP.md). --- -## Next Steps: Phase 1 (Q2–Q3 2026) +## Next Steps: Phase 2 -| Milestone | Q2 2026 | Q3 2026 | -|---|---|---| -| **Events** | Soroban event subscription (Stellar RPC) | ABI registry client for typed decoding | -| **Types** | Discriminated union refinement (exhaustive `switch`) | — | -| **Persistence** | Cursor persistence in `pulse-core` | Pluggable replay adapters in `pulse-webhooks` | -| **Distribution** | Starter boilerplates (`next`, `express`, `anchor`) | npm publish under `@orbital-stellar/` | -| **Stability** | — | `v1.0` stability pledge — semver contract | +| Milestone | Target | +|---|---| +| **SDKs** | `@orbital-stellar/hooks`, `@orbital-stellar/payments`, `@orbital-stellar/auth` | +| **Standards** | First SEP submission | +| **Distribution** | Starter boilerplates (`next`, `express`, `anchor`) | +| **Stability** | `v1.0` stability pledge — formal semver contract | -See [`ROADMAP.md`](./ROADMAP.md) for the full multi-year vision and [`docs/proposal.md`](./docs/proposal.md) for the Phase 1 SCF funding proposal. +See [`ROADMAP.md`](./ROADMAP.md) for the full multi-year vision and [`docs/proposal.md`](./docs/proposal.md) for the current SCF funding proposal. --- @@ -192,17 +199,17 @@ See [`ROADMAP.md`](./ROADMAP.md) for the full multi-year vision and [`docs/propo ### As a Stellar Developer 1. Read [Getting Started](./apps/web/content/getting-started/introduction.md) -2. Install: `pnpm add @orbital-stellar/pulse-core @orbital-stellar/pulse-webhooks @orbital-stellar/pulse-notify` +2. Install: `pnpm add @orbital-stellar/pulse-core @orbital-stellar/pulse-webhooks @orbital-stellar/pulse-notify @orbital-stellar/abi-registry` 3. Follow the [Quick Start](./apps/web/content/getting-started/quick-start.md) ### As a Contributor 1. Read [`CONTRIBUTING.md`](./CONTRIBUTING.md) -2. Browse [issues tagged `good-first-issue`](https://github.com/orbital/orbital/labels/good-first-issue) — Drips Wave Program rewards apply +2. Browse [issues tagged `good-first-issue`](https://github.com/determined-001/orbital_stellar/labels/good-first-issue) — Drips Wave Program rewards apply 3. Run `pnpm -r typecheck && pnpm test` before submitting ### As a Funder / Reviewer 1. Read [`docs/proposal.md`](./docs/proposal.md) for the SCF Infrastructure Grant ask -2. See [`CHANGELOG.md`](./CHANGELOG.md) for the Phase 0 commit trail +2. See [`CHANGELOG.md`](./CHANGELOG.md) for the full commit trail --- @@ -213,7 +220,7 @@ See [`ROADMAP.md`](./ROADMAP.md) for the full multi-year vision and [`docs/propo | Build Status | ✅ Passing | | Test Coverage | ✅ Core paths covered; integration tests gated by `INTEGRATION_TESTS=true` | | Security Scanning | ✅ CodeQL + Dependabot active | -| Documentation | ✅ Complete for Phase 0 | +| Documentation | ✅ Complete for the shipped SDK family | | License | ✅ MIT | | Workspace | ✅ pnpm 10 monorepo, Node 20 + 22 in CI | diff --git a/README.md b/README.md index 1ab642d7..25a9a0d6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Node](https://img.shields.io/badge/node-20%20%7C%2022-339933?style=flat-square&logo=node.js)](.github/workflows/ci.yml) [![Conventional Commits](https://img.shields.io/badge/commits-conventional-fe5196?style=flat-square&logo=conventionalcommits)](https://www.conventionalcommits.org) -> **Status**: Phase 0 — `v0.1.0` on npm  ·  **Networks**: testnet + mainnet  ·  **License**: MIT +> **Status**: `v0.1.0` on npm  ·  **Networks**: testnet + mainnet  ·  **License**: MIT **Stellar's biggest developer-experience gap isn't a missing API — it's that Horizon's firehose still requires every team to build their own event delivery.** @@ -50,12 +50,12 @@ The longer-form thesis, the multi-year vision, and the SCF grant case live in [` | Package | Description | Status | |---|---|---| -| [`@orbital-stellar/pulse-core`](./packages/pulse-core) | EventEngine — Horizon subscription, normalization, reconnection, rate-limit handling | ✅ Phase 0 | -| [`@orbital-stellar/pulse-webhooks`](./packages/pulse-webhooks) | HMAC-signed webhook delivery + verification (Node + edge runtimes) | ✅ Phase 0 | -| [`@orbital-stellar/pulse-notify`](./packages/pulse-notify) | React hooks — `useStellarEvent`, `useStellarPayment`, `useStellarActivity` | ✅ Phase 0 | -| [`@orbital-stellar/abi-registry`](./packages/abi-registry) | Canonical Soroban ABI client, schema helpers, and registry publisher interface | ✅ Phase 1 | +| [`@orbital-stellar/pulse-core`](./packages/pulse-core) | EventEngine — Horizon + Soroban subscription, normalization, reconnection, rate-limit handling, cursor persistence | ✅ Shipped | +| [`@orbital-stellar/pulse-webhooks`](./packages/pulse-webhooks) | HMAC-signed webhook delivery + verification (Node + edge runtimes), durable retry queues | ✅ Shipped | +| [`@orbital-stellar/pulse-notify`](./packages/pulse-notify) | React hooks — `useStellarEvent`, `useContractEvent`, `useStellarPayment`, `useStellarActivity`, `useStellarAddresses`, `useStellarHistory`, `StellarConnectionStatus`, `StellarEventBoundary` | ✅ Shipped | +| [`@orbital-stellar/abi-registry`](./packages/abi-registry) | Canonical Soroban ABI client, schema helpers, and registry publisher interface | ✅ Shipped | -> The full classic-operation taxonomy is shipped (payments, account create/merge/options/bump-sequence, trustlines + auth, offers, claimables, liquidity pools, manage-data). Soroban contract events are Phase 1 (Q2–Q3 2026) — see [`ROADMAP.md`](ROADMAP.md). +> The full classic-operation taxonomy is shipped (payments, account create/merge/options/bump-sequence, trustlines + auth, offers, claimables, liquidity pools, manage-data), alongside Soroban contract event subscription (`engine.subscribeContract`), cursor persistence, and the ABI registry client — see [`ROADMAP.md`](ROADMAP.md). --- @@ -144,13 +144,14 @@ Run it against testnet, send a test payment from the [Stellar Laboratory](https: flowchart LR subgraph Stellar["Stellar network"] Horizon["Horizon REST + SSE"] - RPC["Stellar RPC
(Soroban) — Phase 1"] + RPC["Stellar RPC
(Soroban events)"] end subgraph Core["@orbital-stellar/pulse-core"] Engine["EventEngine
subscribe · reconnect · backoff"] Watcher["Watcher
per-address pub/sub"] Normalize["Normalize
13 op types → typed events"] + Cursor["Cursor persistence
memory · file · Postgres · Redis · S3"] end subgraph Webhooks["@orbital-stellar/pulse-webhooks"] @@ -163,8 +164,9 @@ flowchart LR end Horizon --> Engine - RPC -.-> Engine + RPC --> Engine Engine --> Normalize --> Watcher + Engine --> Cursor Watcher --> Sign Watcher --> Hooks Sign -->|x-orbital-signature| YourBackend["Your endpoint"] @@ -204,8 +206,7 @@ Two paths: ## Roadmap -- **Now (Phase 0)** — Full classic operation taxonomy, edge-runtime webhook verification, React hooks ✅ -- **Q2–Q3 2026 (Phase 1, `v1.0`)** — Soroban event subscription, ABI registry client, cursor persistence, replay adapters, npm publish, stability pledge +- **Shipped** — Full classic operation taxonomy, edge-runtime webhook verification, React hooks, Soroban event subscription, ABI registry client, cursor persistence, durable retry queues, npm publish ✅ - **2027 (Phase 2)** — `@orbital-stellar/hooks`, `@orbital-stellar/payments`, `@orbital-stellar/auth`, first SEP submission - **2028+ (Phase 3)** — `@orbital-stellar/x402`, `@orbital-stellar/agent-sdk`, intent compiler diff --git a/ROADMAP.md b/ROADMAP.md index 75d994f0..53f80030 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,7 +18,7 @@ | Phase | Theme | Tag | Target gate | Status | |---|---|---|---|---| | **Phase 0 — Foundation** | Typed SDKs for Stellar classic operations | `v0.1.0` | `pnpm -r typecheck && pnpm test` green; tag pushed; CHANGELOG entry shipped | 🟢 **Released 2026-05-29** | -| **Phase 1 — Production SDK** | Soroban + cursor persistence + stability pledge | `v1.0.0` | `pnpm publish -r --filter "./packages/*"` succeeds; STABILITY.md merged; Soroban e2e test green | ⚪ Q2–Q3 2026 | +| **Phase 1 — Production SDK** | Soroban + cursor persistence + stability pledge | `v1.0.0` | `pnpm publish -r --filter "./packages/*"` succeeds; STABILITY.md merged; Soroban e2e test green | 🟡 **In progress** — Soroban subscription, ABI registry client, and cursor/retry persistence shipped; stability pledge + SEP draft outstanding | | **Phase 2 — SDK Ecosystem** | `@orbital-stellar/hooks`, `@orbital-stellar/payments`, `@orbital-stellar/auth`, first SEP | `v2.0.0` | First SEP submission accepted or under review by SDF; `useBalance` + `useTransaction` on npm | ⚪ 2027 | | **Phase 3 — Trust & Agent Layer** | x402, agent-sdk, intent compiler, shadow-fork | `v3.0.0` | x402 reference deployed; intent compiler OSS; ≥1,000 agent integrations | ⚪ 2028+ | | **Phase 4 — Protocol Permanence** | Identity layer, reactor library, 10+ SEPs | n/a | 10 SEPs authored or co-authored; Orbital identity in `@orbital-stellar/auth` ≥80% of major Stellar apps | ⚪ long-term | @@ -70,32 +70,32 @@ --- -## Phase 1 — Production-grade SDK (`v1.0.0`, Q2–Q3 2026) +## Phase 1 — Production-grade SDK (`v1.0.0`, in progress) **Goal:** a stability-pledged `v1.0` that teams can build production systems on. -**Release gate:** `pnpm publish -r --filter "./packages/*"` succeeds against npm with `version: "1.0.0"`; [`STABILITY.md`](./STABILITY.md) merged with documented semver contract; Soroban subscription e2e test passing against testnet RPC; M1–M6 in [`docs/proposal.md`](./docs/proposal.md) all check out. +**Release gate:** `pnpm publish -r --filter "./packages/*"` succeeds against npm with `version: "1.0.0"`; `STABILITY.md` merged with documented semver contract; Soroban subscription e2e test passing against testnet RPC; M1–M6 in [`docs/proposal.md`](./docs/proposal.md) all check out. Waves 1.1–1.3 below have shipped; the gate itself remains open pending the stability pledge (Wave 1.5). ### Wave 1.1 — Soroban event subscription -- [ ] Stellar RPC subscriber feeding the same normalization pipeline -- [ ] `contract.invoked` and `contract.emitted` normalized event types -- [ ] `engine.subscribeContract({ contractId, topics })` API -- [ ] Topic filter and contract ID filter +- [x] Stellar RPC subscriber feeding the same normalization pipeline +- [x] `contract.invoked` and `contract.emitted` normalized event types +- [x] `engine.subscribeContract({ contractId, topics })` API +- [x] Topic filter and contract ID filter ### Wave 1.2 — ABI Registry -- [ ] `@orbital-stellar/abi-registry` client package (TBD final naming) +- [x] `@orbital-stellar/abi-registry` client package - [ ] Schema spec published as a draft SEP - [ ] Hosted registry service (operated; client is MIT — see [`docs/open-source-policy.md`](./docs/open-source-policy.md)) -- [ ] `decodedData` field on `contract.emitted` for registered contracts +- [x] `decodedData` field on `contract.emitted` for registered contracts ### Wave 1.3 — Cursor persistence and replay primitives -- [ ] `CursorStore` interface on `EventEngine` config -- [ ] In-memory and on-disk reference adapters -- [ ] `RetryQueue` interface on `WebhookDelivery` -- [ ] In-memory reference adapter for retry queue +- [x] `CursorStore` interface on `EventEngine` config +- [x] Reference adapters — memory, file, Postgres, Redis, S3 +- [x] `RetryQueue` interface on `WebhookDelivery` +- [x] Reference adapters — memory, Redis, SQS ### Wave 1.4 — Discriminated union refinement @@ -105,8 +105,8 @@ ### Wave 1.5 — Distribution - [ ] Starter boilerplates: `orbital-next-starter`, `orbital-express-starter`, `orbital-anchor-starter` -- [ ] `pnpm add @orbital-stellar/pulse-core` works against npm -- [ ] [`STABILITY.md`](./STABILITY.md) — semver contract, deprecation window (6 months), breaking-change policy +- [x] `pnpm add @orbital-stellar/pulse-core` works against npm +- [ ] `STABILITY.md` — semver contract, deprecation window (6 months), breaking-change policy - [ ] `v1.0.0` git tag with full release notes --- diff --git a/apps/web/README.md b/apps/web/README.md index e9e4f929..7c07f162 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,4 +1,4 @@ -# @orbital-stellar/web +# orbital/web **The public Orbital site** — landing page, documentation, and live testnet demos. Built with Next.js 16, Tailwind CSS, and Framer Motion. Also hosts the marketing-demo API routes (`/api/events/[address]`, `/api/webhook-sample`) that power the on-page sandbox. diff --git a/apps/web/app/api/webhook-sample/route.ts b/apps/web/app/api/webhook-sample/route.ts index c8f0ad25..94d4554d 100644 --- a/apps/web/app/api/webhook-sample/route.ts +++ b/apps/web/app/api/webhook-sample/route.ts @@ -1,4 +1,5 @@ -import { createHmac, randomBytes } from "crypto"; +import { randomBytes } from "crypto"; +import { signWebhookPayload } from "@orbital-stellar/pulse-webhooks"; import { checkWebhookCooldown, clientIp } from "@/lib/demo-limits"; export const dynamic = "force-dynamic"; @@ -52,9 +53,7 @@ export async function POST(req: Request) { const event = generateSamplePayment(address); const payload = JSON.stringify(event); const timestamp = Date.now().toString(); - const signature = createHmac("sha256", secret) - .update(`${timestamp}.${payload}`) - .digest("hex"); + const signature = signWebhookPayload(payload, timestamp, secret); return Response.json({ event, diff --git a/apps/web/components/CodeSnippet.tsx b/apps/web/components/CodeSnippet.tsx deleted file mode 100644 index b82e5281..00000000 --- a/apps/web/components/CodeSnippet.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'use client' - -import { useState, useEffect, useRef } from 'react' - -type Tab = 'simple' | 'medium' | 'complex' - -const SNIPPETS: Record = { - simple: [ - "pulse.watch('GABC...1234', (event) => {", - " console.log('something happened', event)", - "})", - ], - medium: [ - "pulse.watch('GABC...1234')", - " .on('payment.received', (event) => {", - " notifyUser(event.amount)", - " })", - ], - complex: [ - "pulse.watch('GABC...1234', {", - " events: ['payment.received', 'contract.invoked'],", - " filters: { asset: 'USDC', minAmount: 100 },", - " delivery: {", - " webhook: 'https://myapp.com/hooks/stellar',", - " retries: 3,", - " signingSecret: 'whsec_...'", - " },", - " onEvent: (event) => { ... }", - "})", - ], -} - -const TAB_LABELS: Record = { - simple: 'Simple', - medium: 'Medium', - complex: 'Complex', -} - -export default function CodeSnippet() { - const [activeTab, setActiveTab] = useState('simple') - const [highlightedLine, setHighlightedLine] = useState(0) - const intervalRef = useRef | null>(null) - - useEffect(() => { - setHighlightedLine(0) - if (intervalRef.current) clearInterval(intervalRef.current) - - const lines = SNIPPETS[activeTab] - intervalRef.current = setInterval(() => { - setHighlightedLine((prev) => (prev + 1) % lines.length) - }, 800) - - return () => { - if (intervalRef.current) clearInterval(intervalRef.current) - } - }, [activeTab]) - - const lines = SNIPPETS[activeTab] - - return ( -
-
-

- Simple by default. Powerful when needed. -

-

- Drop in one function call. Add options as your app grows. -

- -
- {/* Tabs */} -
- {(Object.keys(SNIPPETS) as Tab[]).map((tab) => ( - - ))} -
- - {/* Code window */} -
-
- - - -
-
-              {lines.map((line, i) => (
-                
- {line} -
- ))} -
-
-
-
-
- ) -} diff --git a/apps/web/content/api/pulse-core.md b/apps/web/content/api/pulse-core.md index 148877a0..9116de89 100644 --- a/apps/web/content/api/pulse-core.md +++ b/apps/web/content/api/pulse-core.md @@ -104,7 +104,7 @@ Every event delivered to a `Watcher` is a member of the `NormalizedEvent` discri | `data.set` | A `manage_data` operation set or updated a data entry | | `data.cleared` | A `manage_data` operation removed a data entry | -Soroban contract events (`contract.event`, etc.) are coming in Phase 1 — see the [roadmap](https://github.com/orbital/orbital/blob/main/ROADMAP.md). +Soroban contract events (`contract.invoked`, `contract.emitted`) are also supported — see the [Soroban Event Subscription guide](/docs/guides/soroban-subscription). ## Engine notifications diff --git a/apps/web/content/getting-started/installation.md b/apps/web/content/getting-started/installation.md index eaf62b70..e76e2117 100644 --- a/apps/web/content/getting-started/installation.md +++ b/apps/web/content/getting-started/installation.md @@ -21,11 +21,14 @@ pnpm add @orbital-stellar/pulse-webhooks # React hooks (optional) pnpm add @orbital-stellar/pulse-notify react + +# ABI Registry client for Soroban event decoding (optional) +pnpm add @orbital-stellar/abi-registry ``` ## TypeScript -All three packages ship with full TypeScript types. No `@types/*` packages are needed. +All four packages ship with full TypeScript types. No `@types/*` packages are needed. ```json { @@ -55,8 +58,8 @@ Pick the one that matches your runtime. The signing side (`WebhookDelivery`) req Want to see the SDKs composed end-to-end before building your own backend? Clone the repo and run the marketing site — it ships a sandboxed Next.js route handler that subscribes to a Stellar address and streams events as SSE: ```bash -git clone https://github.com/orbital/orbital.git -cd orbital +git clone https://github.com/determined-001/orbital_stellar.git +cd orbital_stellar pnpm install NEXT_PUBLIC_NETWORK=testnet pnpm --filter orbital/web dev ``` diff --git a/apps/web/content/getting-started/introduction.md b/apps/web/content/getting-started/introduction.md index 38c82454..37eda0e4 100644 --- a/apps/web/content/getting-started/introduction.md +++ b/apps/web/content/getting-started/introduction.md @@ -58,7 +58,7 @@ Orbital normalizes the full Stellar classic operation set into a typed `Normaliz - **Liquidity pools** — `lp.deposited`, `lp.withdrawn` - **Data entries** — `data.set`, `data.cleared` -Soroban contract events are coming in Phase 1 (Q2–Q3 2026). See the [roadmap](https://github.com/orbital/orbital/blob/main/ROADMAP.md). +Soroban contract events are also supported — see the [Soroban Event Subscription guide](/docs/guides/soroban-subscription). ## Production hosting diff --git a/apps/web/content/guides/migrate-from-eventsource.md b/apps/web/content/guides/migrate-from-eventsource.md new file mode 100644 index 00000000..636f0ac5 --- /dev/null +++ b/apps/web/content/guides/migrate-from-eventsource.md @@ -0,0 +1,113 @@ +--- +title: Migrate from raw EventSource +description: Replace a hand-rolled EventSource integration with useStellarEvent or useContractEvent. +--- + +If your app already has a working `new EventSource(...)` call against a Stellar-events backend — your own or someone else's — this guide shows what `@orbital-stellar/pulse-notify` replaces and why. + +## What raw `EventSource` leaves on you + +A minimal hand-rolled integration looks like this: + +```tsx +"use client"; +import { useEffect, useRef, useState } from "react"; + +function useRawStellarEvents(serverUrl: string, address: string) { + const [event, setEvent] = useState(null); + const [connected, setConnected] = useState(false); + const sourceRef = useRef(null); + + useEffect(() => { + const source = new EventSource(`${serverUrl}/events/${address}`); + sourceRef.current = source; + + source.onopen = () => setConnected(true); + source.onerror = () => setConnected(false); + source.onmessage = (message) => { + try { + setEvent(JSON.parse(message.data)); + } catch { + // malformed payload — drop it + } + }; + + return () => { + source.close(); + sourceRef.current = null; + }; + }, [serverUrl, address]); + + return { event, connected }; +} +``` + +This works, but every app that writes it re-solves the same problems: + +- **No connection pooling.** Two components watching the same address open two separate `EventSource` connections instead of sharing one. +- **No type narrowing.** `event` is `unknown` — every consumer re-implements its own parsing and type guards. +- **No event-type filtering.** You get every event on the address and filter client-side, or you write a second hook per event type. +- **No `Last-Event-ID` handling.** A reconnect after a network blip silently misses whatever happened while the tab was disconnected. +- **No auth story.** Token refresh, `withCredentials`, and auth-expiry all need to be wired by hand. +- **Manual cleanup.** Forgetting to `close()` on unmount leaks a connection per mount. + +## The replacement: `useStellarEvent` + +```tsx +"use client"; +import { useStellarEvent } from "@orbital-stellar/pulse-notify"; + +function IncomingPayments({ address }: { address: string }) { + const { event, connected, error } = useStellarEvent( + "https://events.example.com", + address, + { event: "payment.received" }, + ); + + if (!connected) return

Connecting…

; + if (error) return

Error: {error}

; + if (!event) return

No payments yet.

; + return

+{event.amount} {event.asset} from {event.from.slice(0, 8)}…

; +} +``` + +What you get instead of the hand-rolled version above: + +- **Connection pooling** — every hook instance with the same `serverUrl` + `address` + `token` shares one `EventSource`. +- **Typed events** — `event` is a `NormalizedEvent` variant; pass a narrower union as the generic (see [Real-time Events → Type narrowing](/docs/guides/real-time-events#type-narrowing)) for exhaustive `switch` handling with no casts. +- **Event-type filtering** — pass `event: "payment.received"`, an array of types, or `"*"` for everything, without writing a second hook. +- **Last-Event-ID tracking** — the connection pool forwards the SSE `id:` field so a reconnecting backend (backed by `pulse-core`'s cursor persistence — see [Cursor Persistence](/docs/guides/cursor-persistence)) can resume without gaps. +- **Auth handling** — pass `token` and the hook forwards it as a query param; `onAuthExpired` is available for token-refresh flows. +- **Automatic cleanup** — the hook unsubscribes and closes the pooled connection when the last consumer unmounts. + +## Migrating a Soroban contract-event integration + +If your raw integration points at a `/contract_events/:contractId` endpoint instead, the same trade replaces it with `useContractEvent` — the contract-event counterpart to `useStellarEvent`, backed by [Soroban event subscription](/docs/guides/soroban-subscription) on the server side: + +```tsx +"use client"; +import { useContractEvent } from "@orbital-stellar/pulse-notify"; + +function ContractActivity({ contractId }: { contractId: string }) { + const { event, connected } = useContractEvent({ + serverUrl: "https://events.example.com", + contractId, + topics: ["transfer"], + }); + + if (!connected || !event) return

Waiting for events…

; + return

{event.type}: {JSON.stringify(event.decodedData ?? event.raw)}

; +} +``` + +## Migration steps + +1. Stand up (or confirm you already have) a backend SSE endpoint powered by `@orbital-stellar/pulse-core` — see [Real-time Events → Standing up an SSE endpoint](/docs/guides/real-time-events#standing-up-an-sse-endpoint) if you need one. +2. Replace each `new EventSource(...)` call site with `useStellarEvent` (classic operations) or `useContractEvent` (Soroban contract events). +3. Delete the manual `onopen`/`onmessage`/`onerror` wiring, the `useRef` cleanup, and any hand-rolled reconnect logic — the hook owns all of it. +4. If you were parsing raw JSON into your own shape, switch to the typed `NormalizedEvent` fields directly, or narrow with a generic as shown above. +5. If multiple components in your tree watch the same address, verify they now share one connection — check `connected` toggles together across instances, or inspect the pool via [`pulse-notify`'s DevTools panel](/docs/api/pulse-notify) in development. + +## What doesn't change + +Your backend SSE contract stays the same shape (`data: \n\n` per message, optional `id: `) — `pulse-notify` is a client-side replacement, not a protocol change. If your existing endpoint already emits that shape, no backend changes are required at all. diff --git a/apps/web/content/guides/soroban-subscription.md b/apps/web/content/guides/soroban-subscription.md index db8db68c..b4ddc7a7 100644 --- a/apps/web/content/guides/soroban-subscription.md +++ b/apps/web/content/guides/soroban-subscription.md @@ -3,7 +3,7 @@ title: Soroban Event Subscription description: Subscribe to Soroban smart-contract events with engine.subscribeContract, ContractFilter, and the contract.emitted / contract.invoked event shapes. --- -Orbital's Phase 1 adds native Soroban support. `EventEngine` polls the Soroban RPC for contract events and delivers them to your watchers as strongly-typed `contract.emitted` and `contract.invoked` events. +Orbital ships native Soroban support. `EventEngine` polls the Soroban RPC for contract events and delivers them to your watchers as strongly-typed `contract.emitted` and `contract.invoked` events. ## Setup diff --git a/apps/web/content/guides/webhook-durability.md b/apps/web/content/guides/webhook-durability.md index af2db5f2..7d236232 100644 --- a/apps/web/content/guides/webhook-durability.md +++ b/apps/web/content/guides/webhook-durability.md @@ -74,6 +74,5 @@ The same concepts apply to SQS; replace the `redis-cli` commands with `aws sqs r ## Further Reading -- [M4 RetryQueue Interface Documentation](../reference/retryqueue.md) -- [M4 DeadLetterStore Interface Documentation](../reference/deadletterstore.md) -- [DLQ CLI – Inspection & Replay](../cli/dlq-cli.md) +- [pulse-webhooks API reference](/docs/api/pulse-webhooks) — `RetryQueue` and `DeadLetterStore` interfaces, adapter constructors +- [Cursor Persistence](/docs/guides/cursor-persistence) — the equivalent durability story on the subscription side diff --git a/apps/web/lib/docroutes.ts b/apps/web/lib/docroutes.ts index a035de6a..322ce6c3 100644 --- a/apps/web/lib/docroutes.ts +++ b/apps/web/lib/docroutes.ts @@ -23,6 +23,7 @@ export const docSections: DocSection[] = [ { title: 'Webhooks', href: '/docs/guides/webhooks' }, { title: 'Real-time Events', href: '/docs/guides/real-time-events' }, { title: 'Webhook Durability', href: '/docs/guides/webhook-durability' }, + { title: 'Cursor Persistence', href: '/docs/guides/cursor-persistence' }, { title: 'ABI Registry & Typed Event Decoding', href: '/docs/guides/abi-registry' }, { title: 'Migrate from raw EventSource', href: '/docs/guides/migrate-from-eventsource' }, { title: 'Soroban Event Subscription', href: '/docs/guides/soroban-subscription' }, diff --git a/apps/web/package.json b/apps/web/package.json index c6a1c376..88bd81b8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@orbital-stellar/pulse-core": "workspace:*", + "@orbital-stellar/pulse-webhooks": "workspace:*", "framer-motion": "^12.42.1", "geist": "^1.7.2", "gray-matter": "^4.0.3", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2021ed7c..b4504da8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,7 +20,7 @@ 7. [React hook internals](#7-react-hook-internals) 8. [Trust boundaries and invariants](#8-trust-boundaries-and-invariants) 9. [Reference composition (`apps/web`)](#9-reference-composition-appsweb) -10. [Phase 1 evolution](#10-phase-1-evolution) +10. [Shipped since Phase 0, and what's still ahead](#10-shipped-since-phase-0-and-whats-still-ahead) 11. [File map](#11-file-map) --- @@ -29,10 +29,10 @@ Orbital is three planes sharing one vocabulary — the **normalized event**: -- **Subscription plane** — `@orbital-stellar/pulse-core` opens and maintains a - connection to Stellar (Horizon today, Stellar RPC + Soroban in Phase 1), - normalizes raw operations into a typed `NormalizedEvent` union, and routes - each event to per-address `Watcher` subscribers. +- **Subscription plane** — `@orbital-stellar/pulse-core` opens and maintains + connections to Stellar (Horizon SSE and Stellar RPC for Soroban contract + events), normalizes raw operations into a typed `NormalizedEvent` union, and + routes each event to per-address `Watcher` subscribers. - **Delivery plane** — `@orbital-stellar/pulse-webhooks` attaches to a `Watcher`, signs each event with HMAC-SHA256, and POSTs it to one or more HTTPS endpoints with retry, timeout, and SSRF safety. A second export @@ -46,13 +46,14 @@ Orbital is three planes sharing one vocabulary — the **normalized event**: flowchart LR subgraph Stellar["Stellar network"] Horizon["Horizon REST + SSE"] - RPC["Stellar RPC
Soroban — 🛠️ Phase 1"] + RPC["Stellar RPC
Soroban events"] end subgraph Subscribe["Subscription plane
@orbital-stellar/pulse-core"] Engine["EventEngine"] - Normalize["Normalize
13 op types → 21 events"] + Normalize["Normalize
13 op types → 21 events
+ contract.invoked/emitted"] Watcher["Watcher
per-address pub/sub"] + Cursor["CursorStore
memory · file · Postgres · Redis · S3"] end subgraph Deliver["Delivery plane
@orbital-stellar/pulse-webhooks"] @@ -61,11 +62,12 @@ flowchart LR end subgraph Consume["Consumption plane
@orbital-stellar/pulse-notify"] - Hooks["useStellarEvent
useStellarPayment
useStellarActivity"] + Hooks["useStellarEvent
useContractEvent
useStellarPayment
useStellarActivity"] end Horizon --> Engine - RPC -.-> Engine + RPC --> Engine + Engine --> Cursor Engine --> Normalize --> Watcher Watcher --> Sign --> YourEndpoint["Your HTTPS endpoint"] YourEndpoint --> Verify @@ -73,8 +75,8 @@ flowchart LR 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 +Each plane is independently installable from npm 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. @@ -94,9 +96,9 @@ together inside a single Next.js route handler — about 50 lines of glue. | **`useStellarEvent`** | 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 | +| **Soroban subscriber** | Subscribes to Stellar RPC for contract events, decodes via the ABI Registry, normalizes into the same `NormalizedEvent` union. | `packages/pulse-core/src/SorobanSubscriber.ts`, `SorobanRpcClient.ts` | ✅ | +| **Cursor persistence** | Pluggable durable store (`CursorStore`) for the Horizon/Soroban cursor so a process restart resumes from where it left off. Memory, file, Postgres, Redis, and S3 adapters. | `packages/pulse-core/src/CursorStore.ts` + adapters | ✅ | +| **Replay / retry adapters** | Pluggable durable queues (`RetryQueue`) for in-flight webhook retries. Memory, Redis, and SQS adapters. | `packages/pulse-webhooks/src/RetryQueue.ts` + adapters | ✅ | --- @@ -380,33 +382,39 @@ proper process manager. The whole composition is under 200 lines of TS. --- -## 10. Phase 1 evolution +## 10. Shipped since Phase 0, and what's still ahead -Where Phase 1 (`v1.0`, Q2–Q3 2026) bolts on without changing the public -API: +The following bolted on without changing the public API surface consumers +already depend on: - **Soroban event subscription.** A second source feeding into the same normalization pipeline. New event types (`contract.invoked`, `contract.emitted`) join the `NormalizedEvent` union; - existing consumers ignore them unless they subscribe. + existing consumers ignore them unless they call + `engine.subscribeContract({ contractId, topics })`. - **ABI Registry client.** Decodes Soroban event topics + data into typed, - human-readable JSON. Lives in the separate `@orbital-stellar/abi-registry` - package so the registry data layer is independently versioned. + human-readable JSON via `decodedData` on `contract.emitted`. Lives in the + separate `@orbital-stellar/abi-registry` package so the registry data layer + is independently versioned. - **Cursor persistence.** A pluggable `CursorStore` interface stored on - `EventEngine` config. Implementations: in-memory (default, current - behavior), local file, Redis, Postgres, S3. On reconnect, the engine + `EventEngine` config. Implementations: in-memory (default), local file, + Redis, Postgres, S3. On reconnect with a configured store, the engine resumes from the stored cursor instead of `"now"`. - **Replay adapters.** A pluggable `RetryQueue` interface on - `WebhookDelivery`. Implementations: in-memory (current), Redis, Postgres, - SQS. Pending retries survive process restarts. + `WebhookDelivery`. Implementations: in-memory (default), Redis, SQS. + Pending retries survive process restarts when configured. + +Consumers opt in by passing new config (`cursorStore`, `retryQueue`, +`soroban`) — existing `on()` handlers are unaffected. + +Still ahead (tracked in [`ROADMAP.md`](../ROADMAP.md) Wave 1.4–1.5): + - **Discriminated union refinement.** Today the `NormalizedEvent` union is discriminated by `type` but several fields are `unknown` for 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. + reasons (the raw Horizon record). A future pass narrows these to typed + shapes with the help of generated schemas from Horizon's OpenAPI. +- **`STABILITY.md`** — a formal semver contract and deprecation window, + the last gate before a `v1.0.0` tag. --- @@ -416,21 +424,34 @@ existing `on()` handlers unchanged. orbital_stellar/ ├── packages/ # Published SDKs (MIT, npm) │ ├── pulse-core/ -│ │ ├── src/ -│ │ │ ├── EventEngine.ts # ~1100 lines — engine + normalizers + routing +│ │ ├── src/ # 27 files — engine, normalizers, routing, +│ │ │ │ # Soroban subscriber/RPC client, 5 cursor-store +│ │ │ │ # adapters, backoff, address/amount helpers +│ │ │ ├── EventEngine.ts # ~2300 lines — engine + normalizers + routing +│ │ │ ├── SorobanSubscriber.ts # Soroban RPC polling + reconnect +│ │ │ ├── SorobanRpcClient.ts # Stellar RPC getEvents client +│ │ │ ├── CursorStore.ts + {Memory,File,Postgres,Redis,S3}CursorStore.ts │ │ │ ├── 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 +│ │ ├── test/ # Vitest suites (500+ passing across 50 files) +│ │ └── bench/ # tsx benchmark harnesses (throughput, Soroban) │ ├── pulse-webhooks/ │ │ ├── src/ │ │ │ ├── index.ts # WebhookDelivery + verifyWebhook (Node) │ │ │ ├── edge.ts # verifyWebhookEdge (Web Crypto) +│ │ │ ├── signing.ts # Shared HMAC signing helper +│ │ │ ├── RetryQueue.ts + {Memory,Redis,Sqs}RetryQueue.ts +│ │ │ ├── cli.ts, bin/orbital # `orbital dlq` CLI (list/dump/replay) │ │ │ └── types.ts # WebhookConfig -│ │ └── test/ # Vitest suites (13 passing) -│ └── pulse-notify/ -│ └── src/index.ts # ~120 lines — three React hooks +│ │ └── test/ # Vitest suites (180+ passing) +│ ├── pulse-notify/ +│ │ └── src/index.ts + hooks # useStellarEvent, useContractEvent, +│ │ # useStellarPayment, useStellarActivity, +│ │ # useStellarAddresses, useStellarHistory +│ └── abi-registry/ +│ └── src/ # AbiRegistryClient, LocalAbiRegistryClient, +│ # scval/JS conversion, schema validation ├── apps/ │ └── web/ # Marketing site + reference composition │ ├── app/ @@ -447,7 +468,7 @@ orbital_stellar/ │ └── 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 +├── PROGRESS.md # Status snapshot ├── ROADMAP.md # Multi-year phase plan ├── CHANGELOG.md # Rolled-up release notes └── CONTRIBUTING.md # Dev loop + PR conventions @@ -457,11 +478,12 @@ orbital_stellar/ ## Related documents -- [`PROGRESS.md`](../PROGRESS.md) — Phase 0 completion checklist +- [`PROGRESS.md`](../PROGRESS.md) — Status snapshot and completion checklist - [`ROADMAP.md`](../ROADMAP.md) — Phase 0 → Phase 4 timeline - [`CHANGELOG.md`](../CHANGELOG.md) — Per-release notes - [`docs/proposal.md`](./proposal.md) — SCF grant proposal - Per-package READMEs: [`pulse-core`](../packages/pulse-core/README.md), [`pulse-webhooks`](../packages/pulse-webhooks/README.md), - [`pulse-notify`](../packages/pulse-notify/README.md) + [`pulse-notify`](../packages/pulse-notify/README.md), + [`abi-registry`](../packages/abi-registry/README.md) diff --git a/docs/COOKBOOK.md b/docs/COOKBOOK.md index 89721b1b..cc674937 100644 --- a/docs/COOKBOOK.md +++ b/docs/COOKBOOK.md @@ -5,8 +5,8 @@ > deployment, hardening) see the guides at > [`apps/web/content/guides/`](../apps/web/content/guides/). > -> **Legend.** ✅ ships in `v0.1.0` and runs against testnet/mainnet today. -> 🛠️ ships in Phase 1 (`v1.0`, Q2–Q3 2026). +> **Legend.** ✅ ships today and runs against testnet/mainnet. +> 🛠️ planned, tracked in [`ROADMAP.md`](../ROADMAP.md). --- @@ -23,7 +23,7 @@ 9. [Route `webhook.failed` to a dead-letter queue](#9-route-webhookfailed-to-a-dead-letter-queue) 10. [Render live payments in React with type narrowing](#10-render-live-payments-in-react-with-type-narrowing) 11. [Stand up an SSE endpoint in Next.js](#11-stand-up-an-sse-endpoint-in-nextjs) -12. [Subscribe to Soroban contract events 🛠️](#12-subscribe-to-soroban-contract-events-) +12. [Subscribe to Soroban contract events](#12-subscribe-to-soroban-contract-events) 13. [Unit test webhooks with deterministic jitter](#13-unit-test-webhooks-with-deterministic-jitter) --- @@ -405,12 +405,11 @@ The `globalThis` trick keeps one `EventEngine` alive across Next.js HMR. In prod --- -## 12. Subscribe to Soroban contract events 🛠️ +## 12. Subscribe to Soroban contract events -Phase 1, lands in `v1.0`. Subscribes to smart-contract events by contract ID and topic filter via Stellar RPC. Same normalized-event taxonomy as classic operations, with two new types: `contract.invoked` and `contract.emitted`. +Subscribes to smart-contract events by contract ID and topic filter via Stellar RPC. Same normalized-event taxonomy as classic operations, with two new types: `contract.invoked` and `contract.emitted`. ```ts -// 🛠️ Planned API — Phase 1 (Q2–Q3 2026) import { EventEngine } from "@orbital-stellar/pulse-core"; const engine = new EventEngine({ @@ -431,7 +430,7 @@ watcher.on("contract.emitted", (event) => { }); ``` -Decoding to typed `decodedData` requires the ABI Registry client (also Phase 1). Until then, raw XDR is exposed in `event.raw`. Track Phase 1 progress in [`ROADMAP.md`](../ROADMAP.md). +Decoding to typed `decodedData` requires the ABI Registry client (`@orbital-stellar/abi-registry`), wired in via `EventEngine`'s ABI registry config — see [`packages/abi-registry/README.md`](../packages/abi-registry/README.md). Without a registered ABI, raw XDR is exposed in `event.raw`. --- diff --git a/docs/open-source-policy.md b/docs/open-source-policy.md index 83b48210..ae6eadaf 100644 --- a/docs/open-source-policy.md +++ b/docs/open-source-policy.md @@ -39,26 +39,25 @@ Capture the category in open source. Capture the revenue in operations. Never co Everything below is in `packages/` or `apps/` today, or will be added to one of them. All of it is MIT-licensed via the [`LICENSE`](../LICENSE) at the repo root. -### Today (Phase 0, `v0.1.0`) +### Shipped today -- `@orbital-stellar/pulse-core` — EventEngine, Watcher, normalization layer, reconnection state machine -- `@orbital-stellar/pulse-webhooks` — `WebhookDelivery`, `verifyWebhook`, `verifyWebhookEdge` -- `@orbital-stellar/pulse-notify` — `useStellarEvent`, `useStellarPayment`, `useStellarActivity` -- Event schemas — the `NormalizedEvent` discriminated union and per-event TypeScript shapes +- `@orbital-stellar/pulse-core` — EventEngine, Watcher, normalization layer, reconnection state machine, Soroban event subscriber, cursor persistence (`CursorStore` interface + memory/file/Postgres/Redis/S3 adapters) +- `@orbital-stellar/pulse-webhooks` — `WebhookDelivery`, `verifyWebhook`, `verifyWebhookEdge`, durable retry queues (`RetryQueue` interface + memory/Redis/SQS adapters) +- `@orbital-stellar/pulse-notify` — `useStellarEvent`, `useContractEvent`, `useStellarPayment`, `useStellarActivity`, `useStellarAddresses`, `useStellarHistory` +- `@orbital-stellar/abi-registry` — ABI Registry client library, schema, and `RegistryPublisher` interface +- Event schemas — the `NormalizedEvent` discriminated union and per-event TypeScript shapes, including `contract.invoked` / `contract.emitted` - Webhook delivery contract — header format, signing scheme, retry rules -- Reference composition — the Next.js route handlers in `apps/web/app/api/*` that wire the three packages together end-to-end +- Reference composition — the Next.js route handlers in `apps/web/app/api/*` that wire the packages together end-to-end - All test suites - All ADRs (`docs/adr/`) - Marketing + documentation site source (`apps/web/`) - Per-package READMEs, CONTRIBUTING, SECURITY, CHANGELOG -### Phase 1 (`v1.0`, Q2–Q3 2026) +### Remaining Phase 1 work -- Soroban event subscriber (plug into the same normalization pipeline) -- ABI Registry client library, schema, and RegistryPublisher interface -- Cursor persistence **interface** + the in-memory and on-disk reference adapters -- Replay-queue **interface** + the in-memory reference adapter - Starter boilerplates (`orbital-next-starter`, `orbital-express-starter`, `orbital-anchor-starter`) +- `v1.0` stability pledge (`STABILITY.md` — semver contract, deprecation window) +- ABI Registry schema published as a draft SEP; hosted registry service (see [ABI Registry](#abi-registry) below) ### ABI Registry @@ -116,13 +115,13 @@ These live in a **separate private repository** (Orbital Cloud) that imports the The public packages expose **interfaces**; the private repository ships **adapters** against those interfaces. The interfaces stay MIT; the adapters can be closed. -Examples of the boundary, today and in Phase 1: +Examples of the boundary: | Public interface (MIT) | Private adapter (Closed) | |---|---| | `CoreConfig.horizonUrl` | Orbital-operated mainnet Horizon node behind an authenticated endpoint | -| `CoreConfig.cursorStore` *(Phase 1)* | Multi-region Postgres cursor store with consensus on dedup | -| `WebhookDelivery.retryQueue` *(Phase 1)* | Managed Redis / Postgres queue with manual replay UI | +| `CoreConfig.cursorStore` | Multi-region Postgres cursor store with consensus on dedup | +| `WebhookDelivery.retryQueue` | Managed Redis / Postgres queue with manual replay UI | | `WebhookDelivery` event handlers (`webhook.failed`, `webhook.dropped`) | Hosted dead-letter explorer and forensics console | | `EventEngine.subscribe(address, { filter })` | Hosted address-and-event-type RBAC scopes | diff --git a/docs/proposal.md b/docs/proposal.md index 8e9f3e45..d3ef9ae9 100644 --- a/docs/proposal.md +++ b/docs/proposal.md @@ -1,10 +1,10 @@ # Orbital — Stellar's Real-Time Event SDK **Proposal Track:** SCF Infrastructure Grant (Build Award) -**Status:** Phase 0 shipped (`v0.1.0`) · Requesting funding for Phase 1 (`v1.0`) +**Status:** Phase 0 shipped (`v0.1.0`) · Phase 1's Soroban subscription, ABI registry client, and cursor persistence have since shipped too · Requesting funding for the remaining Phase 1 milestones (discriminated union refinement, starter boilerplates, `v1.0` stability pledge) **Repository:** https://github.com/determined-001/orbital_stellar **License:** MIT -**Last updated:** 2026-05-29 +**Last updated:** 2026-07-06 --- @@ -30,15 +30,16 @@ Orbital is the open-source SDK layer Stellar developers reach for when they need real-time event subscriptions, signed webhook delivery, and React integration — the primitives every team currently re-implements from scratch on top of Horizon SSE and Stellar RPC. -Three MIT-licensed packages, designed to be composed and (at Phase 1) published to npm with no vendor lock-in: +Four MIT-licensed packages, designed to be composed and published to npm under the `@orbital-stellar` scope with no vendor lock-in: | Package | Role | |---|---| -| `@orbital-stellar/pulse-core` | Event engine — Horizon + Stellar RPC subscription, normalized typed events, reconnection | -| `@orbital-stellar/pulse-webhooks` | HMAC-signed webhook delivery, retry, SSRF hardening, edge-runtime verification | -| `@orbital-stellar/pulse-notify` | React hooks (`useStellarEvent`, `useStellarPayment`, `useStellarActivity`) | +| `@orbital-stellar/pulse-core` | Event engine — Horizon + Stellar RPC (Soroban) subscription, normalized typed events, reconnection, cursor persistence | +| `@orbital-stellar/pulse-webhooks` | HMAC-signed webhook delivery, retry with durable queues, SSRF hardening, edge-runtime verification | +| `@orbital-stellar/pulse-notify` | React hooks (`useStellarEvent`, `useContractEvent`, `useStellarPayment`, `useStellarActivity`) | +| `@orbital-stellar/abi-registry` | Soroban ABI client, schema helpers, registry publisher interface | -Phase 0 (foundation) shipped as [`v0.1.0`](../CHANGELOG.md) — full classic operation taxonomy, edge-runtime verification, React hooks, reference composition. This proposal funds **Phase 1: production-grade `v1.0`** — the milestone at which any Stellar team can build on Orbital with a stability pledge. +Phase 0 (foundation) shipped as [`v0.1.0`](../CHANGELOG.md) — full classic operation taxonomy, edge-runtime verification, React hooks, reference composition. Since then, three of Phase 1's six milestones (M1 Soroban subscription, M2 ABI registry client, and the cursor-persistence half of M4) have also shipped. This proposal funds the **remaining Phase 1 work**: discriminated union refinement (M3), starter boilerplates (M5), and the `v1.0` stability pledge (M6) — the milestone at which any Stellar team can build on Orbital with a documented semver contract. --- @@ -100,26 +101,26 @@ Phase 0 is complete and released as [`v0.1.0`](../CHANGELOG.md). Independently v - [`docs/ARCHITECTURE.md`](./ARCHITECTURE.md) — system diagrams, component inventory, lifecycle sequence, trust boundaries. - [`PROGRESS.md`](../PROGRESS.md) — Phase 0 completion snapshot. - [`ROADMAP.md`](../ROADMAP.md) — multi-year phase plan. -- Per-package READMEs: [`pulse-core`](../packages/pulse-core/README.md), [`pulse-webhooks`](../packages/pulse-webhooks/README.md), [`pulse-notify`](../packages/pulse-notify/README.md). -- Test suites: **103 tests** in `pulse-core` (102 passing + 1 skipped), **13 tests** in `pulse-webhooks`, all green in CI. +- Per-package READMEs: [`pulse-core`](../packages/pulse-core/README.md), [`pulse-webhooks`](../packages/pulse-webhooks/README.md), [`pulse-notify`](../packages/pulse-notify/README.md), [`abi-registry`](../packages/abi-registry/README.md). +- Test suites: **500+ tests** in `pulse-core`, **180+ tests** in `pulse-webhooks`, all green in CI. --- ## Phase 1 — Production-grade `v1.0` (the SCF-funded milestone) -Phase 1 brings Orbital from "useful prototype" to a **stability-pledged `v1.0`** that teams can build production systems on. Six concrete deliverables, each with a clear merge criterion. +Phase 1 brings Orbital from "useful prototype" to a **stability-pledged `v1.0`** that teams can build production systems on. Six concrete deliverables. M1, M2, and the cursor-persistence half of M4 have shipped since this proposal was first drafted; M3, M5, and M6 remain the funded scope. -### M1 · Soroban event subscription +### M1 · Soroban event subscription — ✅ Delivered -Subscribe to smart contract events by contract ID and topic filter via Stellar RPC. Normalized into the same `NormalizedEvent` taxonomy as classic operations. +Subscribe to smart contract events by contract ID and topic filter via Stellar RPC. Normalized into the same `NormalizedEvent` taxonomy as classic operations, via `engine.subscribeContract({ contractId, topics })`. -**Done when:** A test subscribing to a deployed Soroban contract on testnet receives a typed event payload within 2 ledgers of emission. +**Done when:** A test subscribing to a deployed Soroban contract on testnet receives a typed event payload within 2 ledgers of emission. — met; see `packages/pulse-core/test/SorobanSubscriber.*.test.ts` and the integration test suite. -### M2 · ABI Registry client +### M2 · ABI Registry client — ✅ Delivered -Auto-decode Soroban event payloads into typed, human-readable JSON using a community-contributed ABI registry. Solves the "raw bytes" problem that makes Soroban events painful to consume today. +Auto-decode Soroban event payloads into typed, human-readable JSON using the `@orbital-stellar/abi-registry` client. Solves the "raw bytes" problem that makes Soroban events painful to consume today. -**Done when:** A registered contract's events are fully typed in the consumer's TypeScript autocomplete without manual decoding. +**Done when:** A registered contract's events are fully typed in the consumer's TypeScript autocomplete without manual decoding. — met; `EventEngine` enriches `contract.emitted` with typed `decodedData` when an `AbiRegistryClient` is configured. ### M3 · Discriminated union refinement @@ -127,11 +128,11 @@ Narrow `NormalizedEvent` types so `switch (event.type)` produces exhaustive type **Done when:** A `switch` over `event.type` with no `default` clause produces a TypeScript error if any event type is unhandled. -### M4 · Cursor persistence and replay primitives +### M4 · Cursor persistence and replay primitives — cursor half ✅ delivered, replay half remaining -Pluggable durable adapters (Redis, Postgres, S3) so consumers can implement crash-resilient streams and webhook replay. +Pluggable durable adapters so consumers can implement crash-resilient streams and webhook replay. Cursor persistence (`CursorStore`: memory, file, Postgres, Redis, S3) has shipped in `pulse-core`; durable webhook retry queues (`RetryQueue`: memory, Redis, SQS) have shipped in `pulse-webhooks`. Remaining under this milestone: hardening and documenting the replay story end-to-end (see `packages/pulse-webhooks/src/cli.ts`'s `orbital dlq replay` command). -**Done when:** Killing the worker process mid-stream and restarting it does not lose or duplicate events when configured with a Postgres cursor adapter. +**Done when:** Killing the worker process mid-stream and restarting it does not lose or duplicate events when configured with a Postgres cursor adapter. — met; see `packages/pulse-core/test/EventEngine.sorobanCursorResume.test.ts` and `test/integration/cursorResume.test.ts`. ### M5 · Starter boilerplates @@ -139,11 +140,11 @@ Three reference projects: `orbital-next-starter`, `orbital-express-starter`, `or **Done when:** Each starter is published, deploys to Vercel/Railway free tier, and is documented end-to-end on the marketing site. -### M6 · `v1.0` stability pledge + npm publish +### M6 · `v1.0` stability pledge + npm publish — npm publish ✅ delivered, stability pledge remaining -All three packages published under `@orbital-stellar/` on npm with a documented stability contract: no breaking changes within `v1.x` without a 6-month deprecation window. +All four packages are already published under `@orbital-stellar/` on npm. Remaining under this milestone: a documented stability contract — no breaking changes within `v1.x` without a 6-month deprecation window. -**Done when:** `pnpm add @orbital-stellar/pulse-core` works, semver policy is documented in `STABILITY.md`, and a v1.0 release is tagged on GitHub. +**Done when:** `pnpm add @orbital-stellar/pulse-core` works — met — semver policy is documented in `STABILITY.md`, and a v1.0 release is tagged on GitHub — both remaining. --- @@ -163,10 +164,10 @@ All three packages published under `@orbital-stellar/` on npm with a documented |---|---|---|---| | **Solo-maintainer bus factor** | High | High | The grant funds a contributor ladder. Phase 0 already shipped the Stellar Wave Program issue plumbing (150 pre-planned issues with complexity-tier rewards) and a contributors-table workflow. Target: ≥3 sustained external contributors with merged PRs by Phase 1 close. | | **QuickNode achieves feature parity within 12–18 months** | Medium | Medium | Moat is engineering depth + standards authorship, not feature count. Orbital's normalized-event format becomes a Phase 2 SEP submission; once ratified, QuickNode either conforms (validating Orbital) or diverges (fragmenting Stellar). MIT licensing + edge-runtime + React hooks remain hard to replicate inside a generic multi-chain product. | -| **Soroban event API churn** | Medium | Low | The Soroban subscriber is a pluggable source feeding the same normalization pipeline; upstream churn is isolated to one file (`packages/pulse-core/src/soroban-source.ts` per `docs/ARCHITECTURE.md` §10) and does not propagate to the public `NormalizedEvent` union. | -| **Horizon → Stellar RPC migration timeline** | Medium | Medium | `CoreConfig.horizonUrl` already supports custom backends; the RPC subscriber lands as M1 in Phase 1, giving consumers a typed migration path before Horizon's deprecation horizon. | +| **Soroban event API churn** | Medium | Low | The Soroban subscriber is a pluggable source feeding the same normalization pipeline; upstream churn is isolated to `packages/pulse-core/src/SorobanSubscriber.ts` and `SorobanRpcClient.ts` (per `docs/ARCHITECTURE.md` §10) and does not propagate to the public `NormalizedEvent` union. | +| **Horizon → Stellar RPC migration timeline** | Medium | Medium | `CoreConfig.horizonUrl` already supports custom backends; the RPC subscriber shipped as M1, giving consumers a typed migration path before Horizon's deprecation horizon. | | **Webhook secret leak in downstream consumer code** | Low | High | `WebhookDelivery` enforces HMAC-SHA256 with timing-safe comparison and SSRF block-lists by default; receivers use `verifyWebhook` (Node) or `verifyWebhookEdge` (Web Crypto) which both fail closed. See [`docs/ARCHITECTURE.md` §8](./ARCHITECTURE.md#8-trust-boundaries-and-invariants) for the full trust-boundary table. | -| **Grant-fund exhaustion before Phase 1 milestones complete** | Low | Medium | Milestones are independently shippable. M1, M3, M6 are unblocked from day one. If only $20K of $30K is consumed before pause, M1 + M3 + M6 alone produce a stability-pledged `v1.0` covering Soroban subscription, type narrowing, and the npm publish. | +| **Grant-fund exhaustion before Phase 1 milestones complete** | Low | Medium | Remaining milestones (M3, M5, M6) are independently shippable. M1, M2, and M4's cursor half are already delivered outside grant funding; M3 and M6 alone would close out a stability-pledged `v1.0` even if M5's starter boilerplates slip. | --- diff --git a/package.json b/package.json index 63c0545f..c1df265b 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,5 @@ "prettier": "^3.9.4", "typescript": "^6.0.3", "typescript-eslint": "^8.62.1" - }, - "overrides": { - "path-to-regexp": "0.1.13", - "vite": "7.3.3", - "axios": "1.17.0" } } diff --git a/packages/abi-registry/bin/abi-registry-generate b/packages/abi-registry/bin/abi-registry-generate index f8fa023a..7c202653 100644 --- a/packages/abi-registry/bin/abi-registry-generate +++ b/packages/abi-registry/bin/abi-registry-generate @@ -14,5 +14,5 @@ if (!inputPath || !outputPath) { const resolvedInputPath = resolve(process.cwd(), inputPath); const resolvedOutputPath = resolve(process.cwd(), outputPath); const spec = JSON.parse(readFileSync(resolvedInputPath, "utf8")); -const generated = generateContractTypes(spec, resolvedOutputPath); +const generated = generateContractTypes(spec); writeFileSync(resolvedOutputPath, generated, "utf8"); diff --git a/packages/abi-registry/package.json b/packages/abi-registry/package.json index efcd849a..00d7a174 100644 --- a/packages/abi-registry/package.json +++ b/packages/abi-registry/package.json @@ -40,7 +40,6 @@ "@types/node": "^26.0.1", "@vitest/coverage-v8": "^4.1.9", "ajv": "^8.17.1", - "tsx": "^4.22.4", "vitest": "^4.1.9", "zod": "^4.4.3" } diff --git a/packages/abi-registry/src/AbiRegistryClient.ts b/packages/abi-registry/src/AbiRegistryClient.ts index d5bb7439..32abfdbe 100644 --- a/packages/abi-registry/src/AbiRegistryClient.ts +++ b/packages/abi-registry/src/AbiRegistryClient.ts @@ -21,21 +21,13 @@ export const REGISTRY_SPEC_VERSION = 1; -import { LruCache } from "./LruCache.js"; +import { TtlLruCache, DEFAULT_MAX_CACHE_SIZE, DEFAULT_CACHE_TTL_MS } from "./TtlLruCache.js"; import type { AbiRegistryClientConfig, AbiRegistryClientTransport, XdrContractSpec, } from "./types.js"; -const DEFAULT_MAX_CACHE_SIZE = 512; -const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; - -type CacheEntry = { - value: XdrContractSpec | null; - expiresAt: number; -}; - /** * HTTP client for the Orbital ABI Registry API. * @@ -50,14 +42,15 @@ type CacheEntry = { export class AbiRegistryClient { private readonly baseUrl: string; private readonly transport: AbiRegistryClientTransport; - private readonly cache: LruCache; - private readonly ttlMs: number; + private readonly cache: TtlLruCache; constructor(config: AbiRegistryClientConfig) { this.baseUrl = config.baseUrl.replace(/\/$/, ""); this.transport = config.transport ?? fetch.bind(globalThis); - this.ttlMs = config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; - this.cache = new LruCache(config.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE); + this.cache = new TtlLruCache( + config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS, + config.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE, + ); } /** Fetch a single contract spec (cached). */ @@ -122,20 +115,11 @@ export class AbiRegistryClient { } private getCached(contractId: string): XdrContractSpec | null | undefined { - const entry = this.cache.get(contractId); - if (!entry) return undefined; - if (Date.now() > entry.expiresAt) { - this.cache.delete(contractId); - return undefined; - } - return entry.value; + return this.cache.get(contractId); } private setCache(contractId: string, value: XdrContractSpec | null): void { - this.cache.set(contractId, { - value, - expiresAt: Date.now() + this.ttlMs, - }); + this.cache.set(contractId, value); } /** diff --git a/packages/abi-registry/src/LocalAbiRegistryClient.ts b/packages/abi-registry/src/LocalAbiRegistryClient.ts index 17b6b01f..9351b81c 100644 --- a/packages/abi-registry/src/LocalAbiRegistryClient.ts +++ b/packages/abi-registry/src/LocalAbiRegistryClient.ts @@ -2,12 +2,9 @@ // Provides an ABI registry client that reads contract specs from a local directory. // This enables offline or self‑hosted usage without any network calls. -import { LruCache } from "./LruCache.js"; +import { TtlLruCache, DEFAULT_MAX_CACHE_SIZE, DEFAULT_CACHE_TTL_MS } from "./TtlLruCache.js"; import type { XdrContractSpec } from "./types.js"; -const DEFAULT_MAX_CACHE_SIZE = 512; -const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; - /** * Configuration for the LocalAbiRegistryClient. * `specsDir` is the absolute path to a directory containing one JSON file per contract spec. @@ -22,21 +19,17 @@ export interface LocalAbiRegistryClientConfig { cacheTtlMs?: number; } -type CacheEntry = { - value: XdrContractSpec | null; - expiresAt: number; -}; - export class LocalAbiRegistryClient { private readonly specsDir: string; - private readonly cache: LruCache; - private readonly ttlMs: number; + private readonly cache: TtlLruCache; constructor(config: LocalAbiRegistryClientConfig) { // Remove trailing slash if present this.specsDir = config.specsDir.replace(/\\\/$/, ""); - this.ttlMs = config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; - this.cache = new LruCache(config.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE); + this.cache = new TtlLruCache( + config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS, + config.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE, + ); } /** Fetch a single contract spec from the local directory (cached). */ @@ -74,17 +67,11 @@ export class LocalAbiRegistryClient { } private getCached(contractId: string): XdrContractSpec | null | undefined { - const entry = this.cache.get(contractId); - if (!entry) return undefined; - if (Date.now() > entry.expiresAt) { - this.cache.delete(contractId); - return undefined; - } - return entry.value; + return this.cache.get(contractId); } private setCache(contractId: string, value: XdrContractSpec | null): void { - this.cache.set(contractId, { value, expiresAt: Date.now() + this.ttlMs }); + this.cache.set(contractId, value); } private async loadFromDisk(contractId: string): Promise { @@ -94,7 +81,7 @@ export class LocalAbiRegistryClient { const data = await readFile(path, { encoding: "utf8" }); const spec: XdrContractSpec = JSON.parse(data); return spec; - } catch (e) { + } catch { // Missing file or parse error – treat as not found. return null; } diff --git a/packages/abi-registry/src/TtlLruCache.ts b/packages/abi-registry/src/TtlLruCache.ts new file mode 100644 index 00000000..631de52a --- /dev/null +++ b/packages/abi-registry/src/TtlLruCache.ts @@ -0,0 +1,39 @@ +// TtlLruCache.ts +// Shared TTL-on-top-of-LRU cache used by both AbiRegistryClient and +// LocalAbiRegistryClient — extracted so the two clients don't each carry a +// byte-identical copy of the same eviction/expiry logic. + +import { LruCache } from "./LruCache.js"; + +export const DEFAULT_MAX_CACHE_SIZE = 512; +export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; + +type CacheEntry = { + value: V; + expiresAt: number; +}; + +export class TtlLruCache { + private readonly cache: LruCache>; + + constructor( + private readonly ttlMs: number, + maxSize: number, + ) { + this.cache = new LruCache(maxSize); + } + + get(key: string): V | undefined { + const entry = this.cache.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return undefined; + } + return entry.value; + } + + set(key: string, value: V): void { + this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + } +} diff --git a/packages/abi-registry/src/decode.ts b/packages/abi-registry/src/decode.ts index 7b86c4ea..5d44628d 100644 --- a/packages/abi-registry/src/decode.ts +++ b/packages/abi-registry/src/decode.ts @@ -442,17 +442,6 @@ function parseSpecEntries(entries: string[]): xdr.ScSpecEntry[] { // Helpers // --------------------------------------------------------------------------- -function extractSymbol(raw: unknown): string | null { - if (typeof raw === "string") return raw; - if (raw !== null && typeof raw === "object") { - const obj = raw as Record; - if ("sym" in obj) return String(obj["sym"]); - if ("symbol" in obj) return String(obj["symbol"]); - if ("str" in obj) return String(obj["str"]); - } - return null; -} - function bufferToHex(value: unknown): string { if (value instanceof Uint8Array || Buffer.isBuffer(value as object)) { return Buffer.from(value as Uint8Array).toString("hex"); diff --git a/packages/abi-registry/src/generate.ts b/packages/abi-registry/src/generate.ts index 238cd923..78f26899 100644 --- a/packages/abi-registry/src/generate.ts +++ b/packages/abi-registry/src/generate.ts @@ -19,25 +19,6 @@ function toCamelCase(value: string): string { return pascal ? pascal.charAt(0).toLowerCase() + pascal.slice(1) : value; } -function toIdentifierName(value: string): string { - const normalized = value.replace(/[^a-zA-Z0-9]+/g, "_"); - const parts = normalized.split("_").filter(Boolean); - if (parts.length === 0) { - return "value"; - } - return parts - .map((part, index) => { - const cleaned = part.replace(/^[0-9]+/, ""); - if (!cleaned) { - return index === 0 ? "value" : "value"; - } - return index === 0 - ? cleaned.toLowerCase() - : cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase(); - }) - .join(""); -} - function ensureUniqueName(base: string, used: Set): string { if (!used.has(base)) { used.add(base); @@ -137,10 +118,7 @@ function mapTypeToZod(type: xdr.ScSpecTypeDef | undefined): string { } } -export function generateContractArtifacts( - spec: XdrContractSpec, - contractName: string, -): GeneratedContractArtifacts { +export function generateContractArtifacts(spec: XdrContractSpec): GeneratedContractArtifacts { const entries = spec.entries .map((entry) => { try { @@ -198,7 +176,7 @@ export function generateContractArtifacts( }; } -export function generateContractTypes(spec: XdrContractSpec, outputPath: string): string { - const artifacts = generateContractArtifacts(spec, outputPath); +export function generateContractTypes(spec: XdrContractSpec): string { + const artifacts = generateContractArtifacts(spec); return [artifacts.declarations, artifacts.schemas].filter(Boolean).join("\n\n"); } diff --git a/packages/abi-registry/test/generate.test.ts b/packages/abi-registry/test/generate.test.ts index aea9fd1e..23d08978 100644 --- a/packages/abi-registry/test/generate.test.ts +++ b/packages/abi-registry/test/generate.test.ts @@ -50,7 +50,7 @@ function createTokenSpec(): ContractSpec { describe("generateContractArtifacts", () => { it("generates typed interfaces and matching zod schemas for token events", () => { const spec = createTokenSpec(); - const artifacts = generateContractArtifacts(spec, "token"); + const artifacts = generateContractArtifacts(spec); expect(artifacts.declarations).toContain("export interface Transfer"); expect(artifacts.declarations).toContain("from: string;"); @@ -79,7 +79,7 @@ describe("generateContractArtifacts", () => { ], } satisfies ContractSpec; - const artifacts = generateContractArtifacts(spec, "token"); + const artifacts = generateContractArtifacts(spec); expect(artifacts.declarations).toContain("export interface TransferEvent"); expect(artifacts.declarations).toContain("export interface TransferEvent2"); diff --git a/packages/pulse-core/CHANGELOG.md b/packages/pulse-core/CHANGELOG.md index f8f98251..83358850 100644 --- a/packages/pulse-core/CHANGELOG.md +++ b/packages/pulse-core/CHANGELOG.md @@ -1,6 +1,8 @@ # pulse-core Changelog -## Unreleased +## [0.1.0] — 2026-05-28 ### Breaking Changes - **WatcherNotification API**: The `timestamp` field on `WatcherNotification` (`engine.reconnecting` and `engine.reconnected` events) has been renamed to `emittedAt` to distinguish it from the on-chain `created_at` timestamp used in other events like `payment.received`. + +See the root [`CHANGELOG.md`](../../CHANGELOG.md) for the full `v0.1.0` release notes across all packages. diff --git a/packages/pulse-core/README.md b/packages/pulse-core/README.md index b303b5e9..a96131a2 100644 --- a/packages/pulse-core/README.md +++ b/packages/pulse-core/README.md @@ -8,7 +8,7 @@ pnpm add @orbital-stellar/pulse-core ## What it does -`pulse-core` opens a single streaming connection to Horizon (and, coming soon, Stellar RPC for Soroban events), normalizes each incoming record into a uniform shape, and emits it to any `Watcher` subscribed to the affected address. Reconnection, backoff, and cleanup are handled automatically. +`pulse-core` opens streaming connections to Horizon and, when configured, Stellar RPC for Soroban contract events, normalizes each incoming record into a uniform shape, and emits it to any `Watcher` subscribed to the affected address. Reconnection, backoff, and cleanup are handled automatically. You install `pulse-core` when you want to consume Stellar events in-process — typically inside a server, background worker, or CLI. If you need webhook delivery or React integration, layer [`@orbital-stellar/pulse-webhooks`](../pulse-webhooks) or [`@orbital-stellar/pulse-notify`](../pulse-notify) on top. @@ -249,9 +249,8 @@ Each matching contract subscription receives both the typed event and the `*` wi ## Current limitations -- **Soroban subscription is still Phase 1 work.** Contract event normalization and in-process routing are present, with RPC handoff and restart resiliency covered by tests. Production cursor persistence and broader RPC integration continue under Phase 1 (`v1.0`, Q2–Q3 2026). Open issues tracked under [`core-engine`](https://github.com/determined-001/orbital_stellar/labels/core-engine). - **In-process only.** Horizontal scale and multi-region coordination belong in the deployment layer, not in the SDK. See [`docs/open-source-policy.md`](../../docs/open-source-policy.md) for the public/private boundary. -- **Cursor starts at `now` on every run.** Resume-from-cursor with pluggable adapters ships in Phase 1 — see [`ROADMAP.md`](../../ROADMAP.md#wave-13--cursor-persistence-and-replay-primitives). +- **Cursor starts at `now` unless a `cursorStore` is configured.** Without `CoreConfig.cursorStore`, every run starts from `"now"` (matches previous behavior). Passing a `cursorStore` (memory, file, Postgres, Redis, or S3 adapter — see [`ROADMAP.md`](../../ROADMAP.md#wave-13--cursor-persistence-and-replay-primitives)) makes the engine resume from the last persisted cursor on reconnect or restart instead. ## Related documents diff --git a/packages/pulse-core/package.json b/packages/pulse-core/package.json index f2678e3a..48adb1b3 100644 --- a/packages/pulse-core/package.json +++ b/packages/pulse-core/package.json @@ -47,7 +47,7 @@ "fast-check": "^4.8.0", "pg": "^8.22.0", "tsx": "^4.22.4", - "vite": "^8.1.1", + "vite": "^7.3.3", "vitest": "^4.1.9" } } diff --git a/packages/pulse-core/src/EventEngine.ts b/packages/pulse-core/src/EventEngine.ts index e007b6a4..97f7d04c 100644 --- a/packages/pulse-core/src/EventEngine.ts +++ b/packages/pulse-core/src/EventEngine.ts @@ -1,5 +1,6 @@ import { Horizon } from "@stellar/stellar-sdk"; import { Watcher } from "./Watcher.js"; +import { fullJitterBackoffMs } from "./backoff.js"; import { EngineAlreadyStartedError, NetworkMismatchError } from "./errors.js"; import { resolveSorobanPageLimit, SorobanSubscriber } from "./SorobanSubscriber.js"; import { SorobanRpcClient } from "./SorobanRpcClient.js"; @@ -45,7 +46,6 @@ import type { WatcherNotification, WatcherNotificationType, Logger, - CursorStore, CursorStoreLike, DecodeFailedNotification, RawHorizonPayment, @@ -986,11 +986,11 @@ export class EventEngine { emittedAt: new Date().toISOString(), }); } else { - const exponentialDelay = Math.min( - this.reconnectConfig.initialDelayMs * 2 ** (nextAttempt - 1), + delayMs = fullJitterBackoffMs( + nextAttempt, + this.reconnectConfig.initialDelayMs, this.reconnectConfig.maxDelayMs, ); - delayMs = Math.floor(Math.random() * exponentialDelay); this.log.warn("[pulse-core] SSE reconnect attempt scheduled.", { attempt: nextAttempt, diff --git a/packages/pulse-core/src/FileCursorStore.ts b/packages/pulse-core/src/FileCursorStore.ts index 4e468b17..4519d3f8 100644 --- a/packages/pulse-core/src/FileCursorStore.ts +++ b/packages/pulse-core/src/FileCursorStore.ts @@ -102,5 +102,3 @@ export class FileCursorStore extends CursorStore { } } } - -export default FileCursorStore; diff --git a/packages/pulse-core/src/S3CursorStore.ts b/packages/pulse-core/src/S3CursorStore.ts index 8537a47f..cf22306b 100644 --- a/packages/pulse-core/src/S3CursorStore.ts +++ b/packages/pulse-core/src/S3CursorStore.ts @@ -50,5 +50,3 @@ export class S3CursorStore { await this.s3.putObject({ Bucket: this.bucket, Key, Body }); } } - -export default S3CursorStore; diff --git a/packages/pulse-core/src/SorobanSubscriber.ts b/packages/pulse-core/src/SorobanSubscriber.ts index 9fa0c455..f34a53cf 100644 --- a/packages/pulse-core/src/SorobanSubscriber.ts +++ b/packages/pulse-core/src/SorobanSubscriber.ts @@ -1,3 +1,4 @@ +import { fullJitterBackoffMs } from "./backoff.js"; import type { ContractSubscriptionFilter, ContractAddress } from "./index.js"; import type { GetEventsOptions, @@ -479,11 +480,11 @@ export class SorobanSubscriber extends EventEmitter { emittedAt: new Date().toISOString(), }); } else { - const exponentialDelay = Math.min( - this.initialDelayMs * 2 ** (this.reconnectAttempt - 1), + delayMs = fullJitterBackoffMs( + this.reconnectAttempt, + this.initialDelayMs, this.maxDelayMs, ); - delayMs = Math.floor(Math.random() * exponentialDelay); this.emit("engine.reconnecting", { type: "engine.reconnecting", attempt: this.reconnectAttempt, diff --git a/packages/pulse-core/src/backoff.ts b/packages/pulse-core/src/backoff.ts new file mode 100644 index 00000000..f4aaf8a3 --- /dev/null +++ b/packages/pulse-core/src/backoff.ts @@ -0,0 +1,12 @@ +// backoff.ts +// Shared full-jitter exponential backoff used by both the Horizon reconnect +// path (EventEngine) and the Soroban reconnect path (SorobanSubscriber). + +export function fullJitterBackoffMs( + attempt: number, + initialDelayMs: number, + maxDelayMs: number, +): number { + const exponentialDelay = Math.min(initialDelayMs * 2 ** (attempt - 1), maxDelayMs); + return Math.floor(Math.random() * exponentialDelay); +} diff --git a/packages/pulse-core/src/index.ts b/packages/pulse-core/src/index.ts index 8fbc578c..efcebe2e 100644 --- a/packages/pulse-core/src/index.ts +++ b/packages/pulse-core/src/index.ts @@ -1,4 +1,3 @@ -import { CursorStore } from "./CursorStore.js"; import type { StellarAmount } from "./amount.js"; import type { AccountAddress, MuxedAddress, ContractAddress } from "./address.js"; import type { ClaimPredicate } from "./claimPredicate.js"; diff --git a/packages/pulse-core/test/EventEngine.sorobanCursorResume.test.ts b/packages/pulse-core/test/EventEngine.sorobanCursorResume.test.ts index c6f9aea2..410034a2 100644 --- a/packages/pulse-core/test/EventEngine.sorobanCursorResume.test.ts +++ b/packages/pulse-core/test/EventEngine.sorobanCursorResume.test.ts @@ -1,37 +1,6 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { EventEngine } from "../src/EventEngine.js"; import type { CursorStoreLike } from "../src/CursorStore.js"; -import type { SorobanRpc } from "../src/SorobanSubscriber.js"; - -/** - * Mock Soroban RPC that returns a single contract.emitted event on the first call. - */ -function createMockSorobanRpc(): SorobanRpc { - let callCount = 0; - return { - async getEvents(startCursor?: string, limit?: number) { - callCount++; - if (callCount === 1) { - return { - events: [ - { - id: "1-0", - pagingToken: "cursor-1", - type: "contract", - topic: ["transfer"], - value: { amount: "100" }, - contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", - inSuccessfulContractCall: true, - } as any, - ], - latestLedger: 1000, - cursor: "cursor-1", - }; - } - return { events: [], latestLedger: 1000, cursor: startCursor }; - }, - }; -} describe("EventEngine Soroban cursor resume", () => { it("uses horizon:${network} and soroban:${network} cursor keys by default", async () => { @@ -93,14 +62,14 @@ describe("EventEngine Soroban cursor resume", () => { it("tolerates cursorStore failures and continues event delivery", async () => { let failCount = 0; const cursorStore: CursorStoreLike = { - get: vi.fn(async (key: string) => { + get: vi.fn(async () => { failCount++; if (failCount <= 2) { throw new Error("store unavailable"); } return null; }), - set: vi.fn(async (key: string, cursor: string) => { + set: vi.fn(async () => { throw new Error("store unavailable"); }), }; @@ -133,10 +102,8 @@ describe("EventEngine Soroban cursor resume", () => { }); it("calls handleCursorFailure with the appropriate key context", async () => { - let callCount = 0; const cursorStore: CursorStoreLike = { - get: vi.fn(async (key: string) => { - callCount++; + get: vi.fn(async () => { // Fail on first call (before threshold, should not emit unhealthy) throw new Error("temporarily unavailable"); }), diff --git a/packages/pulse-core/test/eventTypeGuard.test.ts b/packages/pulse-core/test/eventTypeGuard.test.ts new file mode 100644 index 00000000..5547fc00 --- /dev/null +++ b/packages/pulse-core/test/eventTypeGuard.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { isEventType } from "../src/eventTypeGuard.js"; +import type { NormalizedEvent } from "../src/index.js"; + +const paymentReceived: NormalizedEvent = { + type: "payment.received", + to: "GDEST", + from: "GSRC", + amount: "10", + asset: "XLM", + timestamp: "2026-04-26T12:00:00.000Z", + raw: { id: "evt_1" }, +} as unknown as NormalizedEvent; + +const paymentSent: NormalizedEvent = { + type: "payment.sent", + to: "GDEST", + from: "GSRC", + amount: "10", + asset: "XLM", + timestamp: "2026-04-26T12:00:00.000Z", + raw: { id: "evt_2" }, +} as unknown as NormalizedEvent; + +const accountCreated: NormalizedEvent = { + type: "account.created", + account: "GDEST", + timestamp: "2026-04-26T12:00:00.000Z", + raw: { id: "evt_3" }, +} as unknown as NormalizedEvent; + +describe("isEventType", () => { + it("narrows to a single matching type", () => { + expect(isEventType(paymentReceived, "payment.received")).toBe(true); + }); + + it("narrows to any of multiple provided types (OR match)", () => { + expect(isEventType(paymentReceived, "payment.received", "payment.sent")).toBe(true); + expect(isEventType(paymentSent, "payment.received", "payment.sent")).toBe(true); + }); + + it("returns false when the event type is not in the provided list", () => { + expect(isEventType(accountCreated, "payment.received", "payment.sent")).toBe(false); + }); + + it("filters an array of events by type", () => { + const events = [paymentReceived, accountCreated, paymentSent]; + const payments = events.filter((e) => + isEventType(e, "payment.received", "payment.sent", "payment.self"), + ); + expect(payments).toEqual([paymentReceived, paymentSent]); + }); +}); diff --git a/packages/pulse-core/test/pulse-core.test.ts b/packages/pulse-core/test/pulse-core.test.ts index a9aa5820..55efa43f 100644 --- a/packages/pulse-core/test/pulse-core.test.ts +++ b/packages/pulse-core/test/pulse-core.test.ts @@ -2201,65 +2201,66 @@ describe("pulse-core EventEngine", () => { break; } case "account.options_changed": { - const raw: RawHorizonSetOptions | undefined = event.raw; + const _raw: RawHorizonSetOptions | undefined = event.raw; break; } case "account.created": { - const raw: RawHorizonCreateAccount | undefined = event.raw; + const _raw: RawHorizonCreateAccount | undefined = event.raw; break; } case "trustline.added": case "trustline.removed": case "trustline.updated": { - const raw: RawHorizonChangeTrust | undefined = event.raw; + const _raw: RawHorizonChangeTrust | undefined = event.raw; break; } case "account.merged": { - const raw: RawHorizonAccountMerge | undefined = event.raw; + const _raw: RawHorizonAccountMerge | undefined = event.raw; break; } case "offer.created": case "offer.updated": case "offer.deleted": { - const raw: RawHorizonManageSellOffer | RawHorizonManageBuyOffer | undefined = event.raw; + const _raw: RawHorizonManageSellOffer | RawHorizonManageBuyOffer | undefined = + event.raw; break; } case "account.bump_sequence": { - const raw: RawHorizonBumpSequence | undefined = event.raw; + const _raw: RawHorizonBumpSequence | undefined = event.raw; break; } case "data.set": case "data.cleared": { - const raw: RawHorizonManageData | undefined = event.raw; + const _raw: RawHorizonManageData | undefined = event.raw; break; } case "claimable.created": { - const raw: RawHorizonCreateClaimableBalance | undefined = event.raw; + const _raw: RawHorizonCreateClaimableBalance | undefined = event.raw; break; } case "claimable.claimed": { - const raw: RawHorizonClaimClaimableBalance | undefined = event.raw; + const _raw: RawHorizonClaimClaimableBalance | undefined = event.raw; break; } case "lp.deposited": { - const raw: RawHorizonLiquidityPoolDeposit | undefined = event.raw; + const _raw: RawHorizonLiquidityPoolDeposit | undefined = event.raw; break; } case "lp.withdrawn": { - const raw: RawHorizonLiquidityPoolWithdraw | undefined = event.raw; + const _raw: RawHorizonLiquidityPoolWithdraw | undefined = event.raw; break; } case "trustline.authorized": case "trustline.deauthorized": { - const raw: RawHorizonAllowTrust | RawHorizonSetTrustLineFlags | undefined = event.raw; + const _raw: RawHorizonAllowTrust | RawHorizonSetTrustLineFlags | undefined = event.raw; break; } case "contract.emitted": { - const raw = event.raw; + const _raw = event.raw; break; } case "contract.invoked": { - const raw = event.raw; + const _raw = event.raw; break; } default: { diff --git a/packages/pulse-notify/package.json b/packages/pulse-notify/package.json index 52f30c30..b4a760d7 100644 --- a/packages/pulse-notify/package.json +++ b/packages/pulse-notify/package.json @@ -55,7 +55,6 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "size-limit": "^12.1.0", - "tsx": "^4.22.4", "vitest": "^4.1.9" }, "peerDependencies": { diff --git a/packages/pulse-notify/src/connectionPool.ts b/packages/pulse-notify/src/connectionPool.ts index 9b525fa4..e8deeb74 100644 --- a/packages/pulse-notify/src/connectionPool.ts +++ b/packages/pulse-notify/src/connectionPool.ts @@ -1,21 +1,5 @@ import type { NormalizedEvent } from "@orbital-stellar/pulse-core"; - -type ConnectionKey = { - serverUrl: string; - address: string; - token?: string; - withCredentials?: boolean; -}; - -type ConnectionSubscriber = { - onOpen: () => void; - onEvent: (event: NormalizedEvent) => void; - onParseError: () => void; - onError: () => void; - onAuthExpired?: () => void; - /** Called with the SSE `id:` field value when non-empty; enables Last-Event-ID catch-up tracking. */ - onEventId?: (id: string) => void; -}; +import type { ConnectionKey, ConnectionSubscriber } from "./connectionTypes.js"; type ConnectionEntry = { source: EventSource; @@ -75,16 +59,25 @@ function notifySubscribers( } } -export function acquireEventConnection(key: ConnectionKey, subscriber: ConnectionSubscriber) { - const poolKey = getConnectionKey(key); +/** + * Shared pooling/wiring logic for both plain-address and contract-event SSE + * connections: acquires (or creates) the pooled `EventSource` for `poolKey`, + * wires up open/message/error handling and DevTools instrumentation, and + * registers `subscriber` against it. + */ +function acquireSseConnection( + poolKey: string, + buildUrl: () => string, + withCredentials: boolean | undefined, + devtoolsServerUrl: string, + devtoolsAddressLabel: string, + subscriber: ConnectionSubscriber, +) { let entry = pool.get(poolKey); if (!entry) { const newEntry: ConnectionEntry = { - source: new EventSource( - getEventSourceUrl(key), - key.withCredentials ? { withCredentials: true } : undefined, - ), + source: new EventSource(buildUrl(), withCredentials ? { withCredentials: true } : undefined), subscribers: new Set(), connected: false, }; @@ -138,9 +131,9 @@ export function acquireEventConnection(key: ConnectionKey, subscriber: Connectio withDevtools((mod) => { newEntry.devId = mod.registerConnection({ - serverUrl: key.serverUrl, - address: key.address, - url: getEventSourceUrl(key), + serverUrl: devtoolsServerUrl, + address: devtoolsAddressLabel, + url: buildUrl(), connected: newEntry.connected, error: null, }); @@ -170,6 +163,17 @@ export function acquireEventConnection(key: ConnectionKey, subscriber: Connectio }; } +export function acquireEventConnection(key: ConnectionKey, subscriber: ConnectionSubscriber) { + return acquireSseConnection( + getConnectionKey(key), + () => getEventSourceUrl(key), + key.withCredentials, + key.serverUrl, + key.address, + subscriber, + ); +} + // --- Contract event connection helpers ------------------------------------------------- // Build a unique key for contract subscriptions based on contractId, topics, and token. function getContractKey({ @@ -229,97 +233,14 @@ export function acquireContractEventConnection( }, subscriber: ConnectionSubscriber, ) { - const poolKey = getContractKey(key); - let entry = pool.get(poolKey); - - if (!entry) { - const newEntry: ConnectionEntry = { - source: new EventSource( - getContractEventSourceUrl(key), - key.withCredentials ? { withCredentials: true } : undefined, - ), - subscribers: new Set(), - connected: false, - }; - - newEntry.source.onopen = () => { - newEntry.connected = true; - notifySubscribers(newEntry, (cur) => cur.onOpen()); - withDevtools((mod) => { - if (newEntry.devId) mod.updateConnection(newEntry.devId, { connected: true, error: null }); - }); - }; - - newEntry.source.onmessage = (message) => { - try { - const data = JSON.parse(message.data); - if (data && typeof data === "object" && data.type === "auth_expired") { - newEntry.connected = false; - notifySubscribers(newEntry, (cur) => cur.onAuthExpired?.()); - withDevtools((mod) => { - if (newEntry.devId) - mod.updateConnection(newEntry.devId, { connected: false, error: "Token expired" }); - }); - return; - } - const event = data as NormalizedEvent; - const id = message.lastEventId; - notifySubscribers(newEntry, (cur) => { - if (id) cur.onEventId?.(id); - cur.onEvent(event); - }); - withDevtools((mod) => { - if (newEntry.devId) mod.updateConnection(newEntry.devId, { lastEvent: Date.now() }); - }); - } catch { - notifySubscribers(newEntry, (cur) => cur.onParseError()); - } - }; - - newEntry.source.onerror = () => { - newEntry.connected = false; - notifySubscribers(newEntry, (cur) => cur.onError()); - withDevtools((mod) => { - if (newEntry.devId) { - mod.updateConnection(newEntry.devId, { - connected: false, - error: "Connection lost — retrying...", - }); - } - }); - }; - - withDevtools((mod) => { - newEntry.devId = mod.registerConnection({ - serverUrl: key.serverUrl, - address: key.contractId, // reuse address field for devtools display - url: getContractEventSourceUrl(key), - connected: newEntry.connected, - error: null, - }); - }); - - pool.set(poolKey, newEntry); - entry = newEntry; - } - - entry.subscribers.add(subscriber); - - return { - get connected() { - return entry.connected; - }, - unsubscribe: () => { - entry.subscribers.delete(subscriber); - if (entry.subscribers.size === 0) { - entry.source.close(); - pool.delete(poolKey); - withDevtools((mod) => { - if (entry.devId) mod.unregisterConnection(entry.devId); - }); - } - }, - }; + return acquireSseConnection( + getContractKey(key), + () => getContractEventSourceUrl(key), + key.withCredentials, + key.serverUrl, + key.contractId, + subscriber, + ); } export function __getConnectionPoolSizeForTests() { diff --git a/packages/pulse-notify/src/connectionTypes.ts b/packages/pulse-notify/src/connectionTypes.ts new file mode 100644 index 00000000..ec48a7f9 --- /dev/null +++ b/packages/pulse-notify/src/connectionTypes.ts @@ -0,0 +1,28 @@ +// connectionTypes.ts +// Shared connection-key/subscriber shapes used by both the SSE pool +// (connectionPool.ts) and the WebSocket pool (wsTransport.ts). + +import type { NormalizedEvent } from "@orbital-stellar/pulse-core"; + +export type ConnectionKey = { + serverUrl: string; + address: string; + token?: string; + withCredentials?: boolean; +}; + +export type ConnectionSubscriber = { + onOpen: () => void; + onEvent: (event: NormalizedEvent) => void; + onParseError: () => void; + onError: () => void; + onAuthExpired?: () => void; + /** Called with the SSE `id:` field value when non-empty; enables Last-Event-ID catch-up tracking. */ + onEventId?: (id: string) => void; +}; + +/** wsTransport has no notion of `withCredentials` (WebSocket auth differs from EventSource). */ +export type WsConnectionKey = Omit; + +/** wsTransport does not support Last-Event-ID catch-up. */ +export type WsConnectionSubscriber = Omit; diff --git a/packages/pulse-notify/src/useContractState.ts b/packages/pulse-notify/src/useContractState.ts index be54c628..524f4a67 100644 --- a/packages/pulse-notify/src/useContractState.ts +++ b/packages/pulse-notify/src/useContractState.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import type { ContractEmittedEvent } from "@orbital-stellar/pulse-core"; import { acquireEventConnection } from "./connectionPool.js"; -export type ContractStateOptions = { +export type ContractStateOptions = { pollIntervalMs?: number; autoRefreshOn?: { serverUrl: string; @@ -56,7 +56,7 @@ export function useContractState( rpcUrl: string, contractId: string, key: string, - options?: ContractStateOptions, + options?: ContractStateOptions, ): ContractStateResult { const pollIntervalMs = options?.pollIntervalMs ?? 10_000; const autoRefreshOn = options?.autoRefreshOn; diff --git a/packages/pulse-notify/src/wsTransport.ts b/packages/pulse-notify/src/wsTransport.ts index 2e6c7243..18b4101a 100644 --- a/packages/pulse-notify/src/wsTransport.ts +++ b/packages/pulse-notify/src/wsTransport.ts @@ -1,41 +1,28 @@ import type { NormalizedEvent } from "@orbital-stellar/pulse-core"; - -type ConnectionKey = { - serverUrl: string; - address: string; - token?: string; -}; - -type ConnectionSubscriber = { - onOpen: () => void; - onEvent: (event: NormalizedEvent) => void; - onParseError: () => void; - onError: () => void; - onAuthExpired?: () => void; -}; +import type { WsConnectionKey, WsConnectionSubscriber } from "./connectionTypes.js"; type WsEntry = { ws: WebSocket; - subscribers: Set; + subscribers: Set; connected: boolean; }; const pool = new Map(); -function getKey({ serverUrl, address, token }: ConnectionKey): string { +function getKey({ serverUrl, address, token }: WsConnectionKey): string { return JSON.stringify([serverUrl, address, token ?? ""]); } -function getWsUrl({ serverUrl, address, token }: ConnectionKey): string { +function getWsUrl({ serverUrl, address, token }: WsConnectionKey): string { const base = serverUrl.replace(/^http/, "ws") + `/events/${address}`; return token ? `${base}?token=${encodeURIComponent(token)}` : base; } -function notify(entry: WsEntry, fn: (s: ConnectionSubscriber) => void) { +function notify(entry: WsEntry, fn: (s: WsConnectionSubscriber) => void) { for (const s of [...entry.subscribers]) fn(s); } -export function acquireWsConnection(key: ConnectionKey, subscriber: ConnectionSubscriber) { +export function acquireWsConnection(key: WsConnectionKey, subscriber: WsConnectionSubscriber) { const poolKey = getKey(key); let entry = pool.get(poolKey); diff --git a/packages/pulse-notify/test/useContractEvent.test.tsx b/packages/pulse-notify/test/useContractEvent.test.tsx index 582ffae3..deb8f0db 100644 --- a/packages/pulse-notify/test/useContractEvent.test.tsx +++ b/packages/pulse-notify/test/useContractEvent.test.tsx @@ -1,4 +1,3 @@ -import { StrictMode, useState } from "react"; import { render, act, cleanup } from "@testing-library/react"; import { afterEach, expect, test, describe } from "vitest"; import { diff --git a/packages/pulse-notify/test/useStellarEvent.test.tsx b/packages/pulse-notify/test/useStellarEvent.test.tsx index 6e2a4ddc..2d49cbd8 100644 --- a/packages/pulse-notify/test/useStellarEvent.test.tsx +++ b/packages/pulse-notify/test/useStellarEvent.test.tsx @@ -159,8 +159,6 @@ describe("useStellarEvent — onEvent callback", () => { return useStellarEvent(SERVER, ADDRESS, { filter, onEvent }); }); - const renderCountAfterMount = renderCount; - act(() => getSource().onopen?.()); // onopen triggers a connected state change — renderCount may increase here const renderCountAfterOpen = renderCount; diff --git a/packages/pulse-notify/test/useStellarPayment.test.tsx b/packages/pulse-notify/test/useStellarPayment.test.tsx index 4114fc61..f2e0cf61 100644 --- a/packages/pulse-notify/test/useStellarPayment.test.tsx +++ b/packages/pulse-notify/test/useStellarPayment.test.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { render, act } from "@testing-library/react"; import { afterEach, expect, test, describe } from "vitest"; import { useStellarPayment } from "../src/index.ts"; diff --git a/packages/pulse-webhooks/README.md b/packages/pulse-webhooks/README.md index 6cbf44c4..b1c76a1c 100644 --- a/packages/pulse-webhooks/README.md +++ b/packages/pulse-webhooks/README.md @@ -497,6 +497,37 @@ Remove all entries from the store. Get the total number of entries in the store. +## Durable retry queues + +By default, pending retries live in-process — a restart drops them. Pass a `RetryQueue` on `config.retryQueue` to persist them instead: + +```ts +import { WebhookDelivery, RedisRetryQueue } from "@orbital-stellar/pulse-webhooks"; +import Redis from "ioredis"; + +const redis = new Redis(process.env.REDIS_URL!); + +new WebhookDelivery(watcher, { + url: "https://your-app.com/hooks/stellar", + secret: process.env.WEBHOOK_SECRET!, + retryQueue: new RedisRetryQueue(redis), +}); +``` + +`RedisRetryQueue` expects a client exposing `zadd` / `zrangebyscore` / `zrevrange` / `zrem` / `zcard` (the `RedisLike` type) — `ioredis` matches this directly; other clients need a thin wrapper. + +`WebhookDelivery` polls the queue (`retryQueuePollIntervalMs`, default 1000ms) instead of using in-process `setTimeout` retries when `retryQueue` is configured. On process restart, any records left in the queue are picked back up. + +| Adapter | Backing store | Constructor | +|---|---|---| +| `MemoryRetryQueue` | In-process `Map` (default if you build one yourself; not persistent — same limitation as no queue at all, useful mainly for tests) | `new MemoryRetryQueue(options?)` | +| `RedisRetryQueue` | Redis sorted set, keyed by due time | `new RedisRetryQueue(client, options?)` | +| `SqsRetryQueue` | Amazon SQS (standard or FIFO) | `new SqsRetryQueue(client, options)` | + +All three implement the same `RetryQueue` interface (`enqueue`, `dequeue`, `ack`, `nack`, `evictNewest`, `size`), so custom adapters (e.g. Postgres) are a drop-in — see [`src/RetryQueue.ts`](./src/RetryQueue.ts) for the exact contract. + +Note: durable retry queues and the `DeadLetterStore` above solve different problems — the retry queue holds *pending* retries so they survive a restart; the DLQ holds *terminally failed* deliveries (retries exhausted) for inspection and replay. They're independent and can be used together. + ## Index Requirements for Adapter Authors If you persist the dead letter store to a database, create these indexes for query efficiency: @@ -555,7 +586,7 @@ Always pass `maxAgeMs` explicitly. A consumer that omits the option still receiv ## Current limitations -- **Retries live in-process.** Restarting the process loses pending retries. Persistent retry queues with pluggable adapters (Redis, Postgres, S3) ship in Phase 1 — see [`ROADMAP.md`](../../ROADMAP.md#wave-13--cursor-persistence-and-replay-primitives). +- **Retries live in-process unless a `retryQueue` is configured.** See [Durable retry queues](#durable-retry-queues) above for the Redis/SQS adapters that persist pending retries across restarts. - **Retries use a small built-in strategy set.** For specialized schedules, pass a custom `BackoffStrategy` through `config.backoff`. - **No signature versioning.** The header format is fixed at `x-orbital-signature` (HMAC-SHA256 hex) — there is no `v1=…` prefix. If the algorithm needs to change, a future `x-orbital-signature-v2` header will be introduced alongside `v1` for a deprecation window. diff --git a/packages/pulse-webhooks/bin/orbital b/packages/pulse-webhooks/bin/orbital old mode 100644 new mode 100755 index e591ae89..a2109224 --- a/packages/pulse-webhooks/bin/orbital +++ b/packages/pulse-webhooks/bin/orbital @@ -1,10 +1,11 @@ #!/usr/bin/env node -import { DeadLetterStore } from '../src/MemoryDeadLetterStore.js'; -import { listDLQ, dumpDLQ, replayDLQ } from '../src/cli.js'; +import { MemoryDeadLetterStore } from '../dist/MemoryDeadLetterStore.js'; +import { listDLQ, dumpDLQ, replayDLQ } from '../dist/cli.js'; +import { signWebhookPayload } from '../dist/signing.js'; function printUsage() { - console.error(`Usage: orbital dlq [options]\n\nCommands:\n list --url [--since ] [--limit ]\n dump\n replay \n`); + console.error(`Usage: orbital dlq [options]\n\nCommands:\n list --url [--since ] [--limit ]\n dump\n replay [--secret ]\n\nEnv:\n ORBITAL_WEBHOOK_SECRET Signing secret used to re-sign replayed deliveries\n (overridden by --secret).\n`); process.exit(1); } @@ -14,41 +15,81 @@ if (args.length < 2 || args[0] !== 'dlq') { } const command = args[1]; -const store = new DeadLetterStore(); - -switch (command) { - case 'list': { - const options: any = {}; - for (let i = 2; i < args.length; i++) { - const arg = args[i]; - if (arg === '--url' && i + 1 < args.length) { - options.url = args[++i]; - } else if (arg === '--since' && i + 1 < args.length) { - options.since = args[++i]; - } else if (arg === '--limit' && i + 1 < args.length) { - options.limit = parseInt(args[++i], 10); - } else { - console.error(`Unknown option ${arg}`); +const store = new MemoryDeadLetterStore(); + +async function main() { + switch (command) { + case 'list': { + const options = {}; + for (let i = 2; i < args.length; i++) { + const arg = args[i]; + if (arg === '--url' && i + 1 < args.length) { + options.url = args[++i]; + } else if (arg === '--since' && i + 1 < args.length) { + options.since = args[++i]; + } else if (arg === '--limit' && i + 1 < args.length) { + options.limit = parseInt(args[++i], 10); + } else { + console.error(`Unknown option ${arg}`); + printUsage(); + } + } + await listDLQ(store, options); + break; + } + case 'dump': { + await dumpDLQ(store); + break; + } + case 'replay': { + const id = args[2]; + if (!id) { + console.error('Missing dlq id'); printUsage(); + return; } + + let secret = process.env.ORBITAL_WEBHOOK_SECRET; + for (let i = 3; i < args.length; i++) { + if (args[i] === '--secret' && i + 1 < args.length) { + secret = args[++i]; + } + } + + store.setReplayHandler(async (entry) => { + if (!secret) { + throw new Error( + 'No signing secret configured — pass --secret or set ORBITAL_WEBHOOK_SECRET', + ); + } + + const payload = JSON.stringify(entry.event); + const timestamp = Date.now().toString(); + const signature = signWebhookPayload(payload, timestamp, secret); + + const res = await fetch(entry.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-orbital-signature': signature, + 'x-orbital-timestamp': timestamp, + 'x-orbital-attempt': String(entry.attempts + 1), + }, + body: payload, + }); + + if (!res.ok) { + throw new Error(`Replay delivery failed: HTTP ${res.status}`); + } + }); + + await replayDLQ(store, id); + break; } - listDLQ(store, options); - break; - } - case 'dump': { - dumpDLQ(store); - break; - } - case 'replay': { - const id = args[2]; - if (!id) { - console.error('Missing dlq id'); + default: + console.error(`Unknown subcommand ${command}`); printUsage(); - } - replayDLQ(store, id); - break; } - default: - console.error(`Unknown subcommand ${command}`); - printUsage(); } + +await main(); diff --git a/packages/pulse-webhooks/package.json b/packages/pulse-webhooks/package.json index 123f06f5..4c83912f 100644 --- a/packages/pulse-webhooks/package.json +++ b/packages/pulse-webhooks/package.json @@ -11,6 +11,7 @@ "types": "./dist/index.d.ts", "files": [ "dist", + "bin", "README.md", "LICENSE" ], @@ -35,8 +36,7 @@ }, "devDependencies": { "@types/node": "^26.0.1", - "tsx": "^4.22.4", - "vite": "^8.1.1", + "vite": "^7.3.3", "vitest": "^4.1.9" } } diff --git a/packages/pulse-webhooks/src/PostgresDeadLetterStore.ts b/packages/pulse-webhooks/src/PostgresDeadLetterStore.ts index 6e0d1d8a..c61c7e13 100644 --- a/packages/pulse-webhooks/src/PostgresDeadLetterStore.ts +++ b/packages/pulse-webhooks/src/PostgresDeadLetterStore.ts @@ -48,9 +48,6 @@ export type PostgresDeadLetterStoreApi = { delete(id: string): Promise; }; -/** @deprecated Use {@link PostgresDeadLetterStoreApi} instead. */ -export type DeadLetterStore = PostgresDeadLetterStoreApi; - type DeadLetterRow = { id: string | number; url: string; diff --git a/packages/pulse-webhooks/src/cli.ts b/packages/pulse-webhooks/src/cli.ts index ec4e8fa2..6e7471a3 100644 --- a/packages/pulse-webhooks/src/cli.ts +++ b/packages/pulse-webhooks/src/cli.ts @@ -31,18 +31,23 @@ export async function dumpDLQ(store: MemoryDeadLetterStore): Promise { } /** - * Replay a DLQ entry by id. For now this simply re‑adds the entry to the store - * (simulating a retry) and prints the entry. In a full implementation this would - * invoke the webhook delivery pipeline. + * Replay a DLQ entry by id: re-delivers it through the store's configured + * {@link ReplayHandler} (set via {@link MemoryDeadLetterStore.setReplayHandler}), + * which actually re-sends the webhook, and marks the entry `replayedAt` on success. */ -export function replayDLQ(store: MemoryDeadLetterStore, id: string): void { +export async function replayDLQ(store: MemoryDeadLetterStore, id: string): Promise { const entry = store.get(id); if (!entry) { console.error(`DLQ entry with id ${id} not found`); process.exitCode = 1; return; } - // Simulate replay by re‑adding the entry (could be replaced with real delivery). - store.add(entry.url, entry.event, entry.error, entry.attempts); - console.log(JSON.stringify(entry)); + + try { + await store.replay(id); + console.log(JSON.stringify(store.get(id))); + } catch (err) { + console.error(`Replay failed: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } } diff --git a/packages/pulse-webhooks/src/index.ts b/packages/pulse-webhooks/src/index.ts index 1551b280..9362c81b 100644 --- a/packages/pulse-webhooks/src/index.ts +++ b/packages/pulse-webhooks/src/index.ts @@ -5,7 +5,7 @@ import type { WatcherNotification, } from "@orbital-stellar/pulse-core"; -import { createHmac, timingSafeEqual, randomUUID } from "crypto"; +import { timingSafeEqual, randomUUID } from "crypto"; import { lookup } from "dns/promises"; import { BlockList, isIP } from "net"; @@ -14,6 +14,7 @@ import { MemoryDeadLetterStore } from "./MemoryDeadLetterStore.js"; import { exponentialJittered } from "./backoff.js"; import type { BackoffStrategy } from "./backoff.js"; import type { RetryQueue, RetryRecord } from "./RetryQueue.js"; +import { signWebhookPayload } from "./signing.js"; import type { Tracer, UrlEntry, VerifyWebhookOptions, WebhookConfig } from "./types.js"; import { DEFAULT_MAX_AGE_MS, DEFAULT_CLOCK_SKEW_MS } from "./types.js"; import { NOOP_WEBHOOK_METRICS } from "./metrics.js"; @@ -34,6 +35,7 @@ BLOCKED_WEBHOOK_ADDRESSES.addSubnet("::ffff:c0a8:0", 112, "ipv6"); BLOCKED_WEBHOOK_ADDRESSES.addSubnet("::ffff:a9fe:0", 112, "ipv6"); const BLOCKED_ADDRESS_ERROR = "Webhook URL points to a blocked private address"; +export { signWebhookPayload } from "./signing.js"; export { configureDeadLetterStore } from "./DeadLetterStore.js"; export type { DeadLetterEntry, @@ -717,9 +719,7 @@ export class WebhookDelivery { } private sign(payload: string, timestamp: string): string { - const signedPayload = `${timestamp}.${payload}`; - - return createHmac("sha256", this.config.secret).update(signedPayload).digest("hex"); + return signWebhookPayload(payload, timestamp, this.config.secret); } } @@ -795,7 +795,7 @@ export function verifyWebhookRaw( if (timestampMs > nowMs + clockSkewMs) return false; if (timestampMs < nowMs - maxAgeMs - clockSkewMs) return false; - const expected = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex"); + const expected = signWebhookPayload(payload, timestamp, secret); const expectedBuffer = Buffer.from(expected, "hex"); const signatureBuffer = Buffer.from(signature, "hex"); diff --git a/packages/pulse-webhooks/src/signing.ts b/packages/pulse-webhooks/src/signing.ts new file mode 100644 index 00000000..e84a2991 --- /dev/null +++ b/packages/pulse-webhooks/src/signing.ts @@ -0,0 +1,12 @@ +import { createHmac } from "crypto"; + +/** + * Computes the HMAC-SHA256 signature `WebhookDelivery` sends as the + * `x-orbital-signature` header, and that {@link verifyWebhookRaw} expects. + * Exposed so callers that need to send or replay a webhook outside of + * `WebhookDelivery` itself (the `orbital dlq replay` CLI, sample/demo + * receivers) don't have to reimplement the signing scheme. + */ +export function signWebhookPayload(payload: string, timestamp: string, secret: string): string { + return createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex"); +} diff --git a/packages/pulse-webhooks/test/cli.test.ts b/packages/pulse-webhooks/test/cli.test.ts new file mode 100644 index 00000000..b9f7b3e2 --- /dev/null +++ b/packages/pulse-webhooks/test/cli.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { listDLQ, dumpDLQ, replayDLQ } from "../src/cli.js"; +import { MemoryDeadLetterStore } from "../src/MemoryDeadLetterStore.js"; + +const event = { + type: "payment.received", + to: "GDEST", + from: "GSRC", + amount: "10", + asset: "XLM", + timestamp: "2026-04-26T12:00:00.000Z", + raw: { id: "evt_1" }, +} as const; + +describe("cli", () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + process.exitCode = undefined; + }); + + describe("listDLQ", () => { + it("prints matching entries as line-delimited JSON", async () => { + const store = new MemoryDeadLetterStore(); + await store.record({ url: "https://a.example.com", event, error: "boom", attempts: 1 }); + await store.record({ url: "https://b.example.com", event, error: "boom", attempts: 1 }); + + await listDLQ(store, { url: "https://a.example.com" }); + + expect(logSpy).toHaveBeenCalledTimes(1); + const printed = JSON.parse(logSpy.mock.calls[0]![0] as string); + expect(printed.url).toBe("https://a.example.com"); + }); + }); + + describe("dumpDLQ", () => { + it("prints every entry regardless of filter", async () => { + const store = new MemoryDeadLetterStore(); + await store.record({ url: "https://a.example.com", event, error: "boom", attempts: 1 }); + await store.record({ url: "https://b.example.com", event, error: "boom", attempts: 1 }); + + await dumpDLQ(store); + + expect(logSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe("replayDLQ", () => { + it("re-delivers the entry over HTTP via the configured replay handler and marks it replayed", async () => { + const store = new MemoryDeadLetterStore(); + const id = await store.record({ + url: "https://example.com/webhook", + event, + error: "boom", + attempts: 2, + }); + + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + store.setReplayHandler(async (entry) => { + const res = await fetch(entry.url, { + method: "POST", + headers: { "x-orbital-signature": "sig", "x-orbital-timestamp": "123" }, + body: JSON.stringify(entry.event), + }); + if (!(res as Response).ok) throw new Error("delivery failed"); + }); + + await replayDLQ(store, id); + + expect(fetchMock).toHaveBeenCalledWith( + "https://example.com/webhook", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "x-orbital-signature": "sig" }), + }), + ); + expect(store.get(id)?.replayedAt).toBeTypeOf("number"); + expect(process.exitCode).toBeUndefined(); + + vi.unstubAllGlobals(); + }); + + it("reports an error and sets a nonzero exit code when the id is unknown", async () => { + const store = new MemoryDeadLetterStore(); + + await replayDLQ(store, "missing-id"); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("missing-id")); + expect(process.exitCode).toBe(1); + }); + + it("reports an error and sets a nonzero exit code when no replay handler is configured", async () => { + const store = new MemoryDeadLetterStore(); + const id = await store.record({ + url: "https://example.com/webhook", + event, + error: "boom", + attempts: 1, + }); + + await replayDLQ(store, id); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Replay failed")); + expect(process.exitCode).toBe(1); + }); + }); +}); diff --git a/packages/pulse-webhooks/test/signing.test.ts b/packages/pulse-webhooks/test/signing.test.ts new file mode 100644 index 00000000..07f4d31d --- /dev/null +++ b/packages/pulse-webhooks/test/signing.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { signWebhookPayload } from "../src/signing.js"; +import { verifyWebhookRaw } from "../src/index.js"; + +describe("signWebhookPayload", () => { + it("round-trips against verifyWebhookRaw", () => { + const payload = JSON.stringify({ type: "payment.received", amount: "10" }); + const secret = "whsec_test_signing"; + const timestamp = Date.now().toString(); + + const signature = signWebhookPayload(payload, timestamp, secret); + + expect(verifyWebhookRaw(payload, signature, secret, timestamp)).toBe(true); + }); + + it("produces a different signature for a different secret", () => { + const payload = '{"type":"ping"}'; + const timestamp = "1717000000000"; + + const signatureA = signWebhookPayload(payload, timestamp, "secret-a"); + const signatureB = signWebhookPayload(payload, timestamp, "secret-b"); + + expect(signatureA).not.toBe(signatureB); + expect( + verifyWebhookRaw(payload, signatureA, "secret-b", timestamp, { nowMs: 1717000000000 }), + ).toBe(false); + }); + + it("pins the HMAC-SHA256 over `${timestamp}.${payload}` format (ADR-003)", () => { + expect( + signWebhookPayload('{"type":"ping"}', "1717000000000", "whsec_test_regression_fixture"), + ).toBe("9c79798b9fb0e2bf9da3c5f0bb8d36977d013f8ed0fe9837cc8a9d3c1017c6c0"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49f69a5b..d3628634 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: '@orbital-stellar/pulse-core': specifier: workspace:* version: link:../../packages/pulse-core + '@orbital-stellar/pulse-webhooks': + specifier: workspace:* + version: link:../../packages/pulse-webhooks framer-motion: specifier: ^12.42.1 version: 12.42.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -117,9 +120,6 @@ importers: ajv: specifier: ^8.17.1 version: 8.20.0 - tsx: - specifier: ^4.22.4 - version: 4.22.4 vitest: specifier: ^4.1.9 version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.1(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -194,9 +194,6 @@ importers: size-limit: specifier: ^12.1.0 version: 12.1.0(jiti@2.7.0) - tsx: - specifier: ^4.22.4 - version: 4.22.4 vitest: specifier: ^4.1.9 version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.1(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -213,9 +210,6 @@ importers: '@types/node': specifier: ^26.0.1 version: 26.0.1 - tsx: - specifier: ^4.22.4 - version: 4.22.4 vite: specifier: 7.3.3 version: 7.3.3(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)