@ledgerhq/coin-hypercore 0.2.0 → 0.4.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 (53) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/lib/api/getBalance.d.ts +3 -0
  3. package/lib/api/getBalance.d.ts.map +1 -0
  4. package/lib/api/getBalance.js +21 -0
  5. package/lib/api/getBalance.js.map +1 -0
  6. package/lib/api/index.d.ts +1 -1
  7. package/lib/api/index.d.ts.map +1 -1
  8. package/lib/api/index.js +21 -20
  9. package/lib/api/index.js.map +1 -1
  10. package/lib/api/listOperations.d.ts +3 -0
  11. package/lib/api/listOperations.d.ts.map +1 -0
  12. package/lib/api/listOperations.js +64 -0
  13. package/lib/api/listOperations.js.map +1 -0
  14. package/lib/network/yield.d.ts +38 -0
  15. package/lib/network/yield.d.ts.map +1 -0
  16. package/lib/network/yield.js +36 -0
  17. package/lib/network/yield.js.map +1 -0
  18. package/lib/utils/index.d.ts +2 -0
  19. package/lib/utils/index.d.ts.map +1 -0
  20. package/lib/utils/index.js +11 -0
  21. package/lib/utils/index.js.map +1 -0
  22. package/lib-es/api/getBalance.d.ts +3 -0
  23. package/lib-es/api/getBalance.d.ts.map +1 -0
  24. package/lib-es/api/getBalance.js +18 -0
  25. package/lib-es/api/getBalance.js.map +1 -0
  26. package/lib-es/api/index.d.ts +1 -1
  27. package/lib-es/api/index.d.ts.map +1 -1
  28. package/lib-es/api/index.js +6 -5
  29. package/lib-es/api/index.js.map +1 -1
  30. package/lib-es/api/listOperations.d.ts +3 -0
  31. package/lib-es/api/listOperations.d.ts.map +1 -0
  32. package/lib-es/api/listOperations.js +61 -0
  33. package/lib-es/api/listOperations.js.map +1 -0
  34. package/lib-es/network/yield.d.ts +38 -0
  35. package/lib-es/network/yield.d.ts.map +1 -0
  36. package/lib-es/network/yield.js +28 -0
  37. package/lib-es/network/yield.js.map +1 -0
  38. package/lib-es/utils/index.d.ts +2 -0
  39. package/lib-es/utils/index.d.ts.map +1 -0
  40. package/lib-es/utils/index.js +8 -0
  41. package/lib-es/utils/index.js.map +1 -0
  42. package/package.json +3 -3
  43. package/src/api/getBalance.test.ts +66 -0
  44. package/src/api/getBalance.ts +21 -0
  45. package/src/api/index.integ.test.ts +55 -3
  46. package/src/api/index.test.ts +29 -0
  47. package/src/api/index.ts +6 -7
  48. package/src/api/listOperations.test.ts +163 -0
  49. package/src/api/listOperations.ts +84 -0
  50. package/src/network/yield.test.ts +72 -0
  51. package/src/network/yield.ts +72 -0
  52. package/src/utils/index.test.ts +20 -0
  53. package/src/utils/index.ts +9 -0
@@ -0,0 +1,66 @@
1
+ // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import network from '@ledgerhq/live-network'
5
+ import coinConfig from '../config'
6
+ import { getBalance } from './getBalance'
7
+
8
+ jest.mock('@ledgerhq/live-network')
9
+
10
+ const mockNetwork = jest.mocked(network)
11
+
12
+ beforeAll(() => {
13
+ coinConfig.setCoinConfig(() => ({
14
+ node: 'https://perps.live.ledger.com/proxy/perps',
15
+ status: { type: 'active' },
16
+ }))
17
+ })
18
+
19
+ afterEach(() => {
20
+ mockNetwork.mockClear()
21
+ })
22
+
23
+ describe('getBalance', () => {
24
+ it('maps the USDC collateral to a single native balance', async () => {
25
+ mockNetwork.mockResolvedValueOnce({
26
+ status: 200,
27
+ data: {
28
+ providerId: 'hyperliquid',
29
+ collateral: { symbol: 'USDC', network: 'hyperliquid' },
30
+ accountValue: 557.13644,
31
+ usedMargin: 20,
32
+ availableBalance: 537.13644,
33
+ unrealizedPnl: 0,
34
+ },
35
+ })
36
+
37
+ const balances = await getBalance('0xabc')
38
+
39
+ expect(balances).toEqual([
40
+ {
41
+ value: 557_136_440n,
42
+ locked: 20_000_000n,
43
+ asset: { type: 'native' },
44
+ },
45
+ ])
46
+ })
47
+
48
+ it('handles a zero balance without throwing', async () => {
49
+ mockNetwork.mockResolvedValueOnce({
50
+ status: 200,
51
+ data: {
52
+ providerId: 'hyperliquid',
53
+ collateral: { symbol: 'USDC', network: 'hyperliquid' },
54
+ accountValue: 0,
55
+ usedMargin: 0,
56
+ availableBalance: 0,
57
+ unrealizedPnl: 0,
58
+ },
59
+ })
60
+
61
+ const balances = await getBalance('0xabc')
62
+
63
+ expect(balances[0].value).toBe(0n)
64
+ expect(balances[0].locked).toBe(0n)
65
+ })
66
+ })
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { Balance } from '@ledgerhq/coin-module-framework/api/types'
5
+ import { USDC_DECIMALS, fetchBalance } from '../network/yield'
6
+ import { scaleToBigInt } from '../utils'
7
+
8
+ // The proxy only exposes the USDC collateral, surfaced as the account's native balance:
9
+ // `value` is the total account value, `locked` the margin used by open positions.
10
+ export async function getBalance(address: string): Promise<Balance[]> {
11
+ const { collateral, accountValue, usedMargin } = await fetchBalance(address)
12
+ const magnitude = collateral.decimals ?? USDC_DECIMALS
13
+
14
+ return [
15
+ {
16
+ value: scaleToBigInt(accountValue, magnitude),
17
+ locked: scaleToBigInt(usedMargin, magnitude),
18
+ asset: { type: 'native' },
19
+ },
20
+ ]
21
+ }
@@ -3,6 +3,10 @@
3
3
 
4
4
  import { createApi } from '.'
5
5
 
6
+ const NODE = 'https://perps.live.ledger.com/proxy/perps'
7
+ // A real account with USDC collateral and settled fund/withdraw history, for read-only assertions.
8
+ const ADDRESS = '0x90D5b3f3FaA3cd61fBd78bF1CE3DdB2100F4BFb2'
9
+
6
10
  describe('Hypercore Api (Alpaca stub)', () => {
7
11
  const api = createApi({ node: 'https://api.hyperliquid.xyz' })
8
12
 
@@ -11,8 +15,6 @@ describe('Hypercore Api (Alpaca stub)', () => {
11
15
  'getBlockInfo',
12
16
  'getBlock',
13
17
  'getValidators',
14
- 'getBalance',
15
- 'listOperations',
16
18
  'getStakes',
17
19
  'getRewards',
18
20
  'craftTransaction',
@@ -25,6 +27,56 @@ describe('Hypercore Api (Alpaca stub)', () => {
25
27
  'validateAddress',
26
28
  'craftTransactionData',
27
29
  ])('%s throws "not supported"', (name) => {
28
- expect(() => (api[name] as (...args: unknown[]) => unknown)()).toThrow('not supported')
30
+ expect(api[name]).toThrow('not supported')
31
+ })
32
+ })
33
+
34
+ describe('Hypercore Api (Alpaca)', () => {
35
+ const api = createApi({ node: NODE })
36
+
37
+ describe('getBalance', () => {
38
+ it('returns the USDC-denominated account balance', async () => {
39
+ const balances = await api.getBalance(ADDRESS)
40
+
41
+ expect(balances).toHaveLength(1)
42
+ const [balance] = balances
43
+ expect(balance.asset.type).toBe('native')
44
+ expect(typeof balance.value).toBe('bigint')
45
+ expect(balance.value).toBeGreaterThanOrEqual(0n)
46
+ expect(balance.locked ?? 0n).toBeGreaterThanOrEqual(0n)
47
+ })
48
+ })
49
+
50
+ describe('listOperations', () => {
51
+ it('returns settled deposit/withdraw operations across all pages', async () => {
52
+ // Small limit to force several pages and exercise cursor chaining end-to-end.
53
+ const first = await api.listOperations(ADDRESS, { minHeight: 0, limit: 20 })
54
+ const all = [...first.items]
55
+ let cursor = first.next
56
+ while (cursor !== undefined && all.length < 10_000) {
57
+ const page = await api.listOperations(ADDRESS, { minHeight: 0, cursor, limit: 20 })
58
+ all.push(...page.items)
59
+ cursor = page.next
60
+ }
61
+
62
+ expect(all.length).toBeGreaterThan(0)
63
+ for (const op of all) {
64
+ expect(['IN', 'OUT']).toContain(op.type)
65
+ expect(op.senders).toContain(ADDRESS)
66
+ expect(op.recipients).toContain(ADDRESS)
67
+ expect(op.value).toBeGreaterThan(0n)
68
+ expect(op.tx.date).toBeInstanceOf(Date)
69
+ expect(op.tx.fees).toBe(0n)
70
+ }
71
+ // This account has both deposits (IN) and withdrawals (OUT).
72
+ expect(all.some((op) => op.type === 'IN')).toBe(true)
73
+ expect(all.some((op) => op.type === 'OUT')).toBe(true)
74
+ })
75
+
76
+ it('rejects a non-zero minHeight', async () => {
77
+ await expect(api.listOperations(ADDRESS, { minHeight: 1 })).rejects.toThrow(
78
+ 'minHeight is not supported'
79
+ )
80
+ })
29
81
  })
30
82
  })
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { createApi } from '.'
5
+
6
+ describe('createApi', () => {
7
+ it('returns all API methods', () => {
8
+ expect(createApi({ node: 'http://localhost' })).toEqual({
9
+ broadcast: expect.any(Function),
10
+ call: expect.any(Function),
11
+ combine: expect.any(Function),
12
+ craftRawTransaction: expect.any(Function),
13
+ craftTransaction: expect.any(Function),
14
+ craftTransactionData: expect.any(Function),
15
+ estimateFees: expect.any(Function),
16
+ getBalance: expect.any(Function),
17
+ getBlock: expect.any(Function),
18
+ getBlockInfo: expect.any(Function),
19
+ getNextSequence: expect.any(Function),
20
+ getRewards: expect.any(Function),
21
+ getStakes: expect.any(Function),
22
+ getValidators: expect.any(Function),
23
+ lastBlock: expect.any(Function),
24
+ listOperations: expect.any(Function),
25
+ validateAddress: expect.any(Function),
26
+ validateIntent: expect.any(Function),
27
+ })
28
+ })
29
+ })
package/src/api/index.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { CoinModuleApi } from '@ledgerhq/coin-module-framework/api/index'
4
+ import { type CoinModuleApi, notSupported } from '@ledgerhq/coin-module-framework/api/index'
5
5
  import coinConfig, { type HypercoreConfig } from '../config'
6
-
7
- const notSupported = (name: string) => (): never => {
8
- throw new Error(`${name} is not supported`)
9
- }
6
+ import { getBalance } from './getBalance'
7
+ import { listOperations } from './listOperations'
10
8
 
11
9
  export function createApi(config: HypercoreConfig): CoinModuleApi {
12
10
  coinConfig.setCoinConfig(() => ({ ...config, status: { type: 'active' } }))
@@ -15,9 +13,10 @@ export function createApi(config: HypercoreConfig): CoinModuleApi {
15
13
  lastBlock: notSupported('lastBlock'),
16
14
  getBlockInfo: notSupported('getBlockInfo'),
17
15
  getBlock: notSupported('getBlock'),
16
+ call: notSupported('call'),
18
17
  getValidators: notSupported('getValidators'),
19
- getBalance: notSupported('getBalance'),
20
- listOperations: notSupported('listOperations'),
18
+ getBalance,
19
+ listOperations,
21
20
  getStakes: notSupported('getStakes'),
22
21
  getRewards: notSupported('getRewards'),
23
22
  craftTransaction: notSupported('craftTransaction'),
@@ -0,0 +1,163 @@
1
+ // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import network from '@ledgerhq/live-network'
5
+ import coinConfig from '../config'
6
+ import { listOperations } from './listOperations'
7
+
8
+ jest.mock('@ledgerhq/live-network')
9
+
10
+ const mockNetwork = jest.mocked(network)
11
+
12
+ const ADDRESS = '0xabc'
13
+
14
+ beforeAll(() => {
15
+ coinConfig.setCoinConfig(() => ({
16
+ node: 'https://perps.live.ledger.com/proxy/perps',
17
+ status: { type: 'active' },
18
+ }))
19
+ })
20
+
21
+ afterEach(() => {
22
+ mockNetwork.mockClear()
23
+ })
24
+
25
+ function action(overrides: Record<string, unknown>) {
26
+ return {
27
+ id: 'id-1',
28
+ action: 'fund',
29
+ status: 'SUCCESS',
30
+ summary: { amount: '10.5' },
31
+ createdAt: '2026-01-01T00:00:00.000Z',
32
+ completedAt: '2026-01-01T00:01:00.000Z',
33
+ ...overrides,
34
+ }
35
+ }
36
+
37
+ describe('listOperations', () => {
38
+ it('keeps only fund/withdraw actions and maps them to IN/OUT', async () => {
39
+ mockNetwork.mockResolvedValueOnce({
40
+ status: 200,
41
+ data: {
42
+ total: 3,
43
+ offset: 0,
44
+ limit: 100,
45
+ items: [
46
+ action({ id: 'dep', action: 'fund', summary: { amount: '10.5' } }),
47
+ action({ id: 'wit', action: 'withdraw', summary: { amount: '4' } }),
48
+ action({ id: 'opn', action: 'open', summary: null }),
49
+ ],
50
+ },
51
+ })
52
+
53
+ const { items, next } = await listOperations(ADDRESS, { minHeight: 0 })
54
+
55
+ expect(items).toHaveLength(2)
56
+ expect(items[0]).toMatchObject({
57
+ id: `${ADDRESS}-dep`,
58
+ type: 'IN',
59
+ value: 10_500_000n,
60
+ senders: [ADDRESS],
61
+ recipients: [ADDRESS],
62
+ asset: { type: 'native' },
63
+ })
64
+ expect(items[0].tx).toMatchObject({ hash: 'dep', fees: 0n, failed: false })
65
+ expect(items[1]).toMatchObject({ id: `${ADDRESS}-wit`, type: 'OUT', value: 4_000_000n })
66
+ expect(next).toBeUndefined()
67
+ })
68
+
69
+ it('excludes drafts that never settled (no completedAt)', async () => {
70
+ mockNetwork.mockResolvedValueOnce({
71
+ status: 200,
72
+ data: {
73
+ total: 2,
74
+ offset: 0,
75
+ limit: 100,
76
+ items: [
77
+ action({ id: 'settled', completedAt: '2026-01-01T00:01:00.000Z' }),
78
+ action({ id: 'draft', status: 'CREATED', completedAt: null }),
79
+ ],
80
+ },
81
+ })
82
+
83
+ const { items } = await listOperations(ADDRESS, { minHeight: 0 })
84
+
85
+ expect(items).toHaveLength(1)
86
+ expect(items[0].id).toBe(`${ADDRESS}-settled`)
87
+ })
88
+
89
+ it('marks failed actions', async () => {
90
+ mockNetwork.mockResolvedValueOnce({
91
+ status: 200,
92
+ data: {
93
+ total: 1,
94
+ offset: 0,
95
+ limit: 100,
96
+ items: [action({ id: 'dep', status: 'FAILED' })],
97
+ },
98
+ })
99
+
100
+ const { items } = await listOperations(ADDRESS, { minHeight: 0 })
101
+
102
+ expect(items[0].tx.failed).toBe(true)
103
+ })
104
+
105
+ it('computes the next cursor from the raw page size and total', async () => {
106
+ mockNetwork.mockResolvedValueOnce({
107
+ status: 200,
108
+ data: {
109
+ total: 10,
110
+ offset: 0,
111
+ limit: 2,
112
+ items: [action({ id: 'a' }), action({ id: 'b', action: 'open', summary: null })],
113
+ },
114
+ })
115
+
116
+ const { items, next } = await listOperations(ADDRESS, { minHeight: 0, limit: 2 })
117
+
118
+ // Only one fund/withdraw kept, but the cursor advances by the two raw items consumed.
119
+ expect(items).toHaveLength(1)
120
+ expect(next).toBe('2')
121
+ expect(mockNetwork).toHaveBeenCalledWith(
122
+ expect.objectContaining({
123
+ params: { address: ADDRESS, offset: 0, limit: 2, providerId: 'hyperliquid' },
124
+ })
125
+ )
126
+ })
127
+
128
+ it('passes the cursor as offset on subsequent pages', async () => {
129
+ mockNetwork.mockResolvedValueOnce({
130
+ status: 200,
131
+ data: { total: 10, offset: 2, limit: 2, items: [] },
132
+ })
133
+
134
+ await listOperations(ADDRESS, { minHeight: 0, cursor: '2', limit: 2 })
135
+
136
+ expect(mockNetwork).toHaveBeenCalledWith(
137
+ expect.objectContaining({
138
+ params: { address: ADDRESS, offset: 2, limit: 2, providerId: 'hyperliquid' },
139
+ })
140
+ )
141
+ })
142
+
143
+ it('rejects a non-zero minHeight', async () => {
144
+ await expect(listOperations(ADDRESS, { minHeight: 5 })).rejects.toThrow(
145
+ 'minHeight is not supported'
146
+ )
147
+ expect(mockNetwork).not.toHaveBeenCalled()
148
+ })
149
+
150
+ it('rejects an ascending order', async () => {
151
+ await expect(listOperations(ADDRESS, { minHeight: 0, order: 'asc' })).rejects.toThrow(
152
+ 'ascending order is not supported'
153
+ )
154
+ expect(mockNetwork).not.toHaveBeenCalled()
155
+ })
156
+
157
+ it('rejects an invalid cursor', async () => {
158
+ await expect(listOperations(ADDRESS, { minHeight: 0, cursor: 'not-a-number' })).rejects.toThrow(
159
+ 'invalid cursor'
160
+ )
161
+ expect(mockNetwork).not.toHaveBeenCalled()
162
+ })
163
+ })
@@ -0,0 +1,84 @@
1
+ // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type {
5
+ ListOperationsOptions,
6
+ Operation,
7
+ Page,
8
+ } from '@ledgerhq/coin-module-framework/api/types'
9
+ import {
10
+ type ActionDto,
11
+ FUND_ACTION,
12
+ USDC_DECIMALS,
13
+ WITHDRAW_ACTION,
14
+ fetchActions,
15
+ } from '../network/yield'
16
+ import { scaleToBigInt } from '../utils'
17
+
18
+ const DEFAULT_LIMIT = 100
19
+
20
+ // Deposits/withdrawals that actually settled — drafts (status CREATED) have no completedAt.
21
+ function isSettledTransfer(action: ActionDto): boolean {
22
+ const isTransfer = action.action === FUND_ACTION || action.action === WITHDRAW_ACTION
23
+ return isTransfer && action.completedAt !== null
24
+ }
25
+
26
+ function toOperation(address: string, dto: ActionDto): Operation {
27
+ const isDeposit = dto.action === FUND_ACTION
28
+ const amount = dto.summary?.amount ?? '0'
29
+ const date = new Date(dto.completedAt ?? dto.createdAt)
30
+
31
+ return {
32
+ id: `${address}-${dto.id}`,
33
+ type: isDeposit ? 'IN' : 'OUT',
34
+ senders: [address],
35
+ recipients: [address],
36
+ value: scaleToBigInt(amount, USDC_DECIMALS),
37
+ asset: { type: 'native' },
38
+ tx: {
39
+ hash: dto.id,
40
+ // HyperCore actions have no block height on this API.
41
+ block: { height: 0, hash: '', time: date },
42
+ fees: 0n,
43
+ date,
44
+ failed: dto.status.toUpperCase() === 'FAILED',
45
+ },
46
+ }
47
+ }
48
+
49
+ // The proxy only supports offset pagination, so the cursor is the numeric offset.
50
+ function parseCursor(cursor: string | undefined): number {
51
+ if (cursor === undefined) return 0
52
+ const offset = Number(cursor)
53
+ if (!Number.isInteger(offset) || offset < 0) {
54
+ throw new Error(`listOperations received an invalid cursor: ${cursor}`)
55
+ }
56
+ return offset
57
+ }
58
+
59
+ export async function listOperations(
60
+ address: string,
61
+ { minHeight, cursor, limit, order }: ListOperationsOptions
62
+ ): Promise<Page<Operation>> {
63
+ // The proxy has no block height and always returns actions newest-first (descending).
64
+ if (minHeight > 0) {
65
+ throw new Error('listOperations with a minHeight is not supported')
66
+ }
67
+ if (order === 'asc') {
68
+ throw new Error('listOperations with an ascending order is not supported')
69
+ }
70
+
71
+ const offset = parseCursor(cursor)
72
+ const pageLimit = limit ?? DEFAULT_LIMIT
73
+ const page = await fetchActions(address, offset, pageLimit)
74
+
75
+ const items = page.items.filter(isSettledTransfer).map((action) => toOperation(address, action))
76
+
77
+ // Advance by raw items consumed, not the filtered count, so no action is skipped.
78
+ // The cursor is offset-based (volatile if new actions appear between calls); the proxy
79
+ // exposes no stable id/date-based pagination.
80
+ const nextOffset = offset + page.items.length
81
+ const next = nextOffset < page.total ? String(nextOffset) : undefined
82
+
83
+ return { items, next }
84
+ }
@@ -0,0 +1,72 @@
1
+ // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import network from '@ledgerhq/live-network'
5
+ import coinConfig from '../config'
6
+ import { fetchBalance, fetchActions } from './yield'
7
+
8
+ jest.mock('@ledgerhq/live-network')
9
+
10
+ const mockNetwork = jest.mocked(network)
11
+
12
+ const BASE_CONFIG = { node: 'https://perps.live.ledger.com/proxy/perps' }
13
+
14
+ beforeAll(() => {
15
+ coinConfig.setCoinConfig(() => ({ ...BASE_CONFIG, status: { type: 'active' } }))
16
+ })
17
+
18
+ afterEach(() => {
19
+ mockNetwork.mockClear()
20
+ })
21
+
22
+ const COLLATERAL = {
23
+ symbol: 'USDC',
24
+ name: 'USD Coin',
25
+ network: 'arbitrum',
26
+ decimals: 6,
27
+ address: null,
28
+ logoURI: null,
29
+ }
30
+
31
+ describe('fetchBalance', () => {
32
+ it('calls POST /v1/balances with correct args', async () => {
33
+ const dto = {
34
+ providerId: 'hyperliquid',
35
+ collateral: COLLATERAL,
36
+ accountValue: 100,
37
+ usedMargin: 20,
38
+ availableBalance: 80,
39
+ unrealizedPnl: 5,
40
+ }
41
+ mockNetwork.mockResolvedValueOnce({ status: 200, data: dto })
42
+
43
+ const result = await fetchBalance('0xabc')
44
+
45
+ expect(mockNetwork).toHaveBeenCalledWith(
46
+ expect.objectContaining({
47
+ method: 'POST',
48
+ url: 'https://perps.live.ledger.com/proxy/perps/v1/balances',
49
+ data: { providerId: 'hyperliquid', address: '0xabc' },
50
+ })
51
+ )
52
+ expect(result).toEqual(dto)
53
+ })
54
+ })
55
+
56
+ describe('fetchActions', () => {
57
+ it('calls GET /v1/actions with correct args', async () => {
58
+ const dto = { total: 0, offset: 10, limit: 100, items: [] }
59
+ mockNetwork.mockResolvedValueOnce({ status: 200, data: dto })
60
+
61
+ const result = await fetchActions('0xabc', 10, 100)
62
+
63
+ expect(mockNetwork).toHaveBeenCalledWith(
64
+ expect.objectContaining({
65
+ method: 'GET',
66
+ url: 'https://perps.live.ledger.com/proxy/perps/v1/actions',
67
+ params: { address: '0xabc', offset: 10, limit: 100, providerId: 'hyperliquid' },
68
+ })
69
+ )
70
+ expect(result).toEqual(dto)
71
+ })
72
+ })
@@ -0,0 +1,72 @@
1
+ // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import network from '@ledgerhq/live-network'
5
+ import coinConfig from '../config'
6
+
7
+ const PROVIDER_ID = 'hyperliquid'
8
+ const getBaseUrl = () => coinConfig.getCoinConfig().node
9
+
10
+ // The collateral is USDC and the proxy omits its decimals.
11
+ export const USDC_DECIMALS = 6
12
+ // A deposit is the `fund` action, a withdrawal the `withdraw` action.
13
+ export const FUND_ACTION = 'fund'
14
+ export const WITHDRAW_ACTION = 'withdraw'
15
+
16
+ export type TokenDto = {
17
+ symbol: string
18
+ network: string
19
+ // The proxy omits these fields for the perps collateral, so they are optional.
20
+ name?: string
21
+ decimals?: number
22
+ address?: string | null
23
+ logoURI?: string | null
24
+ }
25
+
26
+ export type YieldBalanceDto = {
27
+ providerId: string
28
+ collateral: TokenDto
29
+ accountValue: number
30
+ usedMargin: number
31
+ availableBalance: number
32
+ unrealizedPnl: number
33
+ }
34
+
35
+ export async function fetchBalance(address: string): Promise<YieldBalanceDto> {
36
+ const { data } = await network<YieldBalanceDto>({
37
+ method: 'POST',
38
+ url: `${getBaseUrl()}/v1/balances`,
39
+ data: { providerId: PROVIDER_ID, address },
40
+ })
41
+ return data
42
+ }
43
+
44
+ export type ActionDto = {
45
+ id: string
46
+ action: string
47
+ status: string
48
+ // `summary` is null for some action types.
49
+ summary: { amount: string } | null
50
+ createdAt: string
51
+ completedAt: string | null
52
+ }
53
+
54
+ export type ActionsPage = {
55
+ total: number
56
+ offset: number
57
+ limit: number
58
+ items: ActionDto[]
59
+ }
60
+
61
+ export async function fetchActions(
62
+ address: string,
63
+ offset: number,
64
+ limit: number
65
+ ): Promise<ActionsPage> {
66
+ const { data } = await network<ActionsPage>({
67
+ method: 'GET',
68
+ url: `${getBaseUrl()}/v1/actions`,
69
+ params: { address, offset, limit, providerId: PROVIDER_ID },
70
+ })
71
+ return data
72
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { scaleToBigInt } from '.'
5
+
6
+ describe('scaleToBigInt', () => {
7
+ it.each([
8
+ ['557.13644', 6, 557_136_440n],
9
+ [557.13644, 6, 557_136_440n],
10
+ ['0', 6, 0n],
11
+ [0, 6, 0n],
12
+ ['10', 6, 10_000_000n],
13
+ ['0.000001', 6, 1n],
14
+ // extra fractional digits are rounded (framework uses BigNumber.integerValue)
15
+ ['1.2345678', 6, 1_234_568n],
16
+ ['-3.5', 6, -3_500_000n],
17
+ ])('scales %s with %i decimals', (amount, magnitude, expected) => {
18
+ expect(scaleToBigInt(amount, magnitude)).toBe(expected)
19
+ })
20
+ })
@@ -0,0 +1,9 @@
1
+ // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { parseCurrencyUnit } from '@ledgerhq/coin-module-framework/currencies'
5
+
6
+ // Convert a decimal amount into its integer base-unit representation.
7
+ export function scaleToBigInt(amount: string | number, magnitude: number): bigint {
8
+ return BigInt(parseCurrencyUnit({ name: '', code: '', magnitude }, String(amount)).toFixed())
9
+ }