@ledgerhq/coin-hedera 1.16.0-nightly.20251212024049 → 1.16.0-nightly.20251215100948

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 (131) hide show
  1. package/CHANGELOG.md +12 -10
  2. package/lib/api/index.d.ts.map +1 -1
  3. package/lib/api/index.js +4 -3
  4. package/lib/api/index.js.map +1 -1
  5. package/lib/bridge/buildOptimisticOperation.d.ts.map +1 -1
  6. package/lib/bridge/buildOptimisticOperation.js +14 -43
  7. package/lib/bridge/buildOptimisticOperation.js.map +1 -1
  8. package/lib/constants.d.ts +11 -6
  9. package/lib/constants.d.ts.map +1 -1
  10. package/lib/constants.js +20 -1
  11. package/lib/constants.js.map +1 -1
  12. package/lib/deviceTransactionConfig.d.ts.map +1 -1
  13. package/lib/deviceTransactionConfig.js +1 -3
  14. package/lib/deviceTransactionConfig.js.map +1 -1
  15. package/lib/logic/getBalance.d.ts.map +1 -1
  16. package/lib/logic/getBalance.js +21 -4
  17. package/lib/logic/getBalance.js.map +1 -1
  18. package/lib/logic/getBlock.d.ts.map +1 -1
  19. package/lib/logic/getBlock.js +39 -2
  20. package/lib/logic/getBlock.js.map +1 -1
  21. package/lib/logic/getValidators.d.ts +3 -0
  22. package/lib/logic/getValidators.d.ts.map +1 -0
  23. package/lib/logic/getValidators.js +24 -0
  24. package/lib/logic/getValidators.js.map +1 -0
  25. package/lib/logic/index.d.ts +1 -0
  26. package/lib/logic/index.d.ts.map +1 -1
  27. package/lib/logic/index.js +3 -1
  28. package/lib/logic/index.js.map +1 -1
  29. package/lib/logic/listOperations.d.ts.map +1 -1
  30. package/lib/logic/listOperations.js +16 -2
  31. package/lib/logic/listOperations.js.map +1 -1
  32. package/lib/logic/utils.d.ts +17 -1
  33. package/lib/logic/utils.d.ts.map +1 -1
  34. package/lib/logic/utils.js +54 -1
  35. package/lib/logic/utils.js.map +1 -1
  36. package/lib/network/api.d.ts +22 -2
  37. package/lib/network/api.d.ts.map +1 -1
  38. package/lib/network/api.js +49 -14
  39. package/lib/network/api.js.map +1 -1
  40. package/lib/preload.js +2 -2
  41. package/lib/preload.js.map +1 -1
  42. package/lib/test/fixtures/account.fixture.d.ts +8 -0
  43. package/lib/test/fixtures/account.fixture.d.ts.map +1 -1
  44. package/lib/test/fixtures/account.fixture.js +8 -0
  45. package/lib/test/fixtures/account.fixture.js.map +1 -1
  46. package/lib/test/fixtures/mirror.fixture.d.ts +2 -1
  47. package/lib/test/fixtures/mirror.fixture.d.ts.map +1 -1
  48. package/lib/test/fixtures/mirror.fixture.js +16 -1
  49. package/lib/test/fixtures/mirror.fixture.js.map +1 -1
  50. package/lib/types/bridge.d.ts +1 -0
  51. package/lib/types/bridge.d.ts.map +1 -1
  52. package/lib/types/logic.d.ts +6 -0
  53. package/lib/types/logic.d.ts.map +1 -1
  54. package/lib-es/api/index.d.ts.map +1 -1
  55. package/lib-es/api/index.js +5 -4
  56. package/lib-es/api/index.js.map +1 -1
  57. package/lib-es/bridge/buildOptimisticOperation.d.ts.map +1 -1
  58. package/lib-es/bridge/buildOptimisticOperation.js +15 -44
  59. package/lib-es/bridge/buildOptimisticOperation.js.map +1 -1
  60. package/lib-es/constants.d.ts +11 -6
  61. package/lib-es/constants.d.ts.map +1 -1
  62. package/lib-es/constants.js +19 -0
  63. package/lib-es/constants.js.map +1 -1
  64. package/lib-es/deviceTransactionConfig.d.ts.map +1 -1
  65. package/lib-es/deviceTransactionConfig.js +2 -4
  66. package/lib-es/deviceTransactionConfig.js.map +1 -1
  67. package/lib-es/logic/getBalance.d.ts.map +1 -1
  68. package/lib-es/logic/getBalance.js +21 -4
  69. package/lib-es/logic/getBalance.js.map +1 -1
  70. package/lib-es/logic/getBlock.d.ts.map +1 -1
  71. package/lib-es/logic/getBlock.js +40 -3
  72. package/lib-es/logic/getBlock.js.map +1 -1
  73. package/lib-es/logic/getValidators.d.ts +3 -0
  74. package/lib-es/logic/getValidators.d.ts.map +1 -0
  75. package/lib-es/logic/getValidators.js +20 -0
  76. package/lib-es/logic/getValidators.js.map +1 -0
  77. package/lib-es/logic/index.d.ts +1 -0
  78. package/lib-es/logic/index.d.ts.map +1 -1
  79. package/lib-es/logic/index.js +1 -0
  80. package/lib-es/logic/index.js.map +1 -1
  81. package/lib-es/logic/listOperations.d.ts.map +1 -1
  82. package/lib-es/logic/listOperations.js +17 -3
  83. package/lib-es/logic/listOperations.js.map +1 -1
  84. package/lib-es/logic/utils.d.ts +17 -1
  85. package/lib-es/logic/utils.d.ts.map +1 -1
  86. package/lib-es/logic/utils.js +52 -1
  87. package/lib-es/logic/utils.js.map +1 -1
  88. package/lib-es/network/api.d.ts +22 -2
  89. package/lib-es/network/api.d.ts.map +1 -1
  90. package/lib-es/network/api.js +49 -14
  91. package/lib-es/network/api.js.map +1 -1
  92. package/lib-es/preload.js +2 -2
  93. package/lib-es/preload.js.map +1 -1
  94. package/lib-es/test/fixtures/account.fixture.d.ts +8 -0
  95. package/lib-es/test/fixtures/account.fixture.d.ts.map +1 -1
  96. package/lib-es/test/fixtures/account.fixture.js +8 -0
  97. package/lib-es/test/fixtures/account.fixture.js.map +1 -1
  98. package/lib-es/test/fixtures/mirror.fixture.d.ts +2 -1
  99. package/lib-es/test/fixtures/mirror.fixture.d.ts.map +1 -1
  100. package/lib-es/test/fixtures/mirror.fixture.js +14 -0
  101. package/lib-es/test/fixtures/mirror.fixture.js.map +1 -1
  102. package/lib-es/types/bridge.d.ts +1 -0
  103. package/lib-es/types/bridge.d.ts.map +1 -1
  104. package/lib-es/types/logic.d.ts +6 -0
  105. package/lib-es/types/logic.d.ts.map +1 -1
  106. package/package.json +10 -10
  107. package/src/api/index.integ.test.ts +226 -1
  108. package/src/api/index.test.ts +5 -2
  109. package/src/api/index.ts +5 -5
  110. package/src/bridge/{buildOptimisticOperation.integration.test.ts → buildOptimisticOperation.test.ts} +23 -68
  111. package/src/bridge/buildOptimisticOperation.ts +16 -45
  112. package/src/constants.ts +23 -1
  113. package/src/deviceTransactionConfig.test.ts +59 -43
  114. package/src/deviceTransactionConfig.ts +2 -5
  115. package/src/logic/getBalance.test.ts +50 -0
  116. package/src/logic/getBalance.ts +21 -4
  117. package/src/logic/getBlock.test.ts +283 -1
  118. package/src/logic/getBlock.ts +57 -6
  119. package/src/logic/getValidators.test.ts +50 -0
  120. package/src/logic/getValidators.ts +22 -0
  121. package/src/logic/index.ts +1 -0
  122. package/src/logic/listOperations.ts +33 -3
  123. package/src/logic/utils.test.ts +113 -0
  124. package/src/logic/utils.ts +67 -1
  125. package/src/network/api.test.ts +55 -9
  126. package/src/network/api.ts +66 -14
  127. package/src/preload.ts +2 -2
  128. package/src/test/fixtures/account.fixture.ts +8 -0
  129. package/src/test/fixtures/mirror.fixture.ts +18 -0
  130. package/src/types/bridge.ts +1 -0
  131. package/src/types/logic.ts +7 -0
@@ -22,6 +22,7 @@ describe("getBalance", () => {
22
22
 
23
23
  (apiClient.getAccount as jest.Mock).mockResolvedValue(mockMirrorAccount);
24
24
  (apiClient.getAccountTokens as jest.Mock).mockResolvedValue([]);
25
+ (apiClient.getNodes as jest.Mock).mockResolvedValue({ nodes: [] });
25
26
 
26
27
  const result = await getBalance(mockCurrency, address);
27
28
 
@@ -70,6 +71,7 @@ describe("getBalance", () => {
70
71
 
71
72
  (apiClient.getAccount as jest.Mock).mockResolvedValue(mockMirrorAccount);
72
73
  (apiClient.getAccountTokens as jest.Mock).mockResolvedValue(mockMirrorTokens);
74
+ (apiClient.getNodes as jest.Mock).mockResolvedValue({ nodes: [] });
73
75
 
74
76
  const result = await getBalance(mockCurrency, address);
75
77
 
@@ -99,6 +101,51 @@ describe("getBalance", () => {
99
101
  );
100
102
  });
101
103
 
104
+ it("should return stake", async () => {
105
+ const address = "0.0.12345";
106
+ const mockCurrency = getMockedCurrency();
107
+ const mockMirrorAccount = {
108
+ account: address,
109
+ staked_node_id: 5,
110
+ balance: {
111
+ balance: 100,
112
+ },
113
+ pending_reward: 100,
114
+ };
115
+ const mockMirrorNode = {
116
+ node_id: 5,
117
+ node_account_id: "0.0.5",
118
+ description: "Hosted for Wipro | Amsterdam, Netherlands",
119
+ max_stake: 45000000000000000,
120
+ stake: 45000000000000000,
121
+ };
122
+
123
+ (apiClient.getAccount as jest.Mock).mockResolvedValue(mockMirrorAccount);
124
+ (apiClient.getAccountTokens as jest.Mock).mockResolvedValue([]);
125
+ (apiClient.getNodes as jest.Mock).mockResolvedValue({ nodes: [mockMirrorNode] });
126
+
127
+ const result = await getBalance(mockCurrency, address);
128
+
129
+ expect(apiClient.getAccount).toHaveBeenCalledTimes(1);
130
+ expect(apiClient.getAccount).toHaveBeenCalledWith(address);
131
+ expect(apiClient.getNodes).toHaveBeenCalledTimes(1);
132
+ expect(result).toHaveLength(1);
133
+ expect(result[0]).toMatchObject({
134
+ asset: { type: "native" },
135
+ value: BigInt(mockMirrorAccount.balance.balance),
136
+ stake: {
137
+ uid: address,
138
+ address,
139
+ asset: { type: "native" },
140
+ state: "active",
141
+ amount: BigInt(mockMirrorAccount.balance.balance + mockMirrorAccount.pending_reward),
142
+ amountDeposited: BigInt(mockMirrorAccount.balance.balance),
143
+ amountRewarded: BigInt(mockMirrorAccount.pending_reward),
144
+ delegate: mockMirrorNode.node_account_id,
145
+ },
146
+ });
147
+ });
148
+
102
149
  it("should skip tokens not found in CAL", async () => {
103
150
  const address = "0.0.12345";
104
151
  const mockCurrency = getMockedCurrency();
@@ -145,6 +192,7 @@ describe("getBalance", () => {
145
192
 
146
193
  (apiClient.getAccount as jest.Mock).mockResolvedValue(mockMirrorAccount);
147
194
  (apiClient.getAccountTokens as jest.Mock).mockResolvedValue(mockMirrorTokens);
195
+ (apiClient.getNodes as jest.Mock).mockResolvedValue({ nodes: [] });
148
196
 
149
197
  const result = await getBalance(mockCurrency, address);
150
198
 
@@ -173,6 +221,7 @@ describe("getBalance", () => {
173
221
 
174
222
  (apiClient.getAccount as jest.Mock).mockRejectedValue(error);
175
223
  (apiClient.getAccountTokens as jest.Mock).mockResolvedValue([]);
224
+ (apiClient.getNodes as jest.Mock).mockResolvedValue({ nodes: [] });
176
225
 
177
226
  await expect(getBalance(mockCurrency, address)).rejects.toThrow(error);
178
227
  });
@@ -189,6 +238,7 @@ describe("getBalance", () => {
189
238
 
190
239
  (apiClient.getAccount as jest.Mock).mockResolvedValue(mockMirrorAccount);
191
240
  (apiClient.getAccountTokens as jest.Mock).mockRejectedValue(error);
241
+ (apiClient.getNodes as jest.Mock).mockResolvedValue({ nodes: [] });
192
242
 
193
243
  await expect(getBalance(mockCurrency, address)).rejects.toThrow(error);
194
244
  });
@@ -4,15 +4,32 @@ import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state";
4
4
  import { apiClient } from "../network/api";
5
5
 
6
6
  export async function getBalance(currency: CryptoCurrency, address: string): Promise<Balance[]> {
7
- const [mirrorAccount, mirrorTokens] = await Promise.all([
7
+ const [mirrorAccount, mirrorTokens, mirrorNodes] = await Promise.all([
8
8
  apiClient.getAccount(address),
9
9
  apiClient.getAccountTokens(address),
10
+ apiClient.getNodes({ fetchAllPages: true }),
10
11
  ]);
11
12
 
12
- const balance: Balance[] = [
13
+ const validator = mirrorNodes.nodes.find(v => v.node_id === mirrorAccount.staked_node_id);
14
+ const balances: Balance[] = [
13
15
  {
14
16
  asset: { type: "native" },
15
17
  value: BigInt(mirrorAccount.balance.balance),
18
+ ...(validator && {
19
+ stake: {
20
+ uid: address,
21
+ address,
22
+ asset: { type: "native" },
23
+ state: "active",
24
+ amount: BigInt(mirrorAccount.balance.balance) + BigInt(mirrorAccount.pending_reward),
25
+ amountDeposited: BigInt(mirrorAccount.balance.balance),
26
+ amountRewarded: BigInt(mirrorAccount.pending_reward),
27
+ delegate: validator.node_account_id,
28
+ details: {
29
+ overstaked: BigInt(validator.stake) >= BigInt(validator.max_stake),
30
+ },
31
+ },
32
+ }),
16
33
  },
17
34
  ];
18
35
 
@@ -26,7 +43,7 @@ export async function getBalance(currency: CryptoCurrency, address: string): Pro
26
43
  continue;
27
44
  }
28
45
 
29
- balance.push({
46
+ balances.push({
30
47
  value: BigInt(mirrorToken.balance),
31
48
  asset: {
32
49
  type: calToken.tokenType,
@@ -38,5 +55,5 @@ export async function getBalance(currency: CryptoCurrency, address: string): Pro
38
55
  });
39
56
  }
40
57
 
41
- return balance;
58
+ return balances;
42
59
  }
@@ -1,7 +1,9 @@
1
+ import { HEDERA_TRANSACTION_NAMES } from "../constants";
1
2
  import { getBlock } from "./getBlock";
2
3
  import { getBlockInfo } from "./getBlockInfo";
3
4
  import { apiClient } from "../network/api";
4
- import { getTimestampRangeFromBlockHeight } from "./utils";
5
+ import type { StakingAnalysis } from "../types";
6
+ import { analyzeStakingOperation, getTimestampRangeFromBlockHeight } from "./utils";
5
7
 
6
8
  jest.mock("./getBlockInfo");
7
9
  jest.mock("../network/api");
@@ -23,6 +25,7 @@ describe("getBlock", () => {
23
25
  jest.clearAllMocks();
24
26
  (getBlockInfo as jest.Mock).mockResolvedValue(mockBlockInfo);
25
27
  (getTimestampRangeFromBlockHeight as jest.Mock).mockReturnValue(mockTimestampRange);
28
+ (analyzeStakingOperation as jest.Mock).mockResolvedValue(null);
26
29
  });
27
30
 
28
31
  it("should return empty block when no transactions exist", async () => {
@@ -57,6 +60,7 @@ describe("getBlock", () => {
57
60
  name: "CRYPTOTRANSFER",
58
61
  result: "SUCCESS",
59
62
  charged_tx_fee: 100000,
63
+ staking_reward_transfers: [],
60
64
  transfers: [],
61
65
  token_transfers: [],
62
66
  };
@@ -75,6 +79,7 @@ describe("getBlock", () => {
75
79
  name: "CRYPTOTRANSFER",
76
80
  result: "SUCCESS",
77
81
  charged_tx_fee: 67179,
82
+ staking_reward_transfers: [],
78
83
  transfers: [
79
84
  {
80
85
  account: "0.0.999",
@@ -98,4 +103,281 @@ describe("getBlock", () => {
98
103
  amount: BigInt(-567179 + 67179),
99
104
  });
100
105
  });
106
+
107
+ it("should handle token transfers", async () => {
108
+ const mockTx = {
109
+ transaction_id: "0.0.999-1234567890-000000000",
110
+ transaction_hash: "hash",
111
+ name: "CRYPTOTRANSFER",
112
+ result: "SUCCESS",
113
+ charged_tx_fee: 100000,
114
+ staking_reward_transfers: [],
115
+ transfers: [],
116
+ token_transfers: [
117
+ {
118
+ token_id: "0.0.12345",
119
+ account: "0.0.999",
120
+ amount: -1000,
121
+ },
122
+ {
123
+ token_id: "0.0.12345",
124
+ account: "0.0.1001",
125
+ amount: 1000,
126
+ },
127
+ ],
128
+ };
129
+
130
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
131
+
132
+ const result = await getBlock(100);
133
+
134
+ expect(result.transactions[0].operations).toEqual([
135
+ {
136
+ type: "transfer",
137
+ address: "0.0.999",
138
+ asset: {
139
+ type: "hts",
140
+ assetReference: "0.0.12345",
141
+ },
142
+ amount: BigInt(-1000),
143
+ },
144
+ {
145
+ type: "transfer",
146
+ address: "0.0.1001",
147
+ asset: {
148
+ type: "hts",
149
+ assetReference: "0.0.12345",
150
+ },
151
+ amount: BigInt(1000),
152
+ },
153
+ ]);
154
+ });
155
+
156
+ it("should mark failed transactions", async () => {
157
+ const mockTx = {
158
+ transaction_id: "0.0.999-1234567890-000000000",
159
+ transaction_hash: "hash",
160
+ name: "CRYPTOTRANSFER",
161
+ result: "INSUFFICIENT_ACCOUNT_BALANCE",
162
+ charged_tx_fee: 100000,
163
+ staking_reward_transfers: [],
164
+ transfers: [],
165
+ token_transfers: [],
166
+ };
167
+
168
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
169
+
170
+ const result = await getBlock(100);
171
+
172
+ expect(result.transactions[0].failed).toBe(true);
173
+ });
174
+
175
+ it("should analyze CRYPTOUPDATEACCOUNT transactions for staking", async () => {
176
+ const mockTx = {
177
+ transaction_id: "0.0.999-1234567890-000000000",
178
+ transaction_hash: "hash_update",
179
+ name: HEDERA_TRANSACTION_NAMES.UpdateAccount,
180
+ result: "SUCCESS",
181
+ charged_tx_fee: 22000,
182
+ consensus_timestamp: "1704067210.123456789",
183
+ staking_reward_transfers: [],
184
+ transfers: [],
185
+ token_transfers: [],
186
+ };
187
+ const mockStakingAnalysis: StakingAnalysis = {
188
+ operationType: "DELEGATE",
189
+ targetStakingNodeId: 5,
190
+ previousStakingNodeId: null,
191
+ stakedAmount: BigInt(100),
192
+ };
193
+
194
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
195
+ (analyzeStakingOperation as jest.Mock).mockResolvedValue(mockStakingAnalysis);
196
+
197
+ const result = await getBlock(100);
198
+
199
+ expect(analyzeStakingOperation).toHaveBeenCalledTimes(1);
200
+ expect(analyzeStakingOperation).toHaveBeenCalledWith("0.0.999", mockTx);
201
+ expect(result.transactions[0].operations).toHaveLength(1);
202
+ expect(result.transactions[0].operations[0]).toEqual({
203
+ type: "other",
204
+ operationType: mockStakingAnalysis.operationType,
205
+ stakedNodeId: mockStakingAnalysis.targetStakingNodeId,
206
+ previousStakedNodeId: mockStakingAnalysis.previousStakingNodeId,
207
+ stakedAmount: mockStakingAnalysis.stakedAmount,
208
+ });
209
+ });
210
+
211
+ it("should handle UNDELEGATE staking operation", async () => {
212
+ const mockTx = {
213
+ transaction_id: "0.0.999-1234567890-000000000",
214
+ transaction_hash: "hash_undelegate",
215
+ name: HEDERA_TRANSACTION_NAMES.UpdateAccount,
216
+ result: "SUCCESS",
217
+ charged_tx_fee: 22000,
218
+ consensus_timestamp: "1704067210.123456789",
219
+ staking_reward_transfers: [],
220
+ transfers: [],
221
+ token_transfers: [],
222
+ };
223
+ const mockStakingAnalysis: StakingAnalysis = {
224
+ operationType: "UNDELEGATE",
225
+ targetStakingNodeId: null,
226
+ previousStakingNodeId: 3,
227
+ stakedAmount: BigInt(100),
228
+ };
229
+
230
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
231
+ (analyzeStakingOperation as jest.Mock).mockResolvedValue(mockStakingAnalysis);
232
+
233
+ const result = await getBlock(100);
234
+
235
+ expect(result.transactions[0].operations[0]).toEqual({
236
+ type: "other",
237
+ operationType: mockStakingAnalysis.operationType,
238
+ stakedNodeId: mockStakingAnalysis.targetStakingNodeId,
239
+ previousStakedNodeId: mockStakingAnalysis.previousStakingNodeId,
240
+ stakedAmount: mockStakingAnalysis.stakedAmount,
241
+ });
242
+ });
243
+
244
+ it("should handle REDELEGATE staking operation", async () => {
245
+ const mockTx = {
246
+ transaction_id: "0.0.999-1234567890-000000000",
247
+ transaction_hash: "hash_redelegate",
248
+ name: HEDERA_TRANSACTION_NAMES.UpdateAccount,
249
+ result: "SUCCESS",
250
+ charged_tx_fee: 22000,
251
+ consensus_timestamp: "1704067210.123456789",
252
+ staking_reward_transfers: [],
253
+ transfers: [],
254
+ token_transfers: [],
255
+ };
256
+ const mockStakingAnalysis: StakingAnalysis = {
257
+ operationType: "REDELEGATE",
258
+ targetStakingNodeId: 10,
259
+ previousStakingNodeId: 5,
260
+ stakedAmount: BigInt(100),
261
+ };
262
+
263
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
264
+ (analyzeStakingOperation as jest.Mock).mockResolvedValue(mockStakingAnalysis);
265
+
266
+ const result = await getBlock(100);
267
+
268
+ expect(result.transactions[0].operations).toEqual([
269
+ {
270
+ type: "other",
271
+ operationType: mockStakingAnalysis.operationType,
272
+ stakedNodeId: mockStakingAnalysis.targetStakingNodeId,
273
+ previousStakedNodeId: mockStakingAnalysis.previousStakingNodeId,
274
+ stakedAmount: mockStakingAnalysis.stakedAmount,
275
+ },
276
+ ]);
277
+ });
278
+
279
+ it("should create CLAIM_REWARDS operations for staking reward transfers", async () => {
280
+ const mockTx = {
281
+ transaction_id: "0.0.999-1234567890-000000000",
282
+ transaction_hash: "hash",
283
+ name: "CRYPTOTRANSFER",
284
+ result: "SUCCESS",
285
+ charged_tx_fee: 100000,
286
+ staking_reward_transfers: [
287
+ {
288
+ account: "0.0.999",
289
+ amount: 100000,
290
+ },
291
+ {
292
+ account: "0.0.1001",
293
+ amount: 200000,
294
+ },
295
+ ],
296
+ transfers: [
297
+ {
298
+ account: "0.0.999",
299
+ amount: -600000,
300
+ },
301
+ {
302
+ account: "0.0.1001",
303
+ amount: 500000,
304
+ },
305
+ ],
306
+ token_transfers: [],
307
+ };
308
+
309
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
310
+
311
+ const result = await getBlock(100);
312
+
313
+ expect(result.transactions[0].operations).toEqual([
314
+ {
315
+ type: "transfer",
316
+ address: "0.0.999",
317
+ asset: { type: "native" },
318
+ amount: BigInt(-500000),
319
+ },
320
+ {
321
+ type: "transfer",
322
+ address: "0.0.1001",
323
+ asset: { type: "native" },
324
+ amount: BigInt(500000),
325
+ },
326
+ {
327
+ type: "transfer",
328
+ address: "0.0.999",
329
+ asset: { type: "native" },
330
+ amount: BigInt(100000),
331
+ },
332
+ {
333
+ type: "transfer",
334
+ address: "0.0.1001",
335
+ asset: { type: "native" },
336
+ amount: BigInt(200000),
337
+ },
338
+ ]);
339
+ });
340
+
341
+ it("should handle CRYPTOUPDATEACCOUNT if it's not related to staking", async () => {
342
+ const mockTx = {
343
+ transaction_id: "0.0.999-1234567890-000000000",
344
+ transaction_hash: "hash_regular_update",
345
+ name: HEDERA_TRANSACTION_NAMES.UpdateAccount,
346
+ result: "SUCCESS",
347
+ charged_tx_fee: 22000,
348
+ consensus_timestamp: "1704067210.123456789",
349
+ staking_reward_transfers: [],
350
+ transfers: [
351
+ {
352
+ account: "0.0.999",
353
+ amount: -23000,
354
+ },
355
+ {
356
+ account: "0.0.1000",
357
+ amount: 1000,
358
+ },
359
+ ],
360
+ token_transfers: [],
361
+ };
362
+
363
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValue([mockTx]);
364
+ (analyzeStakingOperation as jest.Mock).mockResolvedValue(null);
365
+
366
+ const result = await getBlock(100);
367
+
368
+ expect(result.transactions[0].operations).toEqual([
369
+ {
370
+ type: "transfer",
371
+ address: "0.0.999",
372
+ asset: { type: "native" },
373
+ amount: BigInt(-1000),
374
+ },
375
+ {
376
+ type: "transfer",
377
+ address: "0.0.1000",
378
+ asset: { type: "native" },
379
+ amount: BigInt(1000),
380
+ },
381
+ ]);
382
+ });
101
383
  });
@@ -4,10 +4,19 @@ import type {
4
4
  BlockOperation,
5
5
  BlockTransaction,
6
6
  } from "@ledgerhq/coin-framework/api/types";
7
+ import { HEDERA_TRANSACTION_NAMES } from "../constants";
7
8
  import { getBlockInfo } from "./getBlockInfo";
8
9
  import { apiClient } from "../network/api";
9
- import type { HederaMirrorCoinTransfer, HederaMirrorTokenTransfer } from "../types";
10
- import { getMemoFromBase64, getTimestampRangeFromBlockHeight } from "./utils";
10
+ import type {
11
+ HederaMirrorCoinTransfer,
12
+ HederaMirrorTokenTransfer,
13
+ HederaMirrorTransaction,
14
+ } from "../types";
15
+ import {
16
+ getMemoFromBase64,
17
+ analyzeStakingOperation,
18
+ getTimestampRangeFromBlockHeight,
19
+ } from "./utils";
11
20
 
12
21
  function toHederaAsset(
13
22
  mirrorTransfer: HederaMirrorCoinTransfer | HederaMirrorTokenTransfer,
@@ -45,18 +54,60 @@ function toBlockOperation(
45
54
  };
46
55
  }
47
56
 
57
+ function createStakingRewardOperations(tx: HederaMirrorTransaction): BlockOperation[] {
58
+ return tx.staking_reward_transfers.map(rewardTransfer => ({
59
+ type: "transfer",
60
+ address: rewardTransfer.account,
61
+ asset: { type: "native" },
62
+ amount: BigInt(rewardTransfer.amount),
63
+ }));
64
+ }
65
+
48
66
  export async function getBlock(height: number): Promise<Block> {
49
67
  const { start, end } = getTimestampRangeFromBlockHeight(height);
50
68
  const blockInfo = await getBlockInfo(height);
51
69
  const transactions = await apiClient.getTransactionsByTimestampRange(start, end);
52
70
 
71
+ // analyze CRYPTOUPDATEACCOUNT transactions to distinguish staking operations from regular account updates.
72
+ // this creates a map of transaction_hash -> StakingAnalysis to avoid repeated lookups.
73
+ const stakingAnalyses = await Promise.all(
74
+ transactions
75
+ .filter(tx => tx.name === HEDERA_TRANSACTION_NAMES.UpdateAccount)
76
+ .map(async tx => {
77
+ const payerAccount = tx.transaction_id.split("-")[0];
78
+ const analysis = await analyzeStakingOperation(payerAccount, tx);
79
+
80
+ return [tx.transaction_hash, analysis] as const;
81
+ }),
82
+ );
83
+ const stakingAnalysisMap = new Map(stakingAnalyses);
84
+
53
85
  const blockTransactions: BlockTransaction[] = transactions.map(tx => {
54
86
  const payerAccount = tx.transaction_id.split("-")[0];
55
- const allTransfers = [...tx.transfers, ...tx.token_transfers];
87
+ const stakingAnalysis = stakingAnalysisMap.get(tx.transaction_hash);
88
+
89
+ let operations: BlockOperation[];
90
+
91
+ if (stakingAnalysis) {
92
+ operations = [
93
+ {
94
+ type: "other",
95
+ operationType: stakingAnalysis.operationType,
96
+ stakedNodeId: stakingAnalysis.targetStakingNodeId,
97
+ previousStakedNodeId: stakingAnalysis.previousStakingNodeId,
98
+ stakedAmount: stakingAnalysis.stakedAmount,
99
+ },
100
+ ];
101
+ } else {
102
+ const allTransfers = [...tx.transfers, ...tx.token_transfers];
103
+ operations = allTransfers.map(transfer =>
104
+ toBlockOperation(payerAccount, tx.charged_tx_fee, transfer),
105
+ );
106
+ }
56
107
 
57
- const operations = allTransfers.map(transfer =>
58
- toBlockOperation(payerAccount, tx.charged_tx_fee, transfer),
59
- );
108
+ // add staking reward operations if present (can occur on any transaction type)
109
+ const rewardOperations = createStakingRewardOperations(tx);
110
+ operations.push(...rewardOperations);
60
111
 
61
112
  return {
62
113
  hash: tx.transaction_hash,
@@ -0,0 +1,50 @@
1
+ import { getValidators } from "./getValidators";
2
+ import { apiClient } from "../network/api";
3
+
4
+ jest.mock("../network/api");
5
+
6
+ describe("getValidators", () => {
7
+ beforeEach(() => {
8
+ jest.clearAllMocks();
9
+ });
10
+
11
+ it("should return formatted validators with APY", async () => {
12
+ (apiClient.getNodes as jest.Mock).mockResolvedValue({
13
+ nodes: [
14
+ {
15
+ node_id: 1,
16
+ node_account_id: "0.0.3",
17
+ description: "Hosted by Ledger | Paris, France",
18
+ stake: 1000000,
19
+ reward_rate_start: 3538,
20
+ },
21
+ ],
22
+ nextCursor: null,
23
+ });
24
+
25
+ const result = await getValidators();
26
+
27
+ expect(result.items).toHaveLength(1);
28
+ expect(result.items[0]).toMatchObject({
29
+ address: "0.0.3",
30
+ nodeId: "1",
31
+ name: "Ledger",
32
+ description: "Hosted by Ledger | Paris, France",
33
+ balance: BigInt(1000000),
34
+ apy: expect.any(Number),
35
+ });
36
+ expect(result.items[0].apy).toBeCloseTo(0.01291, 5);
37
+ });
38
+
39
+ it("should handle pagination cursor", async () => {
40
+ (apiClient.getNodes as jest.Mock).mockResolvedValue({
41
+ nodes: [],
42
+ nextCursor: "123",
43
+ });
44
+
45
+ const result = await getValidators("100");
46
+
47
+ expect(apiClient.getNodes).toHaveBeenCalledWith({ cursor: "100", fetchAllPages: false });
48
+ expect(result.next).toBe("123");
49
+ });
50
+ });
@@ -0,0 +1,22 @@
1
+ import type { Cursor, Page, Validator } from "@ledgerhq/coin-framework/api/types";
2
+ import { apiClient } from "../network/api";
3
+ import { calculateAPY, extractCompanyFromNodeDescription } from "./utils";
4
+
5
+ export async function getValidators(cursor?: Cursor): Promise<Page<Validator>> {
6
+ const res = await apiClient.getNodes({
7
+ fetchAllPages: false,
8
+ ...(cursor && { cursor }),
9
+ });
10
+
11
+ return {
12
+ next: res.nextCursor ?? undefined,
13
+ items: res.nodes.map(node => ({
14
+ address: node.node_account_id,
15
+ nodeId: node.node_id.toString(),
16
+ name: extractCompanyFromNodeDescription(node.description),
17
+ description: node.description,
18
+ balance: BigInt(node.stake),
19
+ apy: calculateAPY(node.reward_rate_start),
20
+ })),
21
+ };
22
+ }
@@ -9,3 +9,4 @@ export { lastBlock } from "./lastBlock";
9
9
  export { listOperations } from "./listOperations";
10
10
  export { getAssetFromToken } from "./getAssetFromToken";
11
11
  export { getTokenFromAsset } from "./getTokenFromAsset";
12
+ export { getValidators } from "./getValidators";