@lifi/perps-sdk-provider-hyperliquid 19.0.0 → 20.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +56 -0
  2. package/dist/cjs/HyperliquidProvider.js.map +1 -1
  3. package/dist/cjs/services/getAccount.d.ts.map +1 -1
  4. package/dist/cjs/services/getAccount.js +12 -8
  5. package/dist/cjs/services/getAccount.js.map +1 -1
  6. package/dist/cjs/services/getActivity.d.ts.map +1 -1
  7. package/dist/cjs/services/getActivity.js +71 -45
  8. package/dist/cjs/services/getActivity.js.map +1 -1
  9. package/dist/cjs/services/getFills.d.ts.map +1 -1
  10. package/dist/cjs/services/getFills.js +30 -38
  11. package/dist/cjs/services/getFills.js.map +1 -1
  12. package/dist/cjs/services/getMarketSettings.d.ts +2 -2
  13. package/dist/cjs/services/getMarketSettings.d.ts.map +1 -1
  14. package/dist/cjs/services/getMarketSettings.js +8 -3
  15. package/dist/cjs/services/getMarketSettings.js.map +1 -1
  16. package/dist/cjs/services/historyPagination.d.ts +22 -0
  17. package/dist/cjs/services/historyPagination.d.ts.map +1 -0
  18. package/dist/cjs/services/historyPagination.js +125 -0
  19. package/dist/cjs/services/historyPagination.js.map +1 -0
  20. package/dist/cjs/types/ledger.d.ts +17 -1
  21. package/dist/cjs/types/ledger.d.ts.map +1 -1
  22. package/dist/cjs/types/ledger.js +10 -1
  23. package/dist/cjs/types/ledger.js.map +1 -1
  24. package/dist/cjs/utils/mapActivity.d.ts.map +1 -1
  25. package/dist/cjs/utils/mapActivity.js +41 -0
  26. package/dist/cjs/utils/mapActivity.js.map +1 -1
  27. package/dist/cjs/utils/withdrawableBalances.d.ts.map +1 -1
  28. package/dist/cjs/utils/withdrawableBalances.js +3 -1
  29. package/dist/cjs/utils/withdrawableBalances.js.map +1 -1
  30. package/dist/cjs/websocket/HyperliquidWsProvider.d.ts +6 -0
  31. package/dist/cjs/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  32. package/dist/cjs/websocket/HyperliquidWsProvider.js +129 -37
  33. package/dist/cjs/websocket/HyperliquidWsProvider.js.map +1 -1
  34. package/dist/esm/HyperliquidProvider.js.map +1 -1
  35. package/dist/esm/services/getAccount.d.ts.map +1 -1
  36. package/dist/esm/services/getAccount.js +10 -9
  37. package/dist/esm/services/getAccount.js.map +1 -1
  38. package/dist/esm/services/getActivity.d.ts +3 -13
  39. package/dist/esm/services/getActivity.d.ts.map +1 -1
  40. package/dist/esm/services/getActivity.js +78 -66
  41. package/dist/esm/services/getActivity.js.map +1 -1
  42. package/dist/esm/services/getFills.d.ts +2 -14
  43. package/dist/esm/services/getFills.d.ts.map +1 -1
  44. package/dist/esm/services/getFills.js +32 -58
  45. package/dist/esm/services/getFills.js.map +1 -1
  46. package/dist/esm/services/getMarketSettings.d.ts +5 -3
  47. package/dist/esm/services/getMarketSettings.d.ts.map +1 -1
  48. package/dist/esm/services/getMarketSettings.js +13 -7
  49. package/dist/esm/services/getMarketSettings.js.map +1 -1
  50. package/dist/esm/services/historyPagination.d.ts +22 -0
  51. package/dist/esm/services/historyPagination.d.ts.map +1 -0
  52. package/dist/esm/services/historyPagination.js +121 -0
  53. package/dist/esm/services/historyPagination.js.map +1 -0
  54. package/dist/esm/types/ledger.d.ts +42 -1
  55. package/dist/esm/types/ledger.d.ts.map +1 -1
  56. package/dist/esm/types/ledger.js +17 -0
  57. package/dist/esm/types/ledger.js.map +1 -1
  58. package/dist/esm/utils/mapActivity.d.ts +1 -0
  59. package/dist/esm/utils/mapActivity.d.ts.map +1 -1
  60. package/dist/esm/utils/mapActivity.js +49 -1
  61. package/dist/esm/utils/mapActivity.js.map +1 -1
  62. package/dist/esm/utils/withdrawableBalances.d.ts +2 -1
  63. package/dist/esm/utils/withdrawableBalances.d.ts.map +1 -1
  64. package/dist/esm/utils/withdrawableBalances.js +5 -2
  65. package/dist/esm/utils/withdrawableBalances.js.map +1 -1
  66. package/dist/esm/websocket/HyperliquidWsProvider.d.ts +6 -0
  67. package/dist/esm/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  68. package/dist/esm/websocket/HyperliquidWsProvider.js +131 -49
  69. package/dist/esm/websocket/HyperliquidWsProvider.js.map +1 -1
  70. package/dist/types/services/getAccount.d.ts.map +1 -1
  71. package/dist/types/services/getActivity.d.ts +3 -13
  72. package/dist/types/services/getActivity.d.ts.map +1 -1
  73. package/dist/types/services/getFills.d.ts +2 -14
  74. package/dist/types/services/getFills.d.ts.map +1 -1
  75. package/dist/types/services/getMarketSettings.d.ts +5 -3
  76. package/dist/types/services/getMarketSettings.d.ts.map +1 -1
  77. package/dist/types/services/historyPagination.d.ts +22 -0
  78. package/dist/types/services/historyPagination.d.ts.map +1 -0
  79. package/dist/types/types/ledger.d.ts +42 -1
  80. package/dist/types/types/ledger.d.ts.map +1 -1
  81. package/dist/types/utils/mapActivity.d.ts +1 -0
  82. package/dist/types/utils/mapActivity.d.ts.map +1 -1
  83. package/dist/types/utils/withdrawableBalances.d.ts +2 -1
  84. package/dist/types/utils/withdrawableBalances.d.ts.map +1 -1
  85. package/dist/types/websocket/HyperliquidWsProvider.d.ts +6 -0
  86. package/dist/types/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  87. package/package.json +3 -3
  88. package/src/HyperliquidProvider.ts +1 -1
  89. package/src/services/getAccount.ts +22 -12
  90. package/src/services/getActivity.ts +138 -105
  91. package/src/services/getFills.ts +42 -95
  92. package/src/services/getMarketSettings.ts +20 -8
  93. package/src/services/historyPagination.ts +169 -0
  94. package/src/types/ledger.ts +61 -0
  95. package/src/utils/mapActivity.ts +56 -0
  96. package/src/utils/withdrawableBalances.ts +8 -3
  97. package/src/websocket/HyperliquidWsProvider.ts +141 -63
@@ -1,19 +1,12 @@
1
1
  import { getMarketRegistry, type SDKRequestOptions } from '@lifi/perps-sdk'
2
2
  import type { Fill, FillsResponse } from '@lifi/perps-types'
3
3
  import type { Address } from 'viem'
4
- import {
5
- DEFAULT_HISTORY_LIMIT,
6
- MAX_HISTORY_LIMIT,
7
- PROVIDER_KEY,
8
- } from '../constants.js'
4
+ import { PROVIDER_KEY } from '../constants.js'
9
5
  import type { HyperliquidContext } from '../context.js'
10
- import type {
11
- HlUserFill,
12
- HlUserFills,
13
- HlUserFillsByTime,
14
- } from '../types/index.js'
6
+ import type { HlUserFills } from '../types/index.js'
15
7
  import { assetIsOutcome, mapFill } from '../utils/index.js'
16
8
  import { hlInfoOptions, infoRequest } from '../utils/infoClient.js'
9
+ import { getHistoryLimit, historyPage } from './historyPagination.js'
17
10
 
18
11
  /**
19
12
  * Parameters for {@link getFills}.
@@ -22,7 +15,7 @@ import { hlInfoOptions, infoRequest } from '../utils/infoClient.js'
22
15
  */
23
16
  export interface GetFillsParams {
24
17
  address: Address
25
- /** Maximum items returned; defaults to 50 and is capped at 200. */
18
+ /** Positive safe integer; defaults to 50 and is capped at 200. */
26
19
  limit?: number
27
20
  /** Opaque cursor returned in the previous page's `pagination.cursor`. */
28
21
  cursor?: string
@@ -32,101 +25,55 @@ export interface GetFillsParams {
32
25
  endTime?: number
33
26
  }
34
27
 
35
- interface FillCursor {
36
- time: number
37
- tid: number
38
- }
39
-
40
- // HL's docs specify neither newest-first ordering nor monotonic `tid` for
41
- // `userFills`/`userFillsByTime`, so pagination can't rely on upstream order —
42
- // it must impose its own (time desc, tid desc as tiebreaker for same-time fills).
43
- const compareFillsDesc = (a: FillCursor, b: FillCursor): number =>
44
- b.time - a.time || b.tid - a.tid
45
-
46
- const encodeCursor = (fill: FillCursor): string => `${fill.time}:${fill.tid}`
47
-
48
- const decodeCursor = (cursor: string): FillCursor => {
49
- const [time, tid] = cursor.split(':').map(Number)
50
- return { time, tid }
51
- }
52
-
53
- /**
54
- * Fetch the user's fills history. When `startTime` or `endTime` is provided,
55
- * Hyperliquid's `userFillsByTime` endpoint is used; otherwise the unbounded
56
- * `userFills` endpoint returns the full history. The backend's enriched asset
57
- * list supplies the display fields; only the fills endpoint is read direct
58
- * from HL.
59
- *
60
- * The response is sorted (time desc, `tid` as tiebreaker) before pagination —
61
- * see {@link compareFillsDesc}. The cursor encodes the last returned fill's
62
- * `(time, tid)`; the next page keeps fills that sort strictly after it.
63
- * @throws {PerpsError} On Hyperliquid REST error, network, or parsing failures.
64
- * @public
65
- */
28
+ /** Fetch retained fills newest-first, using opaque cursors across inclusive time ranges. */
66
29
  export const getFills = async (
67
30
  { client, apiUrl }: HyperliquidContext,
68
31
  params: GetFillsParams,
69
32
  options?: SDKRequestOptions
70
33
  ): Promise<FillsResponse> => {
34
+ const limit = getHistoryLimit(params.limit)
71
35
  const registry = getMarketRegistry(client, PROVIDER_KEY)
72
36
  await registry.sync()
73
37
  const infoOpts = hlInfoOptions(client, options)
74
38
 
75
- const limit = Math.min(
76
- params.limit ?? DEFAULT_HISTORY_LIMIT,
77
- MAX_HISTORY_LIMIT
78
- )
79
-
80
- const useByTime =
81
- params.startTime !== undefined || params.endTime !== undefined
82
-
83
- const allFills = useByTime
84
- ? await infoRequest<HlUserFillsByTime>(
85
- apiUrl,
86
- {
87
- type: 'userFillsByTime',
88
- user: params.address,
89
- startTime: params.startTime ?? 0,
90
- ...(params.endTime !== undefined ? { endTime: params.endTime } : {}),
91
- },
92
- infoOpts
93
- )
94
- : await infoRequest<HlUserFills>(
39
+ let useRecent =
40
+ params.cursor === undefined &&
41
+ params.startTime === undefined &&
42
+ params.endTime === undefined
43
+ const page = await historyPage(
44
+ params,
45
+ limit,
46
+ async (startTime, endTime) => {
47
+ const fills = await infoRequest<HlUserFills>(
95
48
  apiUrl,
96
- { type: 'userFills', user: params.address },
49
+ useRecent
50
+ ? { type: 'userFills', user: params.address }
51
+ : {
52
+ type: 'userFillsByTime',
53
+ user: params.address,
54
+ startTime,
55
+ endTime,
56
+ },
97
57
  infoOpts
98
58
  )
99
-
100
- const sorted = [...allFills].sort(compareFillsDesc)
101
-
102
- const filtered =
103
- params.cursor === undefined
104
- ? sorted
105
- : sorted.filter(
106
- (f) => compareFillsDesc(f, decodeCursor(params.cursor!)) > 0
107
- )
108
-
109
- const hasMore = filtered.length > limit
110
- const page = filtered.slice(0, limit)
111
- // `get`, not `require`: a coin the backend market list does not hold drops
112
- // only its own row instead of rejecting the whole page. The registry warns
113
- // once per unresolved id. A delisted market still resolves, so its rows stay.
114
- const items = page.flatMap((f): Fill[] => {
115
- if (assetIsOutcome(f.coin)) {
116
- return []
117
- }
118
- const market = registry.get(f.coin)
119
- return market === undefined ? [] : [mapFill(f, market)]
120
- })
121
- const lastPageFill: HlUserFill | undefined = page[page.length - 1]
122
-
123
- return {
124
- provider: PROVIDER_KEY,
125
- items,
126
- pagination: {
127
- limit,
128
- hasMore,
129
- cursor: lastPageFill ? encodeCursor(lastPageFill) : undefined,
59
+ useRecent = false
60
+ if (fills.length >= 2000) {
61
+ return { items: [], saturated: true }
62
+ }
63
+ const items = fills.flatMap((fill): Fill[] => {
64
+ if (assetIsOutcome(fill.coin)) {
65
+ return []
66
+ }
67
+ const market = registry.get(fill.coin)
68
+ return market === undefined ? [] : [mapFill(fill, market)]
69
+ })
70
+ return { items, saturated: false }
130
71
  },
131
- }
72
+ (fill) => ({
73
+ time: new Date(fill.createdAt).getTime(),
74
+ key: Number(fill.id),
75
+ }),
76
+ options?.signal
77
+ )
78
+ return { provider: PROVIDER_KEY, ...page }
132
79
  }
@@ -1,11 +1,12 @@
1
- import type { SDKRequestOptions } from '@lifi/perps-sdk'
1
+ import { PerpsError, type SDKRequestOptions } from '@lifi/perps-sdk'
2
2
  import {
3
3
  MarginMode,
4
4
  type MarketRef,
5
5
  type MarketSettings,
6
+ PerpsErrorCode,
6
7
  } from '@lifi/perps-types'
7
8
  import type { Address } from 'viem'
8
- import { SPOT_MARKET_ID } from '../constants.js'
9
+ import { PROVIDER_KEY, SPOT_MARKET_ID } from '../constants.js'
9
10
  import type { HyperliquidContext } from '../context.js'
10
11
  import { fetchActiveAssetData } from './activeAssetData.js'
11
12
 
@@ -22,17 +23,23 @@ export interface GetMarketSettingsParams {
22
23
  /**
23
24
  * The user's venue-stored margin mode and leverage for one perps market,
24
25
  * read from `activeAssetData` — present whether or not a position is open.
25
- * @throws {PerpsError} On Hyperliquid REST error, network, or parsing failures.
26
+ * @throws {PerpsError} `ValidationError` for a spot market; `SDKError` when
27
+ * the response carries no positive leverage; any other `PerpsError` on
28
+ * Hyperliquid REST error, network, or parsing failures.
26
29
  * @public
27
30
  */
28
31
  export const getMarketSettings = async (
29
32
  context: HyperliquidContext,
30
33
  params: GetMarketSettingsParams,
31
34
  options?: SDKRequestOptions
32
- ): Promise<MarketSettings | undefined> => {
33
- // Spot markets carry no leverage state on the venue.
35
+ ): Promise<MarketSettings> => {
34
36
  if (params.market.categoryId === SPOT_MARKET_ID) {
35
- return undefined
37
+ const error = new PerpsError(
38
+ PerpsErrorCode.ValidationError,
39
+ `Hyperliquid market '${params.market.marketId}' is a spot market and carries no leverage setting`
40
+ )
41
+ error.tool = PROVIDER_KEY
42
+ throw error
36
43
  }
37
44
  const data = await fetchActiveAssetData(
38
45
  context,
@@ -41,8 +48,13 @@ export const getMarketSettings = async (
41
48
  options
42
49
  )
43
50
  const leverage = data.leverage
44
- if (!leverage) {
45
- return undefined
51
+ if (!leverage || !(leverage.value > 0)) {
52
+ const error = new PerpsError(
53
+ PerpsErrorCode.SDKError,
54
+ `Hyperliquid returned no leverage for market '${params.market.marketId}'`
55
+ )
56
+ error.tool = PROVIDER_KEY
57
+ throw error
46
58
  }
47
59
  return {
48
60
  marginMode:
@@ -0,0 +1,169 @@
1
+ import { PerpsError } from '@lifi/perps-sdk'
2
+ import { PerpsErrorCode } from '@lifi/perps-types'
3
+ import { DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT } from '../constants.js'
4
+
5
+ type HistoryKey = string | number
6
+ interface HistoryBoundary {
7
+ time: number
8
+ key: HistoryKey
9
+ startTime: number
10
+ endTime: number
11
+ }
12
+
13
+ const compareKeys = (a: HistoryKey, b: HistoryKey): number =>
14
+ typeof a === 'number' && typeof b === 'number'
15
+ ? b - a
16
+ : a < b
17
+ ? 1
18
+ : a > b
19
+ ? -1
20
+ : 0
21
+
22
+ export const getHistoryLimit = (limit = DEFAULT_HISTORY_LIMIT): number => {
23
+ if (!Number.isSafeInteger(limit) || limit < 1) {
24
+ throw new PerpsError(
25
+ PerpsErrorCode.ValidationError,
26
+ 'Hyperliquid history limit must be a positive safe integer'
27
+ )
28
+ }
29
+ return Math.min(limit, MAX_HISTORY_LIMIT)
30
+ }
31
+
32
+ const decodeCursor = (cursor: string): HistoryBoundary => {
33
+ let value: unknown
34
+ try {
35
+ value = JSON.parse(cursor)
36
+ } catch {
37
+ throw new PerpsError(
38
+ PerpsErrorCode.ValidationError,
39
+ 'Invalid Hyperliquid history cursor'
40
+ )
41
+ }
42
+ if (
43
+ !Array.isArray(value) ||
44
+ value.length !== 4 ||
45
+ !Number.isSafeInteger(value[0]) ||
46
+ (typeof value[1] !== 'string' && typeof value[1] !== 'number') ||
47
+ !Number.isSafeInteger(value[2]) ||
48
+ value[2] > value[0] ||
49
+ !Number.isSafeInteger(value[3]) ||
50
+ value[3] < value[0]
51
+ ) {
52
+ throw new PerpsError(
53
+ PerpsErrorCode.ValidationError,
54
+ 'Invalid Hyperliquid history cursor'
55
+ )
56
+ }
57
+ return {
58
+ time: value[0],
59
+ key: value[1],
60
+ startTime: value[2],
61
+ endTime: value[3],
62
+ }
63
+ }
64
+
65
+ export async function historyPage<T>(
66
+ params: { startTime?: number; endTime?: number; cursor?: string },
67
+ limit: number,
68
+ fetchWindow: (
69
+ startTime: number,
70
+ endTime: number,
71
+ snapshotEnd: number
72
+ ) => Promise<{ items: T[]; saturated: boolean }>,
73
+ identify: (item: T) => { time: number; key: HistoryKey },
74
+ signal?: AbortSignal
75
+ ): Promise<{
76
+ items: T[]
77
+ pagination: { limit: number; hasMore: boolean; cursor?: string }
78
+ }> {
79
+ const boundary =
80
+ params.cursor === undefined ? undefined : decodeCursor(params.cursor)
81
+ const lower = params.startTime ?? 0
82
+ const snapshotEnd = boundary?.endTime ?? params.endTime ?? Date.now()
83
+ const upper = Math.min(snapshotEnd, boundary?.time ?? Infinity)
84
+ const windows: { startTime: number; endTime: number }[] = []
85
+ if (boundary && boundary.startTime > lower) {
86
+ windows.push({ startTime: lower, endTime: boundary.startTime - 1 })
87
+ }
88
+ windows.push({
89
+ startTime: Math.max(lower, boundary?.startTime ?? lower),
90
+ endTime: upper,
91
+ })
92
+ const items: T[] = []
93
+ let last: HistoryBoundary | undefined
94
+ while (windows.length > 0) {
95
+ signal?.throwIfAborted()
96
+ const window = windows.pop()!
97
+ if (window.startTime > window.endTime) {
98
+ continue
99
+ }
100
+ const result = await fetchWindow(
101
+ window.startTime,
102
+ window.endTime,
103
+ snapshotEnd
104
+ )
105
+ signal?.throwIfAborted()
106
+ if (result.saturated) {
107
+ if (window.startTime === window.endTime) {
108
+ throw new PerpsError(
109
+ PerpsErrorCode.ThirdPartyError,
110
+ 'Hyperliquid history saturates a single millisecond; its time-only API cannot paginate this timestamp safely'
111
+ )
112
+ }
113
+ // Time APIs truncate from the start, so a capped response cannot supply a newest page.
114
+ const midpoint =
115
+ window.startTime + Math.floor((window.endTime - window.startTime) / 2)
116
+ windows.push({ startTime: window.startTime, endTime: midpoint })
117
+ windows.push({ startTime: midpoint + 1, endTime: window.endTime })
118
+ continue
119
+ }
120
+ const sorted = result.items.sort((a, b) => {
121
+ const left = identify(a)
122
+ const right = identify(b)
123
+ return right.time - left.time || compareKeys(left.key, right.key)
124
+ })
125
+ for (const item of sorted) {
126
+ const identity = identify(item)
127
+ if (identity.time < window.startTime || identity.time > window.endTime) {
128
+ continue
129
+ }
130
+ if (
131
+ boundary &&
132
+ (identity.time > boundary.time ||
133
+ (identity.time === boundary.time &&
134
+ compareKeys(identity.key, boundary.key) <= 0))
135
+ ) {
136
+ continue
137
+ }
138
+ if (items.length === limit) {
139
+ return {
140
+ items,
141
+ pagination: {
142
+ limit,
143
+ hasMore: true,
144
+ cursor:
145
+ last &&
146
+ JSON.stringify([
147
+ last.time,
148
+ last.key,
149
+ last.startTime,
150
+ last.endTime,
151
+ ]),
152
+ },
153
+ }
154
+ }
155
+ items.push(item)
156
+ last = { ...identity, startTime: window.startTime, endTime: snapshotEnd }
157
+ }
158
+ }
159
+ return {
160
+ items,
161
+ pagination: {
162
+ limit,
163
+ hasMore: false,
164
+ cursor:
165
+ last &&
166
+ JSON.stringify([last.time, last.key, last.startTime, last.endTime]),
167
+ },
168
+ }
169
+ }
@@ -130,6 +130,38 @@ export type HlLiquidationDelta = {
130
130
  liquidatedPositions?: HlLiquidatedPosition[]
131
131
  }
132
132
 
133
+ /**
134
+ * Hyperliquid `cStakingTransfer` ledger delta. Moves `token` between the
135
+ * account's spot balance and its staking balance; `isDeposit` is `true` for a
136
+ * move into staking. `amount` is unsigned. A completed unstake row carries the
137
+ * zero hash.
138
+ * @public
139
+ */
140
+ export type HlCStakingTransferDelta = {
141
+ type: 'cStakingTransfer'
142
+ token: string
143
+ amount: string
144
+ isDeposit: boolean
145
+ }
146
+
147
+ /** Borrow-lend market action named by a `borrowLend` ledger delta. @public */
148
+ export type HlBorrowLendOperation = 'supply' | 'withdraw' | 'borrow' | 'repay'
149
+
150
+ /**
151
+ * Hyperliquid `borrowLend` ledger delta. Moves `token` between the account's
152
+ * spot balance and the borrow-lend market; `operation` gives the direction and
153
+ * `amount` is unsigned. `interestAmount` is the accrued interest that
154
+ * `amount` includes on a `withdraw` or `repay`, and `"0.0"` otherwise.
155
+ * @public
156
+ */
157
+ export type HlBorrowLendDelta = {
158
+ type: 'borrowLend'
159
+ token: string
160
+ operation: HlBorrowLendOperation
161
+ amount: string
162
+ interestAmount: string
163
+ }
164
+
133
165
  /**
134
166
  * Union of known Hyperliquid non-funding ledger deltas plus an open fallback
135
167
  * for endpoint variants the provider does not map.
@@ -143,6 +175,8 @@ export type HlLedgerDelta =
143
175
  | HlDepositDelta
144
176
  | HlWithdrawDelta
145
177
  | HlLiquidationDelta
178
+ | HlCStakingTransferDelta
179
+ | HlBorrowLendDelta
146
180
  | {
147
181
  type: string
148
182
  usdc?: string
@@ -202,6 +236,33 @@ export const isLiquidationDelta = (
202
236
  delta: HlLedgerDelta
203
237
  ): delta is HlLiquidationDelta => delta.type === 'liquidation'
204
238
 
239
+ /**
240
+ * Type guard for `HlCStakingTransferDelta`. `isDeposit` is part of the guard:
241
+ * a row without a boolean `isDeposit` names no direction for the movement.
242
+ * @public
243
+ */
244
+ export const isCStakingTransferDelta = (
245
+ delta: HlLedgerDelta
246
+ ): delta is HlCStakingTransferDelta =>
247
+ delta.type === 'cStakingTransfer' &&
248
+ 'isDeposit' in delta &&
249
+ typeof delta.isDeposit === 'boolean'
250
+
251
+ const HL_BORROW_LEND_OPERATIONS: ReadonlySet<unknown> =
252
+ new Set<HlBorrowLendOperation>(['supply', 'withdraw', 'borrow', 'repay'])
253
+
254
+ /**
255
+ * Type guard for `HlBorrowLendDelta`. The operation is part of the guard: an
256
+ * unknown operation names no direction for the movement.
257
+ * @public
258
+ */
259
+ export const isBorrowLendDelta = (
260
+ delta: HlLedgerDelta
261
+ ): delta is HlBorrowLendDelta =>
262
+ delta.type === 'borrowLend' &&
263
+ 'operation' in delta &&
264
+ HL_BORROW_LEND_OPERATIONS.has(delta.operation)
265
+
205
266
  /**
206
267
  * One timestamped non-funding ledger update. `time` is milliseconds since
207
268
  * epoch and `hash` is the upstream transaction identifier.
@@ -17,13 +17,17 @@ import type {
17
17
  } from '@lifi/perps-types'
18
18
  import { ActivityType, PerpsErrorCode } from '@lifi/perps-types'
19
19
  import Big from 'big.js'
20
+ import { zeroHash } from 'viem'
20
21
  import type {
22
+ HlBorrowLendOperation,
21
23
  HlFundingUpdate,
22
24
  HlLedgerUpdate,
23
25
  HlUserFill,
24
26
  } from '../types/index.js'
25
27
  import {
28
+ isBorrowLendDelta,
26
29
  isCollateralTransferDelta,
30
+ isCStakingTransferDelta,
27
31
  isDepositDelta,
28
32
  isLiquidationDelta,
29
33
  isSendAssetDelta,
@@ -47,6 +51,17 @@ const HL_NATIVE_TOKEN_SYMBOL = 'HYPE'
47
51
  // Hyperliquid reserves spot token index 0 for USDC.
48
52
  const HL_COLLATERAL_ASSET_ID = '0'
49
53
 
54
+ /**
55
+ * Direction relative to the account's spot balance, the same frame as an
56
+ * outbound `vaultDeposit`.
57
+ */
58
+ const BORROW_LEND_DIRECTION: Record<HlBorrowLendOperation, 'IN' | 'OUT'> = {
59
+ supply: 'OUT',
60
+ withdraw: 'IN',
61
+ borrow: 'IN',
62
+ repay: 'OUT',
63
+ }
64
+
50
65
  /**
51
66
  * A ledger row moves a spot asset — a perp contract cannot be sent, received
52
67
  * or withdrawn — so the delta's token symbol resolves inside the spot asset
@@ -69,6 +84,7 @@ const resolveLedgerAsset = (symbol: string, registry: AssetRegistry): Asset => {
69
84
 
70
85
  /**
71
86
  * Map supported ledger entries; exclude same-account moves and unsupported types.
87
+ * Staking and borrow-lend moves map with the queried account as counterparty.
72
88
  * Missing assets throw; unresolved liquidation markets omit only those positions.
73
89
  * @public
74
90
  */
@@ -143,6 +159,46 @@ export const mapLedgerEntry = (
143
159
  } satisfies TransferActivity
144
160
  }
145
161
 
162
+ if (isCStakingTransferDelta(delta) || isBorrowLendDelta(delta)) {
163
+ // A completed unstake is a system event with the zero hash and no venue
164
+ // transaction, so the id is synthesized and no explorer link applies.
165
+ const hasTransaction = entry.hash !== zeroHash
166
+ const direction = isCStakingTransferDelta(delta)
167
+ ? delta.isDeposit
168
+ ? 'OUT'
169
+ : 'IN'
170
+ : BORROW_LEND_DIRECTION[delta.operation]
171
+ const action = isCStakingTransferDelta(delta) ? direction : delta.operation
172
+ return {
173
+ ...base,
174
+ ...(hasTransaction
175
+ ? {}
176
+ : {
177
+ id: `${delta.type}:${delta.token}:${action}:${delta.amount}:${base.timestamp}`,
178
+ }),
179
+ type: ActivityType.TRANSFER,
180
+ direction,
181
+ counterpartyAddress: queriedAddress.toLowerCase(),
182
+ asset: resolveLedgerAsset(delta.token, assetRegistry),
183
+ amount: delta.amount,
184
+ meta: isCStakingTransferDelta(delta)
185
+ ? { transferType: delta.type }
186
+ : {
187
+ transferType: delta.type,
188
+ operation: delta.operation,
189
+ interestAmount: delta.interestAmount,
190
+ },
191
+ ...(hasTransaction
192
+ ? {
193
+ explorerLink: explorerTxUrl(
194
+ ExplorerChainId.HYPERLIQUID,
195
+ entry.hash
196
+ ),
197
+ }
198
+ : {}),
199
+ } satisfies TransferActivity
200
+ }
201
+
146
202
  if (isVaultTransferDelta(delta)) {
147
203
  const queried = queriedAddress.toLowerCase()
148
204
  const vault = delta.vault.toLowerCase()
@@ -17,7 +17,8 @@ import { toWireBig } from './decimal.js'
17
17
  *
18
18
  * @param quoteAssetId - `Asset.id` the perps-route row is keyed by.
19
19
  * @param withdrawalFee - Flat venue fee in quote-asset units, set on every
20
- * quote-asset row. Absent leaves every row without a fee.
20
+ * quote-asset row. Hyperliquid deducts it from the requested amount. Absent
21
+ * leaves every row without a fee.
21
22
  * @public
22
23
  */
23
24
  export const hyperliquidWithdrawableBalances = (
@@ -32,8 +33,12 @@ export const hyperliquidWithdrawableBalances = (
32
33
  withdrawalFee === undefined
33
34
  ? undefined
34
35
  : toWireBig(withdrawalFee, 'providers.withdrawalFeeUsd').toFixed()
35
- const feeFor = (assetId: string) =>
36
- fee === undefined || assetId !== quoteAssetId ? {} : { withdrawalFee: fee }
36
+ const feeFor = (
37
+ assetId: string
38
+ ): Pick<ProviderWithdrawableBalance, 'withdrawalFee' | 'isFeeDeducted'> =>
39
+ fee === undefined || assetId !== quoteAssetId
40
+ ? {}
41
+ : { withdrawalFee: fee, isFeeDeducted: true }
37
42
 
38
43
  if (isUnifiedAbstraction(abstraction)) {
39
44
  for (const balance of spotState.balances) {