Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,10 @@ TRUSTFLOW_CONTRACT_ID=
PROFILE_API_BASE_URL=
JWT_SECRET=change-me-in-production
PORT=3001

# Client-side (exposed to the browser bundle). Used by useContractEvents and the
# Soroban RPC client in shared/soroban-rpc.ts.
NEXT_PUBLIC_STELLAR_NETWORK=TESTNET
NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
NEXT_PUBLIC_ESCROW_CONTRACT_ID=
NEXT_PUBLIC_DISPUTE_CONTRACT_ID=
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ Key variables:
```env
NEXT_PUBLIC_STELLAR_NETWORK=TESTNET
NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
NEXT_PUBLIC_ESCROW_CONTRACT_ID=your-contract-id
NEXT_PUBLIC_ESCROW_CONTRACT_ID=your-escrow-contract-id
NEXT_PUBLIC_DISPUTE_CONTRACT_ID=your-dispute-contract-id
```

### Running
Expand Down
81 changes: 81 additions & 0 deletions components/molecules/dispute-event-feed/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import moment from 'moment'
import { useContractEvents } from '../../../hooks'
import { DISPUTE_CONTRACT_ID, ESCROW_CONTRACT_ID } from '../../../shared/contracts'
import type { ContractEvent } from '../../../shared/contract-events/types'

function eventTitle(event: ContractEvent): string {
const topLevel = event.topic[0]
return typeof topLevel === 'string' ? topLevel : `${event.source} event`
}

function truncate(value: string, size = 6): string {
return value.length > size * 2 ? `${value.slice(0, size)}…${value.slice(-size)}` : value
}

function StatusDot({ isLive, hasError }: { isLive: boolean; hasError: boolean }) {
const color = hasError ? 'bg-red-500' : isLive ? 'bg-green-500' : 'bg-gray-400'
return <span className={`inline-block h-2 w-2 rounded-full ${color}`} aria-hidden />
}

/**
* Live feed of escrow/dispute contract events, backed by `useContractEvents`.
* Renders nothing if neither contract ID is configured (see .env.example).
*/
export function DisputeEventFeed() {
const { events, status, error, isLive, hasNewEvents, markSeen, resync } = useContractEvents({
contracts: { escrow: ESCROW_CONTRACT_ID, dispute: DISPUTE_CONTRACT_ID },
})

if (!ESCROW_CONTRACT_ID && !DISPUTE_CONTRACT_ID) {
return null
}

return (
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl overflow-hidden">
<div className="flex items-center justify-between gap-3 p-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center gap-2">
<StatusDot isLive={isLive} hasError={status === 'error'} />
<h3 className="font-semibold text-gray-900 dark:text-white">Live activity</h3>
{hasNewEvents && (
<button
onClick={markSeen}
className="text-xs font-medium px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300"
>
New
</button>
)}
</div>
<button
onClick={resync}
className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"
>
Refresh
</button>
</div>

{error && (
<p className="px-4 py-2 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/40">{error}</p>
)}

{events.length === 0 ? (
<p className="p-6 text-sm text-gray-500 dark:text-gray-400">
{status === 'connecting' ? 'Connecting to the network…' : 'No recent escrow or dispute activity yet.'}
</p>
) : (
<ul className="divide-y divide-gray-200 dark:divide-gray-800 max-h-96 overflow-y-auto">
{[...events].reverse().map(event => (
<li key={event.id} className="p-4 flex items-start justify-between gap-3">
<div>
<p className="font-medium text-gray-900 dark:text-white capitalize">{eventTitle(event)}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="uppercase">{event.source}</span> · ledger {event.ledger} · tx {truncate(event.txHash)}
</p>
</div>
<span className="text-xs text-gray-400 whitespace-nowrap">{moment(event.ledgerClosedAt).fromNow()}</span>
</li>
))}
</ul>
)}
</div>
)
}
1 change: 1 addition & 0 deletions components/molecules/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './deposits'
export * from './usd-converter'
export * from './file-upload'
export * from './dashboard-sidebar'
export * from './dispute-event-feed'
95 changes: 95 additions & 0 deletions docs/contract-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Contract event subscription layer

Implements `useContractEvents` (issue #61): a typed hook that streams
escrow/dispute Soroban contract events, dedupes across reconnects, and
hydrates the UI cache without full refetches.

## New dependency

- **`@stellar/stellar-sdk@^13.3.0`** — added to call Soroban RPC's `getEvents`.
Pinned to v13 rather than the current latest (v16) because v14+ requires
Node >=20, which would break the `node-version: [18, 20]` matrix in
`.github/workflows/ci.yml`. v13.3.0 supports Node >=18 and has the same
`rpc.Server.getEvents()` / `scValToNative` API used here. The previously
installed `soroban-client@1.0.0-beta.2` was left untouched — it was never
imported anywhere in the app, so nothing depends on removing it, but it's
worth dropping in a follow-up cleanup PR since it's superseded upstream by
`@stellar/stellar-sdk`.

## Architecture

Soroban RPC has no server-push/subscribe primitive — "streaming" means
polling `getEvents` with cursor-based pagination. Three pieces:

- **`shared/soroban-rpc.ts`** — lazily-created singleton `rpc.Server`, so
importing it has no side effects at module load (safe for both client and
any future server usage).
- **`shared/contract-events/store.ts`** — a module-level cache + pub/sub,
keyed by a channel key derived from the contract IDs/topics/limits a
caller passes in. This is the actual "UI cache hydration" mechanism:
- Multiple components calling `useContractEvents` with the same
`contracts`/`topics` share **one poller and one accumulated event
list**, subscribed to via React 18's `useSyncExternalStore`. A second
mount hydrates instantly from the shared cache instead of re-fetching.
- **Dedup**: every event carries an RPC-assigned `id` (unique per
ledger/tx/event index). Incoming events are merged into a `Set`/array
keyed by `id`, so resuming from the last known cursor after a dropped
connection never re-adds an event already in the cache.
- **Reconnect/resume**: the channel remembers its cursor. On a retention
error (asking for a cursor/ledger the RPC node has already pruned), it
falls back to a fresh `startLedger` (latest ledger minus a configurable
lookback) rather than getting stuck.
- A channel with zero subscribers keeps its cache for 60s before eviction,
so a quick remount (route change, React StrictMode) doesn't restart
polling from scratch either.
- Event storage is capped at 500 entries per channel (oldest dropped
first) to bound memory for long-lived tabs.
- **`hooks/useContractEvents.ts`** — the public hook. Thin wrapper around
the store via `useSyncExternalStore`; adds per-consumer "have I seen the
latest events" state (`hasNewEvents` / `markSeen`), which is intentionally
*not* shared across subscribers since "seen" is a per-view concern.

### Why no contract-specific event types (e.g. `DisputeOpenedEvent`)

The escrow/dispute contracts' actual event schemas live in the Soroban
contract source, which isn't in this repo. Rather than guess field shapes,
`ContractEvent.data`/`.topic` are decoded generically via `scValToNative`
and typed as `unknown`. Consumers that know the real shape (once the
contract IDL is available) should narrow it themselves, optionally with
`zod` (already a dependency, previously unused in this repo).

### Env vars

Reconciled three inconsistent naming schemes that existed across
`.env.example`, `shared/contracts.ts`, and `README.md` before this change.
Standardized on the `README.md` convention:

```
NEXT_PUBLIC_STELLAR_NETWORK=TESTNET
NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
NEXT_PUBLIC_ESCROW_CONTRACT_ID=
NEXT_PUBLIC_DISPUTE_CONTRACT_ID=
```

The older `NEXT_PUBLIC_CONTRACT_ID`/`NEXT_PUBLIC_RPC_URL` exports in
`shared/contracts.ts` are kept as fallbacks, not removed, in case an
existing deployment only sets those.

### No test infrastructure

This repo has no test runner configured (no Jest/Vitest, no `*.test.ts`
files anywhere, CI only runs lint/typecheck/build). Introducing a test
runner is a bigger, separate decision, so this PR ships without unit tests
for the new store/hook, matching current repo norms — flagged here rather
than silently skipped. `docs/contract-events.md` (this file) exists to
compensate for that a little: read the "Architecture" section above before
changing `shared/contract-events/store.ts`, since the dedup/resume
invariants aren't otherwise covered by any test.

## UI

Wired into `pages/dashboard/disputes.tsx` via a new
`components/molecules/dispute-event-feed` component: a live activity feed
showing escrow/dispute events, a status dot (idle/connecting/live/error),
and a manual refresh (`resync`). Renders `null` when neither contract ID is
configured, so it's inert until a real contract is deployed.
1 change: 1 addition & 0 deletions hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export * from "./useGigsExplorer";
export * from "./useWallet";
export * from "./useIsMounted";
export * from "./useSubscription";
export * from "./useContractEvents";
export * from "./useUSDCPrice";
export * from "./useUserProfile";
112 changes: 112 additions & 0 deletions hooks/useContractEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import {
buildChannelKey,
getServerSnapshot,
getSnapshot,
resync as resyncChannel,
subscribe,
type ChannelContractConfig,
type ChannelOptions,
} from '../shared/contract-events/store'
import type { ContractEvent, ContractEventSource, ContractEventsSnapshot, ContractEventsStatus } from '../shared/contract-events/types'

export type { ContractEvent, ContractEventSource, ContractEventsStatus }

export interface UseContractEventsOptions {
/** Contract IDs to stream events from, keyed by which TrustFlow contract they belong to. Falsy values are skipped. */
contracts: Partial<Record<ContractEventSource, string | null | undefined>>
/** Raw base64-XDR topic filter segments, forwarded to Soroban RPC's getEvents as-is. Omit to receive all topics. */
topics?: string[][]
/** How often to poll Soroban RPC for new events, in ms. Default 6000. */
pollIntervalMs?: number
/** Max events per RPC page. Default 50. */
limit?: number
/** Ledgers to look back on the very first poll, before a cursor exists. Default 100 (~8 min at 5s/ledger). */
initialLookbackLedgers?: number
/** Set to false to pause polling without tearing down the accumulated cache. Default true. */
enabled?: boolean
}

export interface UseContractEventsResult {
/** Deduplicated events accumulated since the channel was created, oldest first. */
events: ContractEvent[]
status: ContractEventsStatus
error: string | null
/** True once at least one successful poll has completed. */
isLive: boolean
/** True if events have arrived since the last `markSeen()` call. */
hasNewEvents: boolean
/** Marks all currently accumulated events as seen (e.g. when the user opens the feed). */
markSeen: () => void
/** Drops the accumulated cache and cursor, then reconnects from a fresh ledger lookback. */
resync: () => void
}

const DEFAULT_POLL_INTERVAL_MS = 6_000
const DEFAULT_LIMIT = 50
const DEFAULT_LOOKBACK_LEDGERS = 100

const IDLE_SNAPSHOT: ContractEventsSnapshot = { status: 'idle', events: [], error: null, cursor: null }

/**
* Streams escrow/dispute contract events from Soroban RPC.
*
* Soroban RPC has no push/subscribe primitive, so this polls `getEvents` with
* cursor-based pagination under the hood. Multiple components calling this
* hook with the same `contracts`/`topics` share a single poller and a single
* accumulated event cache (see shared/contract-events/store.ts), so mounting
* it in several places hydrates instantly from cache instead of re-fetching.
* Events are deduped by their RPC-assigned id, so a dropped connection that
* resumes from the last cursor never produces duplicate entries.
*/
export function useContractEvents(options: UseContractEventsOptions): UseContractEventsResult {
const {
contracts,
topics,
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
limit = DEFAULT_LIMIT,
initialLookbackLedgers = DEFAULT_LOOKBACK_LEDGERS,
enabled = true,
} = options

const escrowContractId = contracts.escrow
const disputeContractId = contracts.dispute

const contractsList = useMemo<ChannelContractConfig[]>(() => {
const list: ChannelContractConfig[] = []
if (escrowContractId) list.push({ source: 'escrow', contractId: escrowContractId })
if (disputeContractId) list.push({ source: 'dispute', contractId: disputeContractId })
return list
}, [escrowContractId, disputeContractId])

const channelOptions = useMemo<ChannelOptions>(
() => ({ contracts: contractsList, topics, pollIntervalMs, limit, initialLookbackLedgers }),
[contractsList, topics, pollIntervalMs, limit, initialLookbackLedgers]
)

const channelKey = useMemo(() => buildChannelKey(channelOptions), [channelOptions])
const isActive = enabled && contractsList.length > 0

const subscribeFn = useCallback(
(listener: () => void) => (isActive ? subscribe(channelKey, channelOptions, listener) : () => {}),
[isActive, channelKey, channelOptions]
)
const getSnapshotFn = useCallback(() => (isActive ? getSnapshot(channelKey) : IDLE_SNAPSHOT), [isActive, channelKey])

const snapshot = useSyncExternalStore(subscribeFn, getSnapshotFn, getServerSnapshot)

const [seenCount, setSeenCount] = useState(0)
const markSeen = useCallback(() => setSeenCount(snapshot.events.length), [snapshot.events.length])

const resync = useCallback(() => resyncChannel(channelKey), [channelKey])

return {
events: snapshot.events,
status: snapshot.status,
error: snapshot.error,
isLive: snapshot.status === 'live',
hasNewEvents: snapshot.events.length > seenCount,
markSeen,
resync,
}
}
5 changes: 3 additions & 2 deletions hooks/useSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import * as React from 'react'
/**
* Placeholder subscription hook.
*
* When the Soroban RPC integration is wired up, replace the body of this hook
* with real event polling against the network configured in shared/contracts.ts.
* @deprecated Superseded by `useContractEvents`, which implements real Soroban
* RPC event polling with dedup and cache hydration. Kept for back-compat with
* any existing callers; prefer `useContractEvents` for new code.
*/
export function useSubscription(
_contractId: string,
Expand Down
Loading
Loading