import { type AssetRegistry, getAssetRegistry, getMarketRegistry, type SDKRequestOptions, } from '@lifi/perps-sdk' import type { ActivitiesResponse, ActivityItem, FundingActivity, MarketDisplay, } from '@lifi/perps-types' import { ActivityType } from '@lifi/perps-types' import type { Address } from 'viem' import { DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT, PROVIDER_KEY, } from '../constants.js' import type { HyperliquidContext } from '../context.js' import type { HlUserFills, HlUserFunding, HlUserNonFundingLedgerUpdates, } from '../types/index.js' import { isCollateralTransferDelta, isDepositDelta, isLiquidationDelta, isSendAssetDelta, isSpotTransferDelta, isVaultTransferDelta, isWithdrawDelta, } from '../types/index.js' import { mapFundingActivity, mapLedgerEntry, mapLiquidationFills, } from '../utils/index.js' import { hlInfoOptions, type InfoRequestOptions, infoRequest, } from '../utils/infoClient.js' /** * Parameters for {@link getActivity}. * * @public */ export interface GetActivityParams { address: Address /** Maximum items returned; defaults to 50 and is capped at 200. */ limit?: number /** Millisecond timestamp cursor; rows strictly older than it are returned. */ cursor?: string /** Inclusive lower bound in milliseconds since epoch. */ startTime?: number /** Inclusive upper bound in milliseconds since epoch. */ endTime?: number /** Optional normalized activity-type filter applied after mapping. */ type?: ActivityType[] } const MARKET_BEARING_TYPES: ReadonlySet = new Set([ ActivityType.FUNDING, ActivityType.LIQUIDATION, ]) const ASSET_BEARING_TYPES: ReadonlySet = new Set([ ActivityType.DEPOSIT, ActivityType.WITHDRAWAL, ActivityType.TRANSFER, ]) const needsMarkets = (typeFilter: ActivityType[] | undefined): boolean => !typeFilter || typeFilter.some((t) => MARKET_BEARING_TYPES.has(t)) const fetchActivityData = async ( apiUrl: string, typeFilter: ActivityType[] | undefined, timeParams: { user: Address; startTime?: number; endTime?: number }, assetRegistry: AssetRegistry, resolveMarket: (coin: string) => MarketDisplay | undefined, options?: InfoRequestOptions ): Promise => { const needLedger = !typeFilter || typeFilter.some((t) => t !== ActivityType.FUNDING) const needFunding = !typeFilter || typeFilter.includes(ActivityType.FUNDING) // A liquidation executed as a market order reaches the account as fills // with a `liquidation` field and never as a ledger `liquidation` delta. const needLiquidationFills = !typeFilter || typeFilter.includes(ActivityType.LIQUIDATION) const [ledgerUpdates, fundingUpdates, fills] = await Promise.all([ needLedger ? infoRequest( apiUrl, { type: 'userNonFundingLedgerUpdates', ...timeParams }, options ) : Promise.resolve([] as HlUserNonFundingLedgerUpdates), needFunding ? infoRequest( apiUrl, { type: 'userFunding', ...timeParams }, options ) : Promise.resolve([] as HlUserFunding), needLiquidationFills ? infoRequest( apiUrl, timeParams.startTime === undefined ? { type: 'userFills', user: timeParams.user } : { type: 'userFillsByTime', ...timeParams }, options ) : Promise.resolve([] as HlUserFills), ]) const ledgerItems: ActivityItem[] = ledgerUpdates.flatMap( (entry): ActivityItem[] => { if ( typeFilter !== undefined && ((isDepositDelta(entry.delta) && !typeFilter.includes(ActivityType.DEPOSIT)) || (isWithdrawDelta(entry.delta) && !typeFilter.includes(ActivityType.WITHDRAWAL)) || (!typeFilter.includes(ActivityType.TRANSFER) && (isSpotTransferDelta(entry.delta) || isSendAssetDelta(entry.delta) || isCollateralTransferDelta(entry.delta) || isVaultTransferDelta(entry.delta)))) ) { return [] } const item = mapLedgerEntry( entry, PROVIDER_KEY, timeParams.user, assetRegistry, resolveMarket ) return item === null ? [] : [item] } ) const fundingItems: ActivityItem[] = fundingUpdates.flatMap( (entry): FundingActivity[] => { const item = mapFundingActivity(entry, PROVIDER_KEY, resolveMarket) return item === null ? [] : [item] } ) const ledgerLiquidationHashes = new Set( ledgerUpdates .filter((entry) => isLiquidationDelta(entry.delta)) .map((entry) => entry.hash) ) const liquidationItems = mapLiquidationFills( fills, PROVIDER_KEY, timeParams.user, resolveMarket, ledgerLiquidationHashes ) const merged = [...ledgerItems, ...fundingItems, ...liquidationItems].sort( (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ) if (!typeFilter) { return merged } const typeSet = new Set(typeFilter) return merged.filter((item) => typeSet.has(item.type)) } /** * Fetch a chronological activity feed (deposits, withdrawals, transfers, * liquidations, funding) for `address`. Combines results from Hyperliquid's * `userNonFundingLedgerUpdates`, `userFunding`, and `userFills` endpoints, * sorted newest-first. * * Cursor-based pagination uses the ms-since-epoch timestamp of the last item * on the current page. * @throws {PerpsError} On Hyperliquid REST error, network, or parsing failures. * @public */ export const getActivity = async ( { client, apiUrl }: HyperliquidContext, params: GetActivityParams, options?: SDKRequestOptions ): Promise => { const registry = getMarketRegistry(client, PROVIDER_KEY) // Only funding and liquidation rows carry a market, so a Ledger-only // request must not pull the market list. if (needsMarkets(params.type)) { await registry.sync() } const assetRegistry = getAssetRegistry(client, PROVIDER_KEY) if ( params.type === undefined || params.type.some((type) => ASSET_BEARING_TYPES.has(type)) ) { await assetRegistry.sync() } const infoOpts = hlInfoOptions(client, options) const limit = Math.min( params.limit ?? DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT ) const timeParams = { user: params.address, ...(params.startTime !== undefined || params.endTime !== undefined ? { startTime: params.startTime ?? 0 } : {}), ...(params.endTime === undefined ? {} : { endTime: params.endTime }), } // `get`, not `require`: a coin the backend market list does not hold drops // only its own row instead of rejecting the whole feed. The registry warns // once per unresolved id. A delisted market still resolves, so its rows stay. const merged = await fetchActivityData( apiUrl, params.type, timeParams, assetRegistry, (coin) => registry.get(coin), infoOpts ) // Drop boundary items so the next page does not return the cursor row twice. const filtered = params.cursor === undefined ? merged : merged.filter( (item) => new Date(item.timestamp).getTime() < Number.parseInt(params.cursor!, 10) ) const hasMore = filtered.length > limit const items = filtered.slice(0, limit) return { provider: PROVIDER_KEY, items, pagination: { limit, hasMore, cursor: items.length > 0 ? String(new Date(items[items.length - 1].timestamp).getTime()) : undefined, }, } }