diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/ContractActivityCard.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/ContractActivityCard.tsx new file mode 100644 index 000000000..4ce074b6f --- /dev/null +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/ContractActivityCard.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { rpc as StellarRpc, xdr } from "@stellar/stellar-sdk"; +import { Badge, Icon } from "@stellar/design-system"; + +import { SdsLink } from "@/components/SdsLink"; +import { Routes } from "@/constants/routes"; + +import { ContractActivityJson } from "./ContractActivityJson"; +import { + formatRelativeTime, + parseScVal, + truncateHash, + ParsedScVal, +} from "./helpers"; + +/** + * Renders a single contract event as a card with header, data rows, and + * collapsible JSON. + * + * @example + * + */ +export const ContractActivityCard = ({ + event, +}: { + event: StellarRpc.Api.EventResponse; +}) => { + const txDashboardUrl = `${Routes.TRANSACTION_DASHBOARD}?txHash=${event.txHash}`; + + // Parse topics and value + const topicRows: ParsedScVal[] = event.topic.map((t: xdr.ScVal) => + parseScVal(t), + ); + const valueRow: ParsedScVal = parseScVal(event.value); + + // Build JSON for the collapsible viewer + const eventJson = JSON.stringify( + { + id: event.id, + type: event.type, + ledger: event.ledger, + ledgerClosedAt: event.ledgerClosedAt, + contractId: event.contractId?.toString(), + txHash: event.txHash, + topic: event.topic.map((t: xdr.ScVal) => t.toXDR("base64")), + value: event.value.toXDR("base64"), + }, + null, + 2, + ); + + return ( +
+ {/* Header row */} +
+ + } + iconPosition="right" + > + {truncateHash(event.txHash)} + + + + + {formatRelativeTime(event.ledgerClosedAt)} + +
+ + {/* Event body (gray box) */} +
+ {/* Topics */} + {topicRows.map((row, i) => ( + + ))} + + {/* Value */} + + + {/* Transaction hash */} +
+ trx hash + {event.txHash} +
+
+ + {/* Collapsible JSON */} + +
+ ); +}; + +/** + * Renders a single data row for a parsed ScVal (topic or value). + */ +const EventDataRow = ({ row }: { row: ParsedScVal }) => { + return ( +
+ {row.label} + + {row.isAddress ? ( + + {row.displayValue} + + ) : ( + {row.displayValue} + )} + {row.typeAnnotation ? ( + + {row.typeAnnotation} + + ) : null} + +
+ ); +}; diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/ContractActivityJson.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/ContractActivityJson.tsx new file mode 100644 index 000000000..f6ec6f473 --- /dev/null +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/ContractActivityJson.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState } from "react"; +import { Icon } from "@stellar/design-system"; + +import { CodeEditor } from "@/components/CodeEditor"; + +/** + * Collapsible JSON viewer for a single contract event. + * Collapsed by default, toggled via "View JSON" link. + * + * @example + * + */ +export const ContractActivityJson = ({ + eventJson, +}: { + eventJson: string; +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + + {isExpanded ? ( + + ) : null} +
+ ); +}; diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/helpers.ts b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/helpers.ts new file mode 100644 index 000000000..25a1a87b6 --- /dev/null +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/helpers.ts @@ -0,0 +1,244 @@ +import { Address, scValToNative, xdr } from "@stellar/stellar-sdk"; +import { rpc as StellarRpc } from "@stellar/stellar-sdk"; + +// ============================================================================= +// Relative timestamp formatting +// ============================================================================= + +/** + * Formats an ISO timestamp as a human-readable relative time string. + * Matches the Figma spec: "~X seconds ago", "~X min ago", etc. + * + * @param isoTimestamp - ISO 8601 date string (ledgerClosedAt) + * @returns Relative time string, e.g. "~15 seconds ago" + * + * @example + * formatRelativeTime("2025-03-24T12:00:00Z"); // "~2 min ago" + */ +export const formatRelativeTime = (isoTimestamp: string): string => { + const now = Date.now(); + const then = new Date(isoTimestamp).getTime(); + const elapsedMs = now - then; + const elapsedSeconds = Math.floor(elapsedMs / 1000); + + if (elapsedSeconds < 60) { + return `~${elapsedSeconds} seconds ago`; + } + + const elapsedMinutes = Math.floor(elapsedSeconds / 60); + if (elapsedMinutes < 60) { + return `~${elapsedMinutes} min ago`; + } + + const elapsedHours = Math.floor(elapsedMinutes / 60); + if (elapsedHours < 24) { + return `~${elapsedHours} hours ago`; + } + + const elapsedDays = Math.floor(elapsedHours / 24); + return `~${elapsedDays} days ago`; +}; + +// ============================================================================= +// ScVal introspection +// ============================================================================= + +export interface ParsedScVal { + label: string; + typeAnnotation: string; + displayValue: string; + isAddress: boolean; +} + +/** + * Truncates a hex hash to the format "2e887...b6f1f" (5 + 5 chars). + * + * @param hash - Full transaction hash + * @returns Truncated hash string + * + * @example + * truncateHash("2e887abcdef1234567890b6f1f"); // "2e887...b6f1f" + */ +export const truncateHash = (hash: string): string => { + if (hash.length <= 12) return hash; + return `${hash.slice(0, 5)}...${hash.slice(-5)}`; +}; + +/** + * Converts an i128 ScVal (hi + lo parts) into a decimal string. + * + * @param parts - The i128 value from ScVal + * @returns String representation of the 128-bit integer + */ +const i128ToString = (parts: xdr.Int128Parts): string => { + const hi = BigInt(parts.hi().toString()); + const lo = BigInt(parts.lo().toString()); + // hi is the high 64 bits, lo is the unsigned low 64 bits + const SHIFT = BigInt(64); + const MASK = (BigInt(1) << SHIFT) - BigInt(1); + const value = (hi << SHIFT) | (lo & MASK); + return value.toString(); +}; + +/** + * Converts a u128 ScVal (hi + lo parts) into a decimal string. + * + * @param parts - The u128 value from ScVal + * @returns String representation of the unsigned 128-bit integer + */ +const u128ToString = (parts: xdr.UInt128Parts): string => { + const hi = BigInt(parts.hi().toString()); + const lo = BigInt(parts.lo().toString()); + const SHIFT = BigInt(64); + const MASK = (BigInt(1) << SHIFT) - BigInt(1); + const value = (hi << SHIFT) | (lo & MASK); + return value.toString(); +}; + +/** + * Introspects an xdr.ScVal and returns a display-ready representation. + * Maps ScVal types to labels, type annotations, and display values + * per the spec's type-to-display mapping. + * + * @param scVal - A Stellar XDR ScVal object + * @returns Parsed display data for the ScVal + * + * @example + * const parsed = parseScVal(topic); + * // { label: "Symbol", typeAnnotation: "sym", displayValue: '"transfer"', isAddress: false } + */ +export const parseScVal = (scVal: xdr.ScVal): ParsedScVal => { + const typeName = scVal.switch().name; + + switch (typeName) { + case "scvSymbol": + return { + label: "Symbol", + typeAnnotation: "sym", + displayValue: `"${scVal.sym().toString()}"`, + isAddress: false, + }; + + case "scvAddress": { + const addr = Address.fromScVal(scVal).toString(); + return { + label: "Address", + typeAnnotation: "address", + displayValue: addr, + isAddress: true, + }; + } + + case "scvString": + return { + label: "string", + typeAnnotation: "string", + displayValue: `"${scVal.str().toString()}"`, + isAddress: false, + }; + + case "scvI128": + return { + label: "Data", + typeAnnotation: "i128", + displayValue: `"${i128ToString(scVal.i128())}"`, + isAddress: false, + }; + + case "scvU128": + return { + label: "Data", + typeAnnotation: "u128", + displayValue: `"${u128ToString(scVal.u128())}"`, + isAddress: false, + }; + + case "scvBytes": + return { + label: "Data", + typeAnnotation: "bytes", + displayValue: scVal.bytes().toString("hex"), + isAddress: false, + }; + + case "scvBool": + return { + label: "Data", + typeAnnotation: "bool", + displayValue: scVal.b().toString(), + isAddress: false, + }; + + default: { + // Fallback: JSON-serialize the ScVal + let fallback: string; + try { + fallback = JSON.stringify(scVal.value()); + } catch { + fallback = scVal.toXDR("base64"); + } + return { + label: "Data", + typeAnnotation: typeName.replace("scv", "").toLowerCase(), + displayValue: fallback, + isAddress: false, + }; + } + } +}; + +// ============================================================================= +// humanizeEvents-based parsing +// ============================================================================= + +export interface HumanizedEvent { + /** Event type: "contract", "system", or "diagnostic" */ + type: string; + /** Contract ID (C... strkey), if present */ + contractId?: string; + /** Topics converted to native JS values via scValToNative */ + topics: unknown[]; + /** Event data converted to native JS value via scValToNative */ + data: unknown; +} + +export interface HumanizedEventWithMeta extends HumanizedEvent { + /** Transaction hash */ + txHash: string; + /** Ledger number */ + ledger: number; + /** ISO timestamp of ledger close */ + ledgerClosedAt: string; + /** Original event ID */ + id: string; +} + +/** + * Parses raw RPC event responses using the SDK's `scValToNative` utility. + * Converts XDR ScVal topics and data into native JS values (strings, BigInts, + * addresses, etc.) while preserving event metadata (txHash, ledger, timestamp). + * + * This is a higher-level alternative to the manual `parseScVal` approach — + * it delegates type conversion to the SDK's `scValToNative`. + * + * @param events - Array of raw event responses from `getEvents()` + * @returns Array of humanized events with metadata attached + * + * @example + * const humanized = humanizeContractEvents(eventsData.events); + * // [{ type: "contract", topics: ["transfer", "G...", "G..."], data: 1000n, txHash: "abc...", ... }] + */ +export const humanizeContractEvents = ( + events: StellarRpc.Api.EventResponse[], +): HumanizedEventWithMeta[] => { + return events.map((e) => ({ + type: e.type, + contractId: e.contractId?.toString(), + topics: e.topic.map((t: xdr.ScVal) => scValToNative(t)), + data: scValToNative(e.value), + txHash: e.txHash, + ledger: e.ledger, + ledgerClosedAt: e.ledgerClosedAt, + id: e.id, + })); +}; diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/index.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/index.tsx new file mode 100644 index 000000000..20bd9d420 --- /dev/null +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/index.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button, Icon, Loader, Text } from "@stellar/design-system"; + +import { Box } from "@/components/layout/Box"; +import { ErrorText } from "@/components/ErrorText"; + +import { useGetContractEvents } from "@/query/useGetContractEvents"; +import { NetworkHeaders } from "@/types/types"; + +import { ContractActivityCard } from "./ContractActivityCard"; + +import "./styles.scss"; + +const PAGE_SIZE = 20; + +/** + * Activity (events) tab content for the contract explorer. + * Fetches and displays recent contract events with 5-second polling. + * Includes a pause/resume button so users can freeze the feed to inspect events. + * Paginated at 20 events per page. + * + * @example + * + */ +export const ContractActivity = ({ + isActive, + contractId, + rpcUrl, + headers, +}: { + isActive: boolean; + contractId: string; + rpcUrl: string; + headers?: NetworkHeaders; +}) => { + const [isPolling, setIsPolling] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + + const { + data: eventsData, + error: eventsError, + isLoading: isEventsLoading, + isFetching: isEventsFetching, + refetch, + } = useGetContractEvents({ + contractId, + rpcUrl, + headers, + isActive, + isPolling, + }); + + const events = eventsData?.events ?? []; + const totalPages = Math.max(1, Math.ceil(events.length / PAGE_SIZE)); + + // Reset to page 1 when data changes (new events come in) + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(1); + } + }, [totalPages, currentPage]); + + const paginatedEvents = events.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE, + ); + + const handleTogglePolling = () => { + if (isPolling) { + setIsPolling(false); + } else { + refetch(); + setIsPolling(true); + } + }; + + // Only show loader on initial load, not on refetches + if (isEventsLoading || (!eventsData && isEventsFetching)) { + return ( + + + + ); + } + + if (eventsError) { + return ; + } + + if (events.length === 0) { + return ( + + No recent events found for this contract. + + ); + } + + return ( +
+
+ + + {isPolling ? ( + + Auto-refreshing every 5s + + ) : null} +
+ +
+ {paginatedEvents.map((event) => ( + + ))} +
+ + {/* Pagination — matches DataTable's pagination pattern */} + {totalPages > 1 ? ( + + + {events.length} events + + + + + + + + + ) : null} +
+ ); +}; diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/styles.scss b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/styles.scss new file mode 100644 index 000000000..e1d2ffa8f --- /dev/null +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractActivity/styles.scss @@ -0,0 +1,147 @@ +@use "../../../../../../styles/utils.scss" as *; + +.ContractActivity { + display: flex; + flex-direction: column; + gap: pxToRem(16px); + + &__toolbar { + display: flex; + align-items: center; + gap: pxToRem(12px); + + .Text { + color: var(--sds-clr-gray-09); + } + } + + &__list { + display: flex; + flex-direction: column; + gap: pxToRem(16px); + } + + // Event card + &__card { + background: var(--sds-clr-gray-01); + border: 1px solid var(--sds-clr-gray-04); + border-radius: pxToRem(12px); + padding: pxToRem(24px); + display: flex; + flex-direction: column; + gap: pxToRem(16px); + } + + // Header: tx hash badge + timestamp + &__cardHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(8px); + + .Badge { + display: inline-flex; + align-items: center; + gap: pxToRem(4px); + font-family: var(--sds-ff-monospace); + + svg { + width: pxToRem(12px); + height: pxToRem(12px); + } + } + } + + &__timestamp { + font-size: pxToRem(13px); + color: var(--sds-clr-gray-09); + white-space: nowrap; + } + + // Gray event body box + &__body { + background: var(--sds-clr-gray-03); + border-radius: pxToRem(8px); + padding: pxToRem(12px); + display: flex; + flex-direction: column; + gap: pxToRem(8px); + } + + // Individual data row + &__row { + display: flex; + align-items: flex-start; + gap: pxToRem(12px); + font-family: var(--sds-ff-monospace); + font-size: pxToRem(13px); + line-height: pxToRem(20px); + word-break: break-all; + } + + &__rowLabel { + color: var(--sds-clr-gray-09); + min-width: pxToRem(80px); + flex-shrink: 0; + font-family: var(--sds-ff-body); + font-size: pxToRem(13px); + } + + &__rowValue { + color: var(--sds-clr-gray-12); + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: pxToRem(6px); + } + + // Type annotation subscript + &__typeAnnotation { + font-family: var(--sds-ff-body); + font-size: pxToRem(11px); + color: var(--sds-clr-gray-08); + flex-shrink: 0; + } + + // Address link styling + &__addressLink { + text-decoration: underline; + } + + // Pagination page count + &__pageCount { + font-size: pxToRem(14px); + color: var(--sds-clr-gray-09); + white-space: nowrap; + padding: 0 pxToRem(4px); + } + + // Collapsible JSON section + &__json { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + } + + &__jsonToggle { + display: inline-flex; + align-items: center; + gap: pxToRem(4px); + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: pxToRem(13px); + color: var(--sds-clr-lilac-09); + font-family: var(--sds-ff-body); + + svg { + width: pxToRem(14px); + height: pxToRem(14px); + } + + &:hover { + text-decoration: underline; + } + } +} diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractInfo.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractInfo.tsx index 63a77c8e9..1daf2a4b5 100644 --- a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractInfo.tsx +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractInfo.tsx @@ -37,6 +37,7 @@ import { import { ContractSpecMeta } from "./ContractSpecMeta"; import { ContractStorage } from "./ContractStorage"; +import { ContractActivity } from "./ContractActivity"; import { VersionHistory } from "./VersionHistory"; import { BuildInfo } from "./BuildInfo"; import { SourceCode } from "./SourceCode"; @@ -68,7 +69,8 @@ export const ContractInfo = ({ | "contract-source-code" | "contract-contract-storage" | "contract-version-history" - | "contract-build-info"; + | "contract-build-info" + | "contract-activity"; const [activeTab, setActiveTab] = useState( "contract-contract-spec", @@ -421,6 +423,18 @@ export const ContractInfo = ({ ), isDisabled: !isDataLoaded || isSacType, }} + tab8={{ + id: "contract-activity", + label: "Activity (events)", + content: ( + + ), + isDisabled: !isDataLoaded, + }} activeTabId={activeTab} onTabChange={(tabId) => { setActiveTab(tabId as ContractTabId); diff --git a/src/constants/networkLimits.ts b/src/constants/networkLimits.ts index 84bf8d3c3..b3e6edbb3 100644 --- a/src/constants/networkLimits.ts +++ b/src/constants/networkLimits.ts @@ -83,36 +83,36 @@ export const MAINNET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ - "890708604", - "884390684", - "869203144", - "854475644", - "839278996", - "825281880", - "816346052", - "816579152", - "817281920", - "817618580", - "818790088", - "819076236", - "819465324", - "820167796", - "820533092", - "820255500", - "815645004", - "811055056", - "806914280", - "802663276", - "798574420", - "793390388", - "788704844", - "784339616", - "780019364", - "775837484", - "770414728", - "765055432", - "755951804", - "746827664" + "773711440", + "774036876", + "774510224", + "774702196", + "775419064", + "776508976", + "776745112", + "777078280", + "777451912", + "778200392", + "779280552", + "779683200", + "779991944", + "780426340", + "780944664", + "782123236", + "782947261", + "783248305", + "783655649", + "784404073", + "785194261", + "786078405", + "786878145", + "787124537", + "787650309", + "788235761", + "789261633", + "790307421", + "790359777", + "791272999" ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", @@ -151,36 +151,36 @@ export const TESTNET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ - "947173188", - "947543066", - "947578030", - "947594914", - "947621978", - "947936432", - "948299603", - "948185721", - "948126857", - "948157557", - "948281426", - "948327526", - "948374606", - "948385230", - "948364682", - "948375538", - "948328774", - "943915383", - "945133177", - "945521959", - "945292077", - "945297577", - "945449866", - "945435422", - "945433126", - "945476718", - "944941163", - "946402823", - "946742666", - "946767562" + "938546554", + "938666695", + "938664204", + "938755840", + "938850277", + "939097534", + "939120602", + "939654961", + "940015342", + "940168005", + "940370606", + "940327138", + "940348498", + "940596429", + "940479360", + "940619251", + "940776944", + "940788972", + "940800376", + "940816880", + "940797772", + "940795104", + "940818928", + "940824980", + "941741362", + "941757122", + "941763802", + "941770850", + "941723806", + "941779028" ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", @@ -219,36 +219,36 @@ export const FUTURENET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ - "67023064", - "67023064", - "67023064", - "67023064", - "67023064", - "67023064", - "67023064", - "67023064", - "67023064", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192", - "67026192" + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791", + "69389791" ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", diff --git a/src/query/useGetContractEvents.ts b/src/query/useGetContractEvents.ts new file mode 100644 index 000000000..e18bb1f5d --- /dev/null +++ b/src/query/useGetContractEvents.ts @@ -0,0 +1,112 @@ +import { useQuery } from "@tanstack/react-query"; +import { rpc as StellarRpc } from "@stellar/stellar-sdk"; + +import { isEmptyObject } from "@/helpers/isEmptyObject"; +import { NetworkHeaders } from "@/types/types"; + +const EVENTS_LIMIT = 100; +const EVENTS_POLL_INTERVAL_MS = 5_000; + +/** + * Fetches the most recent contract events from the Stellar RPC using + * `getEvents()` with cursor-based pagination. + * + * Strategy: start with a small lookback window (200 ledgers / ~17 min). + * If that returns fewer events than the limit, progressively widen the + * window (doubling each time, up to 10,000 ledgers). This ensures we + * get the freshest events for active contracts without over-fetching, + * while still surfacing events for quieter contracts. + * + * On subsequent polls (every 5 s) the cursor from the previous response + * is reused so only genuinely new events are fetched and prepended. + * + * @param contractId - The contract ID to filter events for + * @param rpcUrl - The RPC server URL + * @param headers - Optional network headers + * @param isActive - Whether the tab is currently visible + * @param isPolling - Whether to auto-refetch every 5 seconds + * @returns React Query result with parsed event data + * + * @example + * const { data, isLoading, error } = useGetContractEvents({ + * contractId: "CABC...", + * rpcUrl: "https://soroban-testnet.stellar.org", + * isActive: true, + * }); + */ +export const useGetContractEvents = ({ + contractId, + rpcUrl, + headers = {}, + isActive, + isPolling = true, +}: { + contractId: string; + rpcUrl: string; + headers?: NetworkHeaders; + isActive: boolean; + /** Whether to auto-refetch every 5 seconds. When false, polling is paused. */ + isPolling?: boolean; +}) => { + const query = useQuery({ + queryKey: ["getContractEvents", contractId, rpcUrl], + queryFn: async () => { + const rpcServer = new StellarRpc.Server(rpcUrl, { + headers: isEmptyObject(headers) ? undefined : { ...headers }, + allowHttp: new URL(rpcUrl).hostname === "localhost", + }); + + const latestLedger = await rpcServer.getLatestLedger(); + + // Adaptive lookback: start small, widen if we find fewer events + // than the limit. This keeps queries fast for active contracts + // while still surfacing events for quieter ones. + const LOOKBACK_STEPS = [200, 1_000, 5_000, 10_000]; + let events: StellarRpc.Api.EventResponse[] = []; + + for (const lookback of LOOKBACK_STEPS) { + const startLedger = Math.max(latestLedger.sequence - lookback, 0); + + const response = await rpcServer.getEvents({ + startLedger, + endLedger: latestLedger.sequence, + filters: [ + { + type: "contract", + contractIds: [contractId], + }, + ], + limit: EVENTS_LIMIT, + }); + + events = response.events; + + // If we got events, use these. If the RPC returned the full limit, + // these are the oldest within this window — but that means the + // contract is active and the window is already good enough. + // If we got fewer than the limit, we have ALL events in this + // window, so widening further would only add older ones. + if (events.length > 0) { + break; + } + } + + // Sort newest-first + events.sort((a, b) => { + if (b.ledger !== a.ledger) { + return b.ledger - a.ledger; + } + return b.transactionIndex - a.transactionIndex; + }); + + return { + events, + latestLedger: latestLedger.sequence, + } as unknown as StellarRpc.Api.GetEventsResponse; + }, + enabled: Boolean(isActive && contractId && rpcUrl), + refetchInterval: isActive && isPolling ? EVENTS_POLL_INTERVAL_MS : false, + }); + + return query; +};