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.'} +

+ ) : ( + + )} +
+ ) +} 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 +}