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

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
@@ -6,10 +6,21 @@ import type { Pagination } from "@ledgerhq/coin-framework/api/types";
6
6
  import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state";
7
7
  import { encodeAccountId, encodeTokenAccountId } from "@ledgerhq/coin-framework/account/accountId";
8
8
  import { encodeOperationId } from "@ledgerhq/coin-framework/operation";
9
+ import { HEDERA_TRANSACTION_NAMES } from "../constants";
9
10
  import { apiClient } from "../network/api";
10
11
  import { parseTransfers } from "../network/utils";
11
- import type { HederaMirrorToken, HederaMirrorTransaction, HederaOperationExtra } from "../types";
12
- import { base64ToUrlSafeBase64, getMemoFromBase64, getSyntheticBlock } from "./utils";
12
+ import type {
13
+ HederaMirrorToken,
14
+ HederaMirrorTransaction,
15
+ HederaOperationExtra,
16
+ StakingAnalysis,
17
+ } from "../types";
18
+ import {
19
+ analyzeStakingOperation,
20
+ base64ToUrlSafeBase64,
21
+ getMemoFromBase64,
22
+ getSyntheticBlock,
23
+ } from "./utils";
13
24
 
14
25
  const txNameToCustomOperationType: Record<string, OperationType> = {
15
26
  TOKENASSOCIATE: "ASSOCIATE_TOKEN",
@@ -129,12 +140,14 @@ function processTransfers({
129
140
  ledgerAccountId,
130
141
  commonData,
131
142
  mirrorTokens,
143
+ stakingAnalysis,
132
144
  }: {
133
145
  rawTx: HederaMirrorTransaction;
134
146
  address: string;
135
147
  ledgerAccountId: string;
136
148
  commonData: ReturnType<typeof getCommonOperationData>;
137
149
  mirrorTokens: HederaMirrorToken[];
150
+ stakingAnalysis: StakingAnalysis | null;
138
151
  }): Operation<HederaOperationExtra>[] {
139
152
  const coinOperations: Operation<HederaOperationExtra>[] = [];
140
153
  const transfers = rawTx.transfers ?? [];
@@ -146,7 +159,17 @@ function processTransfers({
146
159
  const { type, value, senders, recipients } = parseTransfers(transfers, address);
147
160
  const { hash, fee, timestamp, blockHeight, blockHash, hasFailed } = commonData;
148
161
  const extra = { ...commonData.extra };
149
- const operationType = txNameToCustomOperationType[rawTx.name] ?? type;
162
+ let operationType = txNameToCustomOperationType[rawTx.name] ?? type;
163
+
164
+ // update operation type and extra fields if staking analysis is available
165
+ if (stakingAnalysis) {
166
+ operationType = stakingAnalysis.operationType;
167
+ extra.previousStakingNodeId = stakingAnalysis.previousStakingNodeId;
168
+ extra.targetStakingNodeId = stakingAnalysis.targetStakingNodeId;
169
+ extra.stakedAmount = new BigNumber(stakingAnalysis.stakedAmount.toString());
170
+ }
171
+
172
+ // each transfer may trigger staking reward claim
150
173
  const stakingReward = rawTx.staking_reward_transfers.reduce((acc, transfer) => {
151
174
  const transferAmount = new BigNumber(transfer.amount);
152
175
 
@@ -256,6 +279,12 @@ export async function listOperations({
256
279
  for (const rawTx of mirrorResult.transactions) {
257
280
  const commonData = getCommonOperationData(rawTx, useEncodedHash, useSyntheticBlocks);
258
281
 
282
+ // try to distinguish staking operations for CRYPTOUPDATEACCOUNT transactions
283
+ const stakingAnalysis =
284
+ rawTx.name === HEDERA_TRANSACTION_NAMES.UpdateAccount
285
+ ? await analyzeStakingOperation(address, rawTx)
286
+ : null;
287
+
259
288
  // process token transfers
260
289
  const tokenResult = await processTokenTransfers({
261
290
  rawTx,
@@ -277,6 +306,7 @@ export async function listOperations({
277
306
  ledgerAccountId,
278
307
  commonData,
279
308
  mirrorTokens,
309
+ stakingAnalysis,
280
310
  });
281
311
 
282
312
  coinOperations.push(...newCoinOperations);
@@ -20,9 +20,11 @@ import {
20
20
  getMockedHTSTokenCurrency,
21
21
  } from "../test/fixtures/currency.fixture";
22
22
  import { getMockedAccount, getMockedTokenAccount } from "../test/fixtures/account.fixture";
23
+ import { getMockedMirrorAccount } from "../test/fixtures/mirror.fixture";
23
24
  import type {
24
25
  HederaAccount,
25
26
  HederaMemo,
27
+ HederaMirrorTransaction,
26
28
  HederaPreloadData,
27
29
  HederaTxData,
28
30
  HederaValidator,
@@ -60,6 +62,8 @@ import {
60
62
  getChecksum,
61
63
  mapIntentToSDKOperation,
62
64
  getOperationDetailsExtraFields,
65
+ calculateAPY,
66
+ analyzeStakingOperation,
63
67
  } from "./utils";
64
68
 
65
69
  jest.mock("../network/api");
@@ -951,4 +955,113 @@ describe("logic utils", () => {
951
955
  ]);
952
956
  });
953
957
  });
958
+
959
+ describe("calculateAPY", () => {
960
+ it("should calculate APY correctly for a typical reward rate", () => {
961
+ const result = calculateAPY(3538);
962
+
963
+ expect(result).toBeCloseTo(0.01291, 5);
964
+ });
965
+
966
+ it("should return 0 for zero reward rate", () => {
967
+ const result = calculateAPY(0);
968
+
969
+ expect(result).toBe(0);
970
+ });
971
+ });
972
+
973
+ describe("analyzeStakingOperation", () => {
974
+ const mockAddress = "0.0.12345";
975
+ const mockTimestamp = "1762202064.065172388";
976
+ const mockTx = {
977
+ consensus_timestamp: mockTimestamp,
978
+ name: "CRYPTOUPDATEACCOUNT",
979
+ } as HederaMirrorTransaction;
980
+
981
+ beforeEach(() => {
982
+ jest.clearAllMocks();
983
+ });
984
+
985
+ it("detects DELEGATE operation when staking starts", async () => {
986
+ const accountBefore = getMockedMirrorAccount({ staked_node_id: null });
987
+ const accountAfter = getMockedMirrorAccount({ staked_node_id: 5 });
988
+
989
+ (apiClient.getAccount as jest.Mock)
990
+ .mockResolvedValueOnce(accountBefore)
991
+ .mockResolvedValueOnce(accountAfter);
992
+
993
+ const result = await analyzeStakingOperation(mockAddress, mockTx);
994
+
995
+ expect(result).toEqual({
996
+ operationType: "DELEGATE",
997
+ previousStakingNodeId: null,
998
+ targetStakingNodeId: 5,
999
+ stakedAmount: BigInt(1000),
1000
+ });
1001
+ expect(apiClient.getAccount).toHaveBeenCalledWith(mockAddress, `lt:${mockTimestamp}`);
1002
+ expect(apiClient.getAccount).toHaveBeenCalledWith(mockAddress, `eq:${mockTimestamp}`);
1003
+ });
1004
+
1005
+ it("detects UNDELEGATE operation when staking stops", async () => {
1006
+ const accountBefore = getMockedMirrorAccount({ staked_node_id: 5 });
1007
+ const accountAfter = getMockedMirrorAccount({ staked_node_id: null });
1008
+
1009
+ (apiClient.getAccount as jest.Mock)
1010
+ .mockResolvedValueOnce(accountBefore)
1011
+ .mockResolvedValueOnce(accountAfter);
1012
+
1013
+ const result = await analyzeStakingOperation(mockAddress, mockTx);
1014
+
1015
+ expect(result).toEqual({
1016
+ operationType: "UNDELEGATE",
1017
+ previousStakingNodeId: 5,
1018
+ targetStakingNodeId: null,
1019
+ stakedAmount: BigInt(1000),
1020
+ });
1021
+ });
1022
+
1023
+ it("detects REDELEGATE operation when changing nodes", async () => {
1024
+ const accountBefore = getMockedMirrorAccount({ staked_node_id: 3 });
1025
+ const accountAfter = getMockedMirrorAccount({ staked_node_id: 10 });
1026
+
1027
+ (apiClient.getAccount as jest.Mock)
1028
+ .mockResolvedValueOnce(accountBefore)
1029
+ .mockResolvedValueOnce(accountAfter);
1030
+
1031
+ const result = await analyzeStakingOperation(mockAddress, mockTx);
1032
+
1033
+ expect(result).toEqual({
1034
+ operationType: "REDELEGATE",
1035
+ previousStakingNodeId: 3,
1036
+ targetStakingNodeId: 10,
1037
+ stakedAmount: BigInt(1000),
1038
+ });
1039
+ });
1040
+
1041
+ it("returns null for regular account update (both null)", async () => {
1042
+ const accountBefore = getMockedMirrorAccount({ staked_node_id: null });
1043
+ const accountAfter = getMockedMirrorAccount({ staked_node_id: null });
1044
+
1045
+ (apiClient.getAccount as jest.Mock)
1046
+ .mockResolvedValueOnce(accountBefore)
1047
+ .mockResolvedValueOnce(accountAfter);
1048
+
1049
+ const result = await analyzeStakingOperation(mockAddress, mockTx);
1050
+
1051
+ expect(result).toBeNull();
1052
+ });
1053
+
1054
+ it("returns null when staked node doesn't change", async () => {
1055
+ const accountBefore = getMockedMirrorAccount({ staked_node_id: 5 });
1056
+ const accountAfter = getMockedMirrorAccount({ staked_node_id: 5 });
1057
+
1058
+ (apiClient.getAccount as jest.Mock)
1059
+ .mockResolvedValueOnce(accountBefore)
1060
+ .mockResolvedValueOnce(accountAfter);
1061
+
1062
+ const result = await analyzeStakingOperation(mockAddress, mockTx);
1063
+
1064
+ expect(result).toBeNull();
1065
+ });
1066
+ });
954
1067
  });
@@ -15,21 +15,24 @@ import { InvalidAddress } from "@ledgerhq/errors";
15
15
  import { getEnv } from "@ledgerhq/live-env";
16
16
  import { makeLRUCache, seconds } from "@ledgerhq/live-network/cache";
17
17
  import type { Currency, ExplorerView, TokenCurrency } from "@ledgerhq/types-cryptoassets";
18
- import type { AccountLike, Operation as LiveOperation } from "@ledgerhq/types-live";
18
+ import type { AccountLike, Operation as LiveOperation, OperationType } from "@ledgerhq/types-live";
19
19
  import {
20
20
  HEDERA_DELEGATION_STATUS,
21
21
  HEDERA_OPERATION_TYPES,
22
22
  HEDERA_TRANSACTION_MODES,
23
23
  SYNTHETIC_BLOCK_WINDOW_SECONDS,
24
+ TINYBAR_SCALE,
24
25
  } from "../constants";
25
26
  import { apiClient } from "../network/api";
26
27
  import type {
27
28
  HederaAccount,
28
29
  HederaMemo,
30
+ HederaMirrorTransaction,
29
31
  HederaOperationExtra,
30
32
  HederaTxData,
31
33
  HederaValidator,
32
34
  OperationDetailsExtraField,
35
+ StakingAnalysis,
33
36
  Transaction,
34
37
  TransactionStaking,
35
38
  TransactionStatus,
@@ -493,3 +496,66 @@ export const hasSpecificIntentData = <Type extends "staking" | "erc20">(
493
496
  ): txIntent is Extract<TransactionIntent<HederaMemo, HederaTxData>, { data: { type: Type } }> => {
494
497
  return "data" in txIntent && txIntent.data.type === expectedType;
495
498
  };
499
+
500
+ export const calculateAPY = (rewardRateStart: number): number => {
501
+ const dailyRate = rewardRateStart / 10 ** TINYBAR_SCALE;
502
+ const annualRate = dailyRate * 365;
503
+
504
+ return annualRate;
505
+ };
506
+
507
+ /**
508
+ * Hedera uses the AccountUpdateTransaction for multiple purposes, including staking operations.
509
+ * Mirror node classifies all such transactions under the same name: "CRYPTOUPDATEACCOUNT".
510
+ *
511
+ * This function distinguishes between:
512
+ * - DELEGATE: Account started staking (staked_node_id changed from null to a node ID)
513
+ * - UNDELEGATE: Account stopped staking (staked_node_id changed from a node ID to null)
514
+ * - REDELEGATE: Account changed staking node (staked_node_id changed from one node to another)
515
+ *
516
+ * The analysis works by:
517
+ * 1. Fetching the account state BEFORE the transaction (using lt: timestamp filter)
518
+ * 2. Fetching the account state AFTER the transaction (using eq: timestamp filter)
519
+ * 3. Comparing the staked_node_id field to determine what changed
520
+ */
521
+ export const analyzeStakingOperation = async (
522
+ address: string,
523
+ mirrorTx: HederaMirrorTransaction,
524
+ ): Promise<StakingAnalysis | null> => {
525
+ const [accountBefore, accountAfter] = await Promise.all([
526
+ apiClient.getAccount(address, `lt:${mirrorTx.consensus_timestamp}`),
527
+ apiClient.getAccount(address, `eq:${mirrorTx.consensus_timestamp}`),
528
+ ]);
529
+
530
+ let operationType: OperationType | null = null;
531
+ const previousStakingNodeId = accountBefore.staked_node_id;
532
+ const targetStakingNodeId = accountAfter.staked_node_id;
533
+
534
+ // stake: node id changed from null -> not null
535
+ if (previousStakingNodeId === null && targetStakingNodeId !== null) {
536
+ operationType = "DELEGATE";
537
+ }
538
+ // unstake: node id changed from not null -> null
539
+ else if (previousStakingNodeId !== null && targetStakingNodeId === null) {
540
+ operationType = "UNDELEGATE";
541
+ }
542
+ // restake: node id changed from not null -> different not null
543
+ else if (
544
+ previousStakingNodeId !== null &&
545
+ targetStakingNodeId !== null &&
546
+ previousStakingNodeId !== targetStakingNodeId
547
+ ) {
548
+ operationType = "REDELEGATE";
549
+ }
550
+
551
+ if (!operationType) {
552
+ return null;
553
+ }
554
+
555
+ return {
556
+ operationType,
557
+ previousStakingNodeId,
558
+ targetStakingNodeId,
559
+ stakedAmount: BigInt(accountAfter.balance.balance), // always entire balance on Hedera (fully liquid)
560
+ };
561
+ };
@@ -124,6 +124,8 @@ describe("getAccountTransactions", () => {
124
124
  });
125
125
 
126
126
  describe("getAccount", () => {
127
+ const mockAddress = "0.0.1234";
128
+
127
129
  beforeEach(() => {
128
130
  jest.resetAllMocks();
129
131
  });
@@ -131,7 +133,7 @@ describe("getAccount", () => {
131
133
  it("should call the correct endpoint and return account data", async () => {
132
134
  mockedNetwork.mockResolvedValueOnce(
133
135
  getMockResponse({
134
- account: "0.0.1234",
136
+ account: mockAddress,
135
137
  max_automatic_token_associations: 0,
136
138
  balance: {
137
139
  balance: 1000,
@@ -141,13 +143,29 @@ describe("getAccount", () => {
141
143
  }),
142
144
  );
143
145
 
144
- const result = await apiClient.getAccount("0.0.1234");
146
+ const result = await apiClient.getAccount(mockAddress);
145
147
  const requestUrl = mockedNetwork.mock.calls[0][0].url;
146
148
 
147
- expect(result.account).toEqual("0.0.1234");
148
- expect(requestUrl).toContain("/api/v1/accounts/0.0.1234");
149
+ expect(result.account).toEqual(mockAddress);
150
+ expect(requestUrl).toContain(`/api/v1/accounts/${mockAddress}?transactions=false`);
149
151
  expect(mockedNetwork).toHaveBeenCalledTimes(1);
150
152
  });
153
+
154
+ it("supports timestamp filter", async () => {
155
+ const mockAccount = { account: mockAddress, staked_node_id: null };
156
+ const timestamp = "lt:1762202064.065172388";
157
+
158
+ (network as jest.Mock).mockResolvedValueOnce({ data: mockAccount });
159
+
160
+ const result = await apiClient.getAccount(mockAddress, timestamp);
161
+ const requestUrl = mockedNetwork.mock.calls[0][0].url;
162
+
163
+ expect(mockedNetwork).toHaveBeenCalledTimes(1);
164
+ expect(result).toEqual(mockAccount);
165
+ expect(requestUrl).toContain(
166
+ `/api/v1/accounts/${mockAddress}?transactions=false&timestamp=${encodeURIComponent(timestamp)}`,
167
+ );
168
+ });
151
169
  });
152
170
 
153
171
  describe("getAccountTokens", () => {
@@ -536,17 +554,17 @@ describe("getNodes", () => {
536
554
  }),
537
555
  );
538
556
 
539
- const result = await apiClient.getNodes();
557
+ const result = await apiClient.getNodes({ fetchAllPages: true });
540
558
  const requestUrl = mockedNetwork.mock.calls[0][0].url;
541
559
 
542
- expect(result.map(n => n.node_id)).toEqual([0, 1]);
560
+ expect(result.nodes.map(n => n.node_id)).toEqual([0, 1]);
543
561
  expect(requestUrl).toContain("/api/v1/network/nodes");
544
562
  expect(requestUrl).toContain("limit=100");
545
563
  expect(requestUrl).toContain("order=desc");
546
564
  expect(mockedNetwork).toHaveBeenCalledTimes(1);
547
565
  });
548
566
 
549
- it("should keep fetching if links.next is present and new nodes are returned", async () => {
567
+ it("should keep fetching if fetchAllPages and links.next is present", async () => {
550
568
  mockedNetwork
551
569
  .mockResolvedValueOnce(
552
570
  getMockResponse({
@@ -567,9 +585,37 @@ describe("getNodes", () => {
567
585
  }),
568
586
  );
569
587
 
570
- const result = await apiClient.getNodes();
588
+ const result = await apiClient.getNodes({ fetchAllPages: true });
571
589
 
572
- expect(result.map(n => n.node_id)).toEqual([0, 1, 2]);
590
+ expect(result.nodes.map(n => n.node_id)).toEqual([0, 1, 2]);
573
591
  expect(mockedNetwork).toHaveBeenCalledTimes(3);
574
592
  });
593
+
594
+ it("should paginate if fetchAllPages is not set", async () => {
595
+ mockedNetwork
596
+ .mockResolvedValueOnce(
597
+ getMockResponse({
598
+ nodes: [
599
+ { node_id: 0, node_account_id: "0.0.3" },
600
+ { node_id: 1, node_account_id: "0.0.4" },
601
+ ],
602
+ links: { next: "/next-1" },
603
+ }),
604
+ )
605
+ .mockResolvedValueOnce(
606
+ getMockResponse({
607
+ nodes: [{ node_id: 2, node_account_id: "0.0.5" }],
608
+ links: { next: null },
609
+ }),
610
+ );
611
+
612
+ const result = await apiClient.getNodes({
613
+ limit: 2,
614
+ fetchAllPages: false,
615
+ });
616
+
617
+ expect(result.nodes.map(tx => tx.node_id)).toEqual([0, 1]);
618
+ expect(result.nextCursor).toBe("1");
619
+ expect(mockedNetwork).toHaveBeenCalledTimes(1);
620
+ });
575
621
  });
@@ -4,6 +4,7 @@ import network from "@ledgerhq/live-network";
4
4
  import type { LiveNetworkResponse } from "@ledgerhq/live-network/network";
5
5
  import { getEnv } from "@ledgerhq/live-env";
6
6
  import { LedgerAPI4xx } from "@ledgerhq/errors";
7
+ import { HEDERA_TRANSACTION_NAMES } from "../constants";
7
8
  import { HederaAddAccountError } from "../errors";
8
9
  import type {
9
10
  HederaMirrorAccountTokensResponse,
@@ -39,11 +40,28 @@ async function getAccountsForPublicKey(publicKey: string): Promise<HederaMirrorA
39
40
  return accounts;
40
41
  }
41
42
 
42
- async function getAccount(address: string): Promise<HederaMirrorAccount> {
43
+ /**
44
+ * Fetches account information from the Hedera Mirror Node API, excluding transactions.
45
+ *
46
+ * @param address - The Hedera account ID (e.g., "0.0.12345")
47
+ * @param timestamp - Optional timestamp filter to get historical account state.
48
+ * Supports comparison operators:
49
+ * - "lt:1234567890.123456789" - state before the timestamp
50
+ * - "eq:1234567890.123456789" - state at the timestamp
51
+ * Used primarily for analyzing state changes in staking operations.
52
+ * @returns Promise resolving to account data
53
+ * @throws HederaAddAccountError if account not found (404)
54
+ */
55
+ async function getAccount(address: string, timestamp?: string): Promise<HederaMirrorAccount> {
43
56
  try {
57
+ const params = new URLSearchParams({
58
+ transactions: "false",
59
+ ...(timestamp && { timestamp }),
60
+ });
61
+
44
62
  const res = await network<HederaMirrorAccount>({
45
63
  method: "GET",
46
- url: `${API_URL}/api/v1/accounts/${address}`,
64
+ url: `${API_URL}/api/v1/accounts/${address}?${params.toString()}`,
47
65
  });
48
66
  const account = res.data;
49
67
 
@@ -57,6 +75,12 @@ async function getAccount(address: string): Promise<HederaMirrorAccount> {
57
75
  }
58
76
  }
59
77
 
78
+ // keeps old behavior when all pages are fetched
79
+ const getPaginationDirection = (fetchAllPages: boolean, order: string) => {
80
+ if (fetchAllPages) return "gt";
81
+ return order === "asc" ? "gt" : "lt";
82
+ };
83
+
60
84
  async function getAccountTransactions({
61
85
  address,
62
86
  pagingToken,
@@ -77,14 +101,8 @@ async function getAccountTransactions({
77
101
  order,
78
102
  });
79
103
 
80
- // keeps old behavior when all pages are fetched
81
- const getTimestampDirection = () => {
82
- if (fetchAllPages) return "gt";
83
- return order === "asc" ? "gt" : "lt";
84
- };
85
-
86
104
  if (pagingToken) {
87
- params.append("timestamp", `${getTimestampDirection()}:${pagingToken}`);
105
+ params.append("timestamp", `${getPaginationDirection(fetchAllPages, order)}:${pagingToken}`);
88
106
  }
89
107
 
90
108
  let nextCursor: string | null = null;
@@ -191,8 +209,11 @@ async function findTransactionByContractCall(
191
209
  url: `${API_URL}/api/v1/transactions?timestamp=${timestamp}`,
192
210
  });
193
211
  const transactions = res.data.transactions;
212
+ const relatedTx = transactions.find(
213
+ el => el.name === HEDERA_TRANSACTION_NAMES.ContractCall && el.entity_id === contractId,
214
+ );
194
215
 
195
- return transactions.find(el => el.name === "CONTRACTCALL" && el.entity_id === contractId) ?? null;
216
+ return relatedTx ?? null;
196
217
  }
197
218
 
198
219
  async function getERC20Balance(
@@ -269,13 +290,28 @@ async function getTransactionsByTimestampRange(
269
290
  return transactions;
270
291
  }
271
292
 
272
- async function getNodes(): Promise<HederaMirrorNode[]> {
293
+ async function getNodes({
294
+ cursor,
295
+ limit = 100,
296
+ order = "desc",
297
+ fetchAllPages,
298
+ }: {
299
+ cursor?: string;
300
+ limit?: number;
301
+ order?: "asc" | "desc";
302
+ fetchAllPages: boolean;
303
+ }): Promise<{ nodes: HederaMirrorNode[]; nextCursor: string | null }> {
273
304
  const nodes: HederaMirrorNode[] = [];
274
305
  const params = new URLSearchParams({
275
- order: "desc",
276
- limit: "100",
306
+ order,
307
+ limit: limit.toString(),
277
308
  });
278
309
 
310
+ if (cursor) {
311
+ params.append("node.id", `${getPaginationDirection(fetchAllPages, order)}:${cursor}`);
312
+ }
313
+
314
+ let nextCursor: string | null = null;
279
315
  let nextPath: string | null = `/api/v1/network/nodes?${params.toString()}`;
280
316
 
281
317
  while (nextPath) {
@@ -286,9 +322,25 @@ async function getNodes(): Promise<HederaMirrorNode[]> {
286
322
  const newNodes = res.data.nodes;
287
323
  nodes.push(...newNodes);
288
324
  nextPath = res.data.links.next;
325
+
326
+ // stop fetching if pagination mode is used and we reached the limit
327
+ if (!fetchAllPages && nodes.length >= limit) {
328
+ break;
329
+ }
330
+ }
331
+
332
+ // ensure we don't exceed the limit in pagination mode
333
+ if (!fetchAllPages && nodes.length > limit) {
334
+ nodes.splice(limit);
335
+ }
336
+
337
+ // set the next cursor only if we have more nodes to fetch
338
+ if (!fetchAllPages && nextPath) {
339
+ const lastNode = nodes.at(-1);
340
+ nextCursor = lastNode?.node_id?.toString() ?? null;
289
341
  }
290
342
 
291
- return nodes;
343
+ return { nodes, nextCursor };
292
344
  }
293
345
 
294
346
  export const apiClient = {
package/src/preload.ts CHANGED
@@ -12,9 +12,9 @@ export const getPreloadStrategy = () => ({
12
12
 
13
13
  export async function preload(currency: CryptoCurrency): Promise<HederaPreloadData> {
14
14
  log("hedera/preload", "preloading hedera data...");
15
- const nodes = await apiClient.getNodes();
15
+ const result = await apiClient.getNodes({ fetchAllPages: true });
16
16
 
17
- const validators: HederaValidator[] = nodes.map(mirrorNode => {
17
+ const validators: HederaValidator[] = result.nodes.map(mirrorNode => {
18
18
  const minStake = new BigNumber(mirrorNode.min_stake);
19
19
  const maxStake = new BigNumber(mirrorNode.max_stake);
20
20
  const activeStake = new BigNumber(mirrorNode.stake_rewarded);
@@ -140,4 +140,12 @@ export const MAINNET_TEST_ACCOUNTS = {
140
140
  associatedTokenWithoutBalance: "0.0.7243470",
141
141
  notAssociatedToken: "0.0.3176721",
142
142
  },
143
+ activeStaking: {
144
+ accountId: "0.0.8835924",
145
+ publicKey: "34e26415574250721e8869bd33ea2678c2bbccff5fc70bd8b0ec9239295fd2cf",
146
+ },
147
+ inactiveStaking: {
148
+ accountId: "0.0.9806001",
149
+ publicKey: "0283ef0997da7161c9a3aec45c57f4e074cb67916c97c1e5339d9f988e702e0450",
150
+ },
143
151
  };
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ HederaMirrorAccount,
2
3
  HederaMirrorCoinTransfer,
3
4
  HederaMirrorToken,
4
5
  HederaMirrorTokenTransfer,
@@ -17,6 +18,23 @@ export const getMockedMirrorToken = (overrides?: Partial<HederaMirrorToken>): He
17
18
  };
18
19
  };
19
20
 
21
+ export const getMockedMirrorAccount = (
22
+ overrides?: Partial<HederaMirrorAccount>,
23
+ ): HederaMirrorAccount => {
24
+ return {
25
+ account: "0.0.12345",
26
+ balance: {
27
+ balance: 1000,
28
+ timestamp: "1764932745.835883000",
29
+ tokens: [],
30
+ },
31
+ max_automatic_token_associations: -1,
32
+ pending_reward: 0,
33
+ staked_node_id: null,
34
+ ...overrides,
35
+ };
36
+ };
37
+
20
38
  export const createMirrorCoinTransfer = (
21
39
  account: string,
22
40
  amount: number,
@@ -171,6 +171,7 @@ export type HederaOperationExtra = {
171
171
  memo?: string | null;
172
172
  targetStakingNodeId?: number | null;
173
173
  previousStakingNodeId?: number | null;
174
+ stakedAmount?: BigNumber;
174
175
  };
175
176
 
176
177
  export type HederaValidator = {
@@ -47,3 +47,10 @@ export interface OperationDetailsExtraField {
47
47
  key: string;
48
48
  value: string | number;
49
49
  }
50
+
51
+ export interface StakingAnalysis {
52
+ operationType: OperationType;
53
+ targetStakingNodeId: number | null;
54
+ previousStakingNodeId: number | null;
55
+ stakedAmount: bigint;
56
+ }