diff --git a/.env.example b/.env.example
index 3c517aa..2873f27 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
diff --git a/README.md b/README.md
index e2d5579..d213dbf 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/components/molecules/dispute-event-feed/index.tsx b/components/molecules/dispute-event-feed/index.tsx
new file mode 100644
index 0000000..9249f63
--- /dev/null
+++ b/components/molecules/dispute-event-feed/index.tsx
@@ -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
+}
+
+/**
+ * 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 (
+
+
+
+
+
Live activity
+ {hasNewEvents && (
+
+ )}
+
+
+
+
+ {error && (
+
{error}
+ )}
+
+ {events.length === 0 ? (
+
+ {status === 'connecting' ? 'Connecting to the network…' : 'No recent escrow or dispute activity yet.'}
+
+ ) : (
+
+ {[...events].reverse().map(event => (
+ -
+
+
{eventTitle(event)}
+
+ {event.source} · ledger {event.ledger} · tx {truncate(event.txHash)}
+
+
+ {moment(event.ledgerClosedAt).fromNow()}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/components/molecules/index.tsx b/components/molecules/index.tsx
index 90305eb..1f751b1 100644
--- a/components/molecules/index.tsx
+++ b/components/molecules/index.tsx
@@ -5,3 +5,4 @@ export * from './deposits'
export * from './usd-converter'
export * from './file-upload'
export * from './dashboard-sidebar'
+export * from './dispute-event-feed'
diff --git a/docs/contract-events.md b/docs/contract-events.md
new file mode 100644
index 0000000..6ae147e
--- /dev/null
+++ b/docs/contract-events.md
@@ -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.
diff --git a/hooks/index.ts b/hooks/index.ts
index a6557b4..cca8547 100644
--- a/hooks/index.ts
+++ b/hooks/index.ts
@@ -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";
diff --git a/hooks/useContractEvents.ts b/hooks/useContractEvents.ts
new file mode 100644
index 0000000..063c12a
--- /dev/null
+++ b/hooks/useContractEvents.ts
@@ -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>
+ /** 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(() => {
+ 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(
+ () => ({ 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,
+ }
+}
diff --git a/hooks/useSubscription.ts b/hooks/useSubscription.ts
index bd81b46..87f7710 100644
--- a/hooks/useSubscription.ts
+++ b/hooks/useSubscription.ts
@@ -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,
diff --git a/package-lock.json b/package-lock.json
index d73586a..1ef433c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dialog": "1.0.2",
"@stellar/freighter-api": "^1.5.1",
+ "@stellar/stellar-sdk": "^13.3.0",
"@tanstack/react-virtual": "^3.14.7",
"axios": "^0.27.2",
"bigint-conversion": "^2.4.1",
@@ -1607,6 +1608,73 @@
"resolved": "https://registry.npmjs.org/@stellar/freighter-api/-/freighter-api-1.6.0.tgz",
"integrity": "sha512-Z6CRY+3+whzMspda6PiHEc4Rs9tdQkHLin9FopdnHhij/FEEAK+IiuF8Ki2ROcQweXdazb8N237aSim10s+Zgw=="
},
+ "node_modules/@stellar/js-xdr": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz",
+ "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@stellar/stellar-base": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-13.1.0.tgz",
+ "integrity": "sha512-90EArG+eCCEzDGj3OJNoCtwpWDwxjv+rs/RNPhvg4bulpjN/CSRj+Ys/SalRcfM4/WRC5/qAfjzmJBAuquWhkA==",
+ "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@stellar/js-xdr": "^3.1.2",
+ "base32.js": "^0.1.0",
+ "bignumber.js": "^9.1.2",
+ "buffer": "^6.0.3",
+ "sha.js": "^2.3.6",
+ "tweetnacl": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "sodium-native": "^4.3.3"
+ }
+ },
+ "node_modules/@stellar/stellar-sdk": {
+ "version": "13.3.0",
+ "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-13.3.0.tgz",
+ "integrity": "sha512-8+GHcZLp+mdin8gSjcgfb/Lb6sSMYRX6Nf/0LcSJxvjLQR0XHpjGzOiRbYb2jSXo51EnA6kAV5j+4Pzh5OUKUg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@stellar/stellar-base": "^13.1.0",
+ "axios": "^1.8.4",
+ "bignumber.js": "^9.3.0",
+ "eventsource": "^2.0.2",
+ "feaxios": "^0.0.23",
+ "randombytes": "^2.1.0",
+ "toml": "^3.0.0",
+ "urijs": "^1.19.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@stellar/stellar-sdk/node_modules/axios": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
+ "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/@stellar/stellar-sdk/node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.43",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz",
@@ -2022,6 +2090,18 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2338,6 +2418,50 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
+ "node_modules/bare-addon-resolve": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.1.tgz",
+ "integrity": "sha512-F/SD2du8keuYSb4xipnGz5j2E6yhNdHA8ZVxtHae6h2uOrpBIjjbhXvjzKZbr5XUOzqBzh/i8GVFycj2DlFQIA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-module-resolve": "^1.10.0",
+ "bare-semver": "^1.0.0"
+ },
+ "peerDependencies": {
+ "bare-url": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-url": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-module-resolve": {
+ "version": "1.12.4",
+ "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.4.tgz",
+ "integrity": "sha512-xcfgg2u7HqgJiBmah71O9vvdFAgHCvkqC/WSC2O7Bbgosoc1eC/BWe/6IDJ4OsfKlkxuvC/TDWXC+oH5yeW8mA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-semver": "^1.0.0"
+ },
+ "peerDependencies": {
+ "bare-url": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-url": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-semver": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz",
+ "integrity": "sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==",
+ "license": "Apache-2.0",
+ "optional": true
+ },
"node_modules/base32.js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz",
@@ -2396,9 +2520,10 @@
}
},
"node_modules/bignumber.js": {
- "version": "9.1.2",
- "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz",
- "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==",
+ "version": "9.3.1",
+ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
+ "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
+ "license": "MIT",
"engines": {
"node": "*"
}
@@ -2547,6 +2672,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2733,7 +2871,6 @@
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
- "dev": true,
"dependencies": {
"ms": "2.1.2"
},
@@ -2883,6 +3020,20 @@
"node": ">=6.0.0"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.354",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.354.tgz",
@@ -2962,25 +3113,46 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/es-set-tostringtag": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz",
- "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==",
- "dev": true,
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
"dependencies": {
- "get-intrinsic": "^1.1.3",
- "has": "^1.0.3",
- "has-tostringtag": "^1.0.0"
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
@@ -3484,6 +3656,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/eventsource": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
+ "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/execa": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz",
@@ -3563,6 +3744,15 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/feaxios": {
+ "version": "0.0.23",
+ "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz",
+ "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==",
+ "license": "MIT",
+ "dependencies": {
+ "is-retry-allowed": "^3.0.0"
+ }
+ },
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -3624,15 +3814,16 @@
"dev": true
},
"node_modules/follow-redirects": {
- "version": "1.15.2",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
- "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
+ "license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -3652,13 +3843,16 @@
}
},
"node_modules/form-data": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
- "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
- "mime-types": "^2.1.12"
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -3703,7 +3897,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3737,15 +3930,24 @@
}
},
"node_modules/get-intrinsic": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz",
- "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==",
- "dev": true,
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
"dependencies": {
- "function-bind": "^1.1.1",
- "has": "^1.0.3",
- "has-proto": "^1.0.1",
- "has-symbols": "^1.0.3"
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3759,6 +3961,19 @@
"node": ">=6"
}
},
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -3887,12 +4102,12 @@
}
},
"node_modules/gopd": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
- "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
- "dev": true,
- "dependencies": {
- "get-intrinsic": "^1.1.3"
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3964,10 +4179,10 @@
}
},
"node_modules/has-symbols": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
- "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
- "dev": true,
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -3976,12 +4191,12 @@
}
},
"node_modules/has-tostringtag": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
- "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
- "dev": true,
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
"dependencies": {
- "has-symbols": "^1.0.2"
+ "has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
@@ -3991,10 +4206,9 @@
}
},
"node_modules/hasown": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
- "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
- "dev": true,
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -4003,6 +4217,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/human-signals": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz",
@@ -4344,6 +4571,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-retry-allowed": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz",
+ "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-shared-array-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz",
@@ -4615,6 +4854,15 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -4707,8 +4955,7 @@
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
- "dev": true
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/mz": {
"version": "2.7.0",
@@ -4928,17 +5175,6 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
- "node_modules/node-gyp-build": {
- "version": "4.6.1",
- "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.1.tgz",
- "integrity": "sha512-24vnklJmyRS8ViBNI8KbtK/r/DmXQMRiOMXTNz2nrTnAYUwjmEEbnnpB/+kt+yWRv73bPsSPRFddrcIbAxSiMQ==",
- "optional": true,
- "bin": {
- "node-gyp-build": "bin.js",
- "node-gyp-build-optional": "optional.js",
- "node-gyp-build-test": "build-test.js"
- }
- },
"node_modules/node-releases": {
"version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
@@ -5516,6 +5752,15 @@
}
]
},
+ "node_modules/randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
"node_modules/react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
@@ -5673,6 +5918,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/require-addon": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz",
+ "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-addon-resolve": "^1.3.0"
+ },
+ "engines": {
+ "bare": ">=1.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -6045,13 +6303,13 @@
}
},
"node_modules/sodium-native": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.0.4.tgz",
- "integrity": "sha512-faqOKw4WQKK7r/ybn6Lqo1F9+L5T6NlBJJYvpxbZPetpWylUVqz449mvlwIBKBqxEHbWakWuOlUt8J3Qpc4sWw==",
- "hasInstallScript": true,
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz",
+ "integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==",
+ "license": "MIT",
"optional": true,
"dependencies": {
- "node-gyp-build": "^4.6.0"
+ "require-addon": "^1.1.0"
}
},
"node_modules/soroban-client": {
@@ -6452,6 +6710,12 @@
"node": ">=8.0"
}
},
+ "node_modules/toml": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
+ "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==",
+ "license": "MIT"
+ },
"node_modules/ts-api-utils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.1.tgz",
diff --git a/package.json b/package.json
index 6abcfa2..bff0ca6 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dialog": "1.0.2",
"@stellar/freighter-api": "^1.5.1",
+ "@stellar/stellar-sdk": "^13.3.0",
"@tanstack/react-virtual": "^3.14.7",
"axios": "^0.27.2",
"bigint-conversion": "^2.4.1",
diff --git a/pages/dashboard/disputes.tsx b/pages/dashboard/disputes.tsx
index c8c6b3a..eaab3de 100644
--- a/pages/dashboard/disputes.tsx
+++ b/pages/dashboard/disputes.tsx
@@ -2,7 +2,7 @@ import type { NextPage } from 'next'
import Head from 'next/head'
import Link from 'next/link'
import { Navbar } from '../../components/organisms'
-import { DashboardSidebar } from '../../components/molecules'
+import { DashboardSidebar, DisputeEventFeed } from '../../components/molecules'
import { useState } from 'react'
interface NavItem {
@@ -51,6 +51,10 @@ const Disputes: NextPage = () => {
Track and manage active dispute resolutions
+
+
+
+
⚖️
No active disputes
diff --git a/shared/contract-events/store.ts b/shared/contract-events/store.ts
new file mode 100644
index 0000000..9d1e29e
--- /dev/null
+++ b/shared/contract-events/store.ts
@@ -0,0 +1,232 @@
+import { rpc, scValToNative } from '@stellar/stellar-sdk'
+import { getSorobanServer } from '../soroban-rpc'
+import type { ContractEvent, ContractEventSource, ContractEventsSnapshot, ContractEventsStatus } from './types'
+
+/**
+ * Module-level event cache/pub-sub, keyed by channel (contract set + topic
+ * filter + polling config). This is what lets `useContractEvents` "hydrate
+ * the UI cache without full refetches": every mount for the same channel
+ * shares one poller and one accumulated event list via useSyncExternalStore,
+ * instead of each component instance re-fetching from scratch.
+ *
+ * Soroban RPC has no server push/subscribe primitive, so "streaming" here
+ * means polling `getEvents` with cursor-based pagination. Events are
+ * deduped by their RPC-assigned `id` (unique per ledger+tx+event index), so
+ * resuming from the last cursor after a dropped connection never re-adds an
+ * event already in the cache.
+ */
+
+export interface ChannelContractConfig {
+ source: ContractEventSource
+ contractId: string
+}
+
+export interface ChannelOptions {
+ contracts: ChannelContractConfig[]
+ /** Raw base64-XDR topic filter segments (advanced use), passed through to Soroban RPC as-is. */
+ topics?: string[][]
+ pollIntervalMs: number
+ limit: number
+ /** How far back (in ledgers) to look on the very first poll, before a cursor exists. */
+ initialLookbackLedgers: number
+}
+
+interface Channel {
+ options: ChannelOptions
+ snapshot: ContractEventsSnapshot
+ events: ContractEvent[]
+ eventIds: Set
+ cursor: string | null
+ startLedger: number | null
+ listeners: Set<() => void>
+ pollTimer: ReturnType | null
+ evictionTimer: ReturnType | null
+}
+
+const MAX_EVENTS = 500
+const CHANNEL_EVICTION_DELAY_MS = 60_000
+
+const channels = new Map()
+
+const EMPTY_SNAPSHOT: ContractEventsSnapshot = { status: 'idle', events: [], error: null, cursor: null }
+
+export function buildChannelKey(options: ChannelOptions): string {
+ const contractsKey = [...options.contracts]
+ .sort((a, b) => a.contractId.localeCompare(b.contractId))
+ .map(c => `${c.source}:${c.contractId}`)
+ .join(',')
+ const topicsKey = options.topics ? JSON.stringify(options.topics) : ''
+ return `${contractsKey}|${topicsKey}|${options.limit}|${options.initialLookbackLedgers}`
+}
+
+function createSnapshot(
+ status: ContractEventsStatus,
+ events: ContractEvent[],
+ error: string | null,
+ cursor: string | null
+): ContractEventsSnapshot {
+ return { status, events, error, cursor }
+}
+
+function getOrCreateChannel(key: string, options: ChannelOptions): Channel {
+ let channel = channels.get(key)
+ if (!channel) {
+ channel = {
+ options,
+ snapshot: EMPTY_SNAPSHOT,
+ events: [],
+ eventIds: new Set(),
+ cursor: null,
+ startLedger: null,
+ listeners: new Set(),
+ pollTimer: null,
+ evictionTimer: null,
+ }
+ channels.set(key, channel)
+ }
+ return channel
+}
+
+function notify(channel: Channel): void {
+ for (const listener of channel.listeners) listener()
+}
+
+export function subscribe(key: string, options: ChannelOptions, listener: () => void): () => void {
+ const channel = getOrCreateChannel(key, options)
+ channel.listeners.add(listener)
+
+ if (channel.evictionTimer) {
+ clearTimeout(channel.evictionTimer)
+ channel.evictionTimer = null
+ }
+
+ if (channel.listeners.size === 1 && !channel.pollTimer) {
+ void runPollLoop(key)
+ }
+
+ return () => {
+ channel.listeners.delete(listener)
+ if (channel.listeners.size === 0 && !channel.evictionTimer) {
+ if (channel.pollTimer) {
+ clearTimeout(channel.pollTimer)
+ channel.pollTimer = null
+ }
+ // Keep the cache around briefly so a quick remount (route change, StrictMode)
+ // hydrates instantly instead of re-polling from scratch.
+ channel.evictionTimer = setTimeout(() => channels.delete(key), CHANNEL_EVICTION_DELAY_MS)
+ }
+ }
+}
+
+export function getSnapshot(key: string): ContractEventsSnapshot {
+ const channel = channels.get(key)
+ return channel ? channel.snapshot : EMPTY_SNAPSHOT
+}
+
+export function getServerSnapshot(): ContractEventsSnapshot {
+ return EMPTY_SNAPSHOT
+}
+
+export function resync(key: string): void {
+ const channel = channels.get(key)
+ if (!channel) return
+
+ channel.events = []
+ channel.eventIds.clear()
+ channel.cursor = null
+ channel.startLedger = null
+ channel.snapshot = createSnapshot('connecting', channel.events, null, null)
+ notify(channel)
+
+ if (channel.pollTimer) {
+ clearTimeout(channel.pollTimer)
+ channel.pollTimer = null
+ }
+ if (channel.listeners.size > 0) {
+ void runPollLoop(key)
+ }
+}
+
+function extractContractId(event: rpc.Api.EventResponse): string | null {
+ return event.contractId ? event.contractId.contractId() : null
+}
+
+/** Soroban RPC rejects `startLedger`/`cursor` values outside its retention window with an error string, not a typed error. */
+function isRetentionError(message: string): boolean {
+ return /startLedger|ledger range|oldest ledger|is before/i.test(message)
+}
+
+async function runPollLoop(key: string): Promise {
+ const channel = channels.get(key)
+ if (!channel) return
+
+ try {
+ const server = getSorobanServer()
+
+ if (!channel.cursor && channel.startLedger === null) {
+ channel.snapshot = createSnapshot('connecting', channel.events, null, channel.cursor)
+ notify(channel)
+ const latest = await server.getLatestLedger()
+ channel.startLedger = Math.max(1, latest.sequence - channel.options.initialLookbackLedgers)
+ }
+
+ const filters = channel.options.contracts.map(contract => ({
+ contractIds: [contract.contractId],
+ ...(channel.options.topics ? { topics: channel.options.topics } : {}),
+ }))
+
+ const request: rpc.Server.GetEventsRequest = channel.cursor
+ ? { filters, cursor: channel.cursor, limit: channel.options.limit }
+ : { filters, startLedger: channel.startLedger ?? 1, limit: channel.options.limit }
+
+ const response = await server.getEvents(request)
+
+ let changed = false
+ for (const raw of response.events) {
+ if (channel.eventIds.has(raw.id)) continue
+
+ const contractId = extractContractId(raw)
+ const contractMeta = channel.options.contracts.find(c => c.contractId === contractId)
+ if (!contractMeta) continue
+
+ channel.eventIds.add(raw.id)
+ channel.events.push({
+ id: raw.id,
+ source: contractMeta.source,
+ contractId: contractMeta.contractId,
+ ledger: raw.ledger,
+ ledgerClosedAt: raw.ledgerClosedAt,
+ txHash: raw.txHash,
+ topic: raw.topic.map(scValToNative),
+ data: scValToNative(raw.value),
+ })
+ changed = true
+ }
+
+ if (channel.events.length > MAX_EVENTS) {
+ const removed = channel.events.splice(0, channel.events.length - MAX_EVENTS)
+ for (const removedEvent of removed) channel.eventIds.delete(removedEvent.id)
+ }
+
+ channel.cursor = response.cursor
+
+ if (changed || channel.snapshot.status !== 'live' || channel.snapshot.error) {
+ channel.snapshot = createSnapshot('live', channel.events, null, channel.cursor)
+ notify(channel)
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to fetch contract events'
+ if (isRetentionError(message)) {
+ channel.cursor = null
+ channel.startLedger = null
+ }
+ channel.snapshot = createSnapshot('error', channel.events, message, channel.cursor)
+ notify(channel)
+ } finally {
+ if (channel.listeners.size > 0) {
+ channel.pollTimer = setTimeout(() => void runPollLoop(key), channel.options.pollIntervalMs)
+ } else {
+ channel.pollTimer = null
+ }
+ }
+}
diff --git a/shared/contract-events/types.ts b/shared/contract-events/types.ts
new file mode 100644
index 0000000..11b9c92
--- /dev/null
+++ b/shared/contract-events/types.ts
@@ -0,0 +1,31 @@
+/**
+ * Which TrustFlow contract emitted an event. Determined by matching the
+ * event's contractId against shared/contracts.ts, not by guessing topic
+ * names, since the exact event schema lives in the Soroban contract source
+ * (not in this frontend repo).
+ */
+export type ContractEventSource = 'escrow' | 'dispute'
+
+export interface ContractEvent {
+ /** Unique + monotonically increasing per contract, used for dedup and as the resume cursor. */
+ id: string
+ source: ContractEventSource
+ contractId: string
+ ledger: number
+ ledgerClosedAt: string
+ txHash: string
+ /** Decoded topic segments (e.g. ["dispute_opened", ""]), native JS values. */
+ topic: unknown[]
+ /** Decoded event body, native JS value. Shape depends on the emitting contract function. */
+ data: unknown
+}
+
+export type ContractEventsStatus = 'idle' | 'connecting' | 'live' | 'error'
+
+export interface ContractEventsSnapshot {
+ events: ContractEvent[]
+ status: ContractEventsStatus
+ error: string | null
+ /** Cursor to resume from on the next poll; persisted across reconnects to avoid full refetches. */
+ cursor: string | null
+}
diff --git a/shared/contracts.ts b/shared/contracts.ts
index c6de1ce..0969459 100644
--- a/shared/contracts.ts
+++ b/shared/contracts.ts
@@ -1,10 +1,23 @@
/**
* TrustFlow contract config.
*
- * Replace CONTRACT_ID and RPC_URL with your deployed Soroban contract details.
+ * Replace the contract IDs with your deployed Soroban contract details.
* These values are read from environment variables at build time.
+ *
+ * Env var names follow the convention documented in README.md
+ * (NEXT_PUBLIC_STELLAR_NETWORK / NEXT_PUBLIC_SOROBAN_RPC_URL / NEXT_PUBLIC_*_CONTRACT_ID).
+ * NEXT_PUBLIC_CONTRACT_ID / NEXT_PUBLIC_RPC_URL are kept as fallbacks for back-compat
+ * with existing deployments that only set the older generic names.
*/
export const CONTRACT_ID = process.env.NEXT_PUBLIC_CONTRACT_ID ?? ''
export const RPC_URL = process.env.NEXT_PUBLIC_RPC_URL ?? 'https://soroban-testnet.stellar.org'
export const NETWORK_PASSPHRASE =
process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015'
+
+export const STELLAR_NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'TESTNET'
+
+export const SOROBAN_RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? RPC_URL
+
+export const ESCROW_CONTRACT_ID = process.env.NEXT_PUBLIC_ESCROW_CONTRACT_ID ?? CONTRACT_ID
+
+export const DISPUTE_CONTRACT_ID = process.env.NEXT_PUBLIC_DISPUTE_CONTRACT_ID ?? ''
diff --git a/shared/soroban-rpc.ts b/shared/soroban-rpc.ts
new file mode 100644
index 0000000..d26adfd
--- /dev/null
+++ b/shared/soroban-rpc.ts
@@ -0,0 +1,15 @@
+import { rpc } from '@stellar/stellar-sdk'
+import { SOROBAN_RPC_URL } from './contracts'
+
+/**
+ * Shared Soroban RPC client, lazily created so this module has no side effects
+ * at import time (safe to import from both client and server code).
+ */
+let server: rpc.Server | null = null
+
+export function getSorobanServer(): rpc.Server {
+ if (!server) {
+ server = new rpc.Server(SOROBAN_RPC_URL, { allowHttp: SOROBAN_RPC_URL.startsWith('http://') })
+ }
+ return server
+}