@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.
- package/CHANGELOG.md +21 -0
- package/lib/api/getBalance.d.ts +3 -0
- package/lib/api/getBalance.d.ts.map +1 -0
- package/lib/api/getBalance.js +21 -0
- package/lib/api/getBalance.js.map +1 -0
- package/lib/api/index.d.ts +1 -1
- package/lib/api/index.d.ts.map +1 -1
- package/lib/api/index.js +21 -20
- package/lib/api/index.js.map +1 -1
- package/lib/api/listOperations.d.ts +3 -0
- package/lib/api/listOperations.d.ts.map +1 -0
- package/lib/api/listOperations.js +64 -0
- package/lib/api/listOperations.js.map +1 -0
- package/lib/network/yield.d.ts +38 -0
- package/lib/network/yield.d.ts.map +1 -0
- package/lib/network/yield.js +36 -0
- package/lib/network/yield.js.map +1 -0
- package/lib/utils/index.d.ts +2 -0
- package/lib/utils/index.d.ts.map +1 -0
- package/lib/utils/index.js +11 -0
- package/lib/utils/index.js.map +1 -0
- package/lib-es/api/getBalance.d.ts +3 -0
- package/lib-es/api/getBalance.d.ts.map +1 -0
- package/lib-es/api/getBalance.js +18 -0
- package/lib-es/api/getBalance.js.map +1 -0
- package/lib-es/api/index.d.ts +1 -1
- package/lib-es/api/index.d.ts.map +1 -1
- package/lib-es/api/index.js +6 -5
- package/lib-es/api/index.js.map +1 -1
- package/lib-es/api/listOperations.d.ts +3 -0
- package/lib-es/api/listOperations.d.ts.map +1 -0
- package/lib-es/api/listOperations.js +61 -0
- package/lib-es/api/listOperations.js.map +1 -0
- package/lib-es/network/yield.d.ts +38 -0
- package/lib-es/network/yield.d.ts.map +1 -0
- package/lib-es/network/yield.js +28 -0
- package/lib-es/network/yield.js.map +1 -0
- package/lib-es/utils/index.d.ts +2 -0
- package/lib-es/utils/index.d.ts.map +1 -0
- package/lib-es/utils/index.js +8 -0
- package/lib-es/utils/index.js.map +1 -0
- package/package.json +3 -3
- package/src/api/getBalance.test.ts +66 -0
- package/src/api/getBalance.ts +21 -0
- package/src/api/index.integ.test.ts +55 -3
- package/src/api/index.test.ts +29 -0
- package/src/api/index.ts +6 -7
- package/src/api/listOperations.test.ts +163 -0
- package/src/api/listOperations.ts +84 -0
- package/src/network/yield.test.ts +72 -0
- package/src/network/yield.ts +72 -0
- package/src/utils/index.test.ts +20 -0
- 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(
|
|
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
|
|
4
|
+
import { type CoinModuleApi, notSupported } from '@ledgerhq/coin-module-framework/api/index'
|
|
5
5
|
import coinConfig, { type HypercoreConfig } from '../config'
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
20
|
-
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
|
+
}
|