@ledgerhq/coin-hedera 1.16.0-nightly.20251218023953 → 1.16.0-nightly.20251220023811

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 (89) hide show
  1. package/CHANGELOG.md +14 -10
  2. package/lib/bridge/synchronisation.js +1 -1
  3. package/lib/bridge/synchronisation.js.map +1 -1
  4. package/lib/bridge/utils.d.ts.map +1 -1
  5. package/lib/bridge/utils.js +4 -2
  6. package/lib/bridge/utils.js.map +1 -1
  7. package/lib/constants.d.ts +4 -0
  8. package/lib/constants.d.ts.map +1 -1
  9. package/lib/constants.js +12 -1
  10. package/lib/constants.js.map +1 -1
  11. package/lib/logic/estimateFees.d.ts.map +1 -1
  12. package/lib/logic/estimateFees.js +4 -2
  13. package/lib/logic/estimateFees.js.map +1 -1
  14. package/lib/logic/getBlock.d.ts.map +1 -1
  15. package/lib/logic/getBlock.js +4 -1
  16. package/lib/logic/getBlock.js.map +1 -1
  17. package/lib/logic/utils.d.ts +31 -4
  18. package/lib/logic/utils.d.ts.map +1 -1
  19. package/lib/logic/utils.js +57 -9
  20. package/lib/logic/utils.js.map +1 -1
  21. package/lib/network/api.d.ts +5 -1
  22. package/lib/network/api.d.ts.map +1 -1
  23. package/lib/network/api.js +4 -3
  24. package/lib/network/api.js.map +1 -1
  25. package/lib/network/thirdweb.js +1 -1
  26. package/lib/network/thirdweb.js.map +1 -1
  27. package/lib/test/fixtures/account.fixture.d.ts +3 -0
  28. package/lib/test/fixtures/account.fixture.d.ts.map +1 -1
  29. package/lib/test/fixtures/account.fixture.js +3 -0
  30. package/lib/test/fixtures/account.fixture.js.map +1 -1
  31. package/lib/test/fixtures/mirror.fixture.d.ts.map +1 -1
  32. package/lib/test/fixtures/mirror.fixture.js +1 -0
  33. package/lib/test/fixtures/mirror.fixture.js.map +1 -1
  34. package/lib/types/mirror.d.ts +1 -0
  35. package/lib/types/mirror.d.ts.map +1 -1
  36. package/lib-es/bridge/synchronisation.js +1 -1
  37. package/lib-es/bridge/synchronisation.js.map +1 -1
  38. package/lib-es/bridge/utils.d.ts.map +1 -1
  39. package/lib-es/bridge/utils.js +4 -2
  40. package/lib-es/bridge/utils.js.map +1 -1
  41. package/lib-es/constants.d.ts +4 -0
  42. package/lib-es/constants.d.ts.map +1 -1
  43. package/lib-es/constants.js +11 -0
  44. package/lib-es/constants.js.map +1 -1
  45. package/lib-es/logic/estimateFees.d.ts.map +1 -1
  46. package/lib-es/logic/estimateFees.js +4 -2
  47. package/lib-es/logic/estimateFees.js.map +1 -1
  48. package/lib-es/logic/getBlock.d.ts.map +1 -1
  49. package/lib-es/logic/getBlock.js +4 -1
  50. package/lib-es/logic/getBlock.js.map +1 -1
  51. package/lib-es/logic/utils.d.ts +31 -4
  52. package/lib-es/logic/utils.d.ts.map +1 -1
  53. package/lib-es/logic/utils.js +56 -9
  54. package/lib-es/logic/utils.js.map +1 -1
  55. package/lib-es/network/api.d.ts +5 -1
  56. package/lib-es/network/api.d.ts.map +1 -1
  57. package/lib-es/network/api.js +4 -3
  58. package/lib-es/network/api.js.map +1 -1
  59. package/lib-es/network/thirdweb.js +1 -1
  60. package/lib-es/network/thirdweb.js.map +1 -1
  61. package/lib-es/test/fixtures/account.fixture.d.ts +3 -0
  62. package/lib-es/test/fixtures/account.fixture.d.ts.map +1 -1
  63. package/lib-es/test/fixtures/account.fixture.js +3 -0
  64. package/lib-es/test/fixtures/account.fixture.js.map +1 -1
  65. package/lib-es/test/fixtures/mirror.fixture.d.ts.map +1 -1
  66. package/lib-es/test/fixtures/mirror.fixture.js +1 -0
  67. package/lib-es/test/fixtures/mirror.fixture.js.map +1 -1
  68. package/lib-es/types/mirror.d.ts +1 -0
  69. package/lib-es/types/mirror.d.ts.map +1 -1
  70. package/package.json +11 -10
  71. package/src/api/index.integ.test.ts +48 -6
  72. package/src/bridge/synchronisation.ts +1 -1
  73. package/src/bridge/utils.integration.test.ts +1 -2
  74. package/src/bridge/utils.test.ts +1 -1
  75. package/src/bridge/utils.ts +4 -2
  76. package/src/constants.ts +12 -0
  77. package/src/logic/estimateFees.test.ts +8 -2
  78. package/src/logic/estimateFees.ts +4 -2
  79. package/src/logic/getBlock.test.ts +4 -4
  80. package/src/logic/getBlock.ts +4 -1
  81. package/src/logic/utils.test.ts +197 -13
  82. package/src/logic/utils.ts +71 -8
  83. package/src/network/api.test.ts +36 -17
  84. package/src/network/api.ts +12 -6
  85. package/src/network/thirdweb.test.ts +8 -0
  86. package/src/network/thirdweb.ts +1 -1
  87. package/src/test/fixtures/account.fixture.ts +3 -0
  88. package/src/test/fixtures/mirror.fixture.ts +1 -0
  89. package/src/types/mirror.ts +1 -0
@@ -96,6 +96,11 @@ describe("getEstimatedFees", () => {
96
96
  const gasPriceTinybars = new BigNumber(900);
97
97
  const transferAmount = BigInt(1000000);
98
98
 
99
+ (apiClient.getAccount as jest.Mock).mockImplementation(address => ({
100
+ address,
101
+ evm_address: "0x0000000000000000000000000000000000012345",
102
+ }));
103
+
99
104
  (apiClient.getNetworkFees as jest.Mock).mockResolvedValueOnce({
100
105
  fees: [
101
106
  {
@@ -130,11 +135,12 @@ describe("getEstimatedFees", () => {
130
135
  .multipliedBy(gasPriceTinybars)
131
136
  .integerValue(BigNumber.ROUND_CEIL);
132
137
 
138
+ expect(apiClient.getAccount).toHaveBeenCalledTimes(2);
133
139
  expect(apiClient.getNetworkFees).toHaveBeenCalledTimes(1);
134
140
  expect(apiClient.estimateContractCallGas).toHaveBeenCalledTimes(1);
135
141
  expect(apiClient.estimateContractCallGas).toHaveBeenCalledWith(
136
- toEVMAddress(senderAddress),
137
- toEVMAddress(recipientAddress),
142
+ await toEVMAddress(senderAddress),
143
+ await toEVMAddress(recipientAddress),
138
144
  mockedTokenCurrencyERC20.contractAddress,
139
145
  transferAmount,
140
146
  );
@@ -21,8 +21,10 @@ const estimateContractCallFees = async (
21
21
  let gas = new BigNumber(0);
22
22
 
23
23
  const tokenEvmAddress = "assetReference" in txIntent.asset ? txIntent.asset.assetReference : null;
24
- const senderEvmAddress = toEVMAddress(txIntent.sender);
25
- const recipientEvmAddress = toEVMAddress(txIntent.recipient);
24
+ const [senderEvmAddress, recipientEvmAddress] = await Promise.all([
25
+ toEVMAddress(txIntent.sender),
26
+ toEVMAddress(txIntent.recipient),
27
+ ]);
26
28
 
27
29
  if (!tokenEvmAddress || !senderEvmAddress || !recipientEvmAddress) {
28
30
  return {
@@ -47,10 +47,10 @@ describe("getBlock", () => {
47
47
  expect(getTimestampRangeFromBlockHeight).toHaveBeenCalledWith(42);
48
48
  expect(getBlockInfo).toHaveBeenCalledWith(42);
49
49
  expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledTimes(1);
50
- expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledWith(
51
- mockTimestampRange.start,
52
- mockTimestampRange.end,
53
- );
50
+ expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledWith({
51
+ startTimestamp: `gte:${mockTimestampRange.start}`,
52
+ endTimestamp: `lt:${mockTimestampRange.end}`,
53
+ });
54
54
  });
55
55
 
56
56
  it("should extract fee payer from transaction_id", async () => {
@@ -66,7 +66,10 @@ function createStakingRewardOperations(tx: HederaMirrorTransaction): BlockOperat
66
66
  export async function getBlock(height: number): Promise<Block> {
67
67
  const { start, end } = getTimestampRangeFromBlockHeight(height);
68
68
  const blockInfo = await getBlockInfo(height);
69
- const transactions = await apiClient.getTransactionsByTimestampRange(start, end);
69
+ const transactions = await apiClient.getTransactionsByTimestampRange({
70
+ startTimestamp: `gte:${start}`,
71
+ endTimestamp: `lt:${end}`,
72
+ });
70
73
 
71
74
  // analyze CRYPTOUPDATEACCOUNT transactions to distinguish staking operations from regular account updates.
72
75
  // this creates a map of transaction_hash -> StakingAnalysis to avoid repeated lookups.
@@ -9,6 +9,7 @@ import {
9
9
  HEDERA_OPERATION_TYPES,
10
10
  HEDERA_TRANSACTION_MODES,
11
11
  SYNTHETIC_BLOCK_WINDOW_SECONDS,
12
+ OP_TYPES_EXCLUDING_FEES,
12
13
  } from "../constants";
13
14
  import { HederaRecipientInvalidChecksum } from "../errors";
14
15
  import { apiClient } from "../network/api";
@@ -64,6 +65,7 @@ import {
64
65
  getOperationDetailsExtraFields,
65
66
  calculateAPY,
66
67
  analyzeStakingOperation,
68
+ calculateUncommittedBalanceChange,
67
69
  } from "./utils";
68
70
 
69
71
  jest.mock("../network/api");
@@ -152,14 +154,16 @@ describe("logic utils", () => {
152
154
  expect(getOperationValue({ asset: tokenAsset, operation })).toBe(BigInt(0));
153
155
  });
154
156
 
155
- it("should substract fee from value for native OUT operations", () => {
156
- const operation = getMockedOperation({
157
- type: "OUT",
158
- value: BigNumber(1000),
159
- fee: BigNumber(100),
160
- });
157
+ it("should subtract fee from native operations that exclude fees", () => {
158
+ OP_TYPES_EXCLUDING_FEES.forEach(type => {
159
+ const operation = getMockedOperation({
160
+ type,
161
+ value: BigNumber(1000),
162
+ fee: BigNumber(100),
163
+ });
161
164
 
162
- expect(getOperationValue({ asset: nativeAsset, operation })).toBe(BigInt(900));
165
+ expect(getOperationValue({ asset: nativeAsset, operation })).toBe(BigInt(900));
166
+ });
163
167
  });
164
168
 
165
169
  it("should return value for other operations", () => {
@@ -606,13 +610,27 @@ describe("logic utils", () => {
606
610
  });
607
611
 
608
612
  describe("toEVMAddress", () => {
609
- it("returns correct EVM address for valid Hedera account ID", () => {
610
- const evmAddress = toEVMAddress("0.0.12345");
611
- expect(evmAddress).toBe("0x0000000000000000000000000000000000003039");
613
+ const mockMirrorAccount = {
614
+ account: "0.0.12345",
615
+ evm_address: "0x0000000000000000000000000000000000003039",
616
+ };
617
+
618
+ it("returns correct EVM address for valid Hedera account ID", async () => {
619
+ (apiClient.getAccount as jest.Mock).mockResolvedValueOnce(mockMirrorAccount);
620
+
621
+ const evmAddress = await toEVMAddress(mockMirrorAccount.account);
622
+
623
+ expect(apiClient.getAccount).toHaveBeenCalledTimes(1);
624
+ expect(apiClient.getAccount).toHaveBeenCalledWith(mockMirrorAccount.account);
625
+ expect(evmAddress).toBe(mockMirrorAccount.evm_address);
612
626
  });
613
627
 
614
- it("returns null for invalid Hedera account ID", () => {
615
- const evmAddress = toEVMAddress("invalid_account_id");
628
+ it("returns null when API call fails", async () => {
629
+ (apiClient.getAccount as jest.Mock).mockRejectedValueOnce(new Error("API error"));
630
+
631
+ const evmAddress = await toEVMAddress(mockMirrorAccount.account);
632
+
633
+ expect(apiClient.getAccount).toHaveBeenCalledTimes(1);
616
634
  expect(evmAddress).toBeNull();
617
635
  });
618
636
  });
@@ -970,6 +988,123 @@ describe("logic utils", () => {
970
988
  });
971
989
  });
972
990
 
991
+ describe("calculateUncommittedBalanceChange", () => {
992
+ const mockAddress = "0.0.12345";
993
+ const mockStartTimestamp = "1762202064.065172388";
994
+ const mockEndTimestamp = "1762202074.065172388";
995
+
996
+ beforeEach(() => {
997
+ jest.clearAllMocks();
998
+ });
999
+
1000
+ it("should return 0 when there are no transactions in the time range", async () => {
1001
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
1002
+
1003
+ const result = await calculateUncommittedBalanceChange({
1004
+ address: mockAddress,
1005
+ startTimestamp: mockStartTimestamp,
1006
+ endTimestamp: mockEndTimestamp,
1007
+ });
1008
+
1009
+ expect(result).toEqual(new BigNumber(0));
1010
+ expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledTimes(1);
1011
+ expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledWith({
1012
+ address: mockAddress,
1013
+ startTimestamp: `gt:${mockStartTimestamp}`,
1014
+ endTimestamp: `lte:${mockEndTimestamp}`,
1015
+ });
1016
+ });
1017
+
1018
+ it("should calculate balance change with mixed incoming and outgoing transfers", async () => {
1019
+ const mockTransactions = [
1020
+ {
1021
+ consensus_timestamp: "1762202065.000000000",
1022
+ transfers: [
1023
+ { account: mockAddress, amount: 2000 },
1024
+ { account: "0.0.98", amount: -2000 },
1025
+ ],
1026
+ },
1027
+ {
1028
+ consensus_timestamp: "1762202070.000000000",
1029
+ transfers: [
1030
+ { account: mockAddress, amount: -500 },
1031
+ { account: "0.0.99", amount: 500 },
1032
+ ],
1033
+ },
1034
+ {
1035
+ consensus_timestamp: "1762202072.000000000",
1036
+ transfers: [
1037
+ { account: mockAddress, amount: 300 },
1038
+ { account: "0.0.100", amount: -300 },
1039
+ ],
1040
+ },
1041
+ ] as HederaMirrorTransaction[];
1042
+
1043
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce(
1044
+ mockTransactions,
1045
+ );
1046
+
1047
+ const result = await calculateUncommittedBalanceChange({
1048
+ address: mockAddress,
1049
+ startTimestamp: mockStartTimestamp,
1050
+ endTimestamp: mockEndTimestamp,
1051
+ });
1052
+
1053
+ expect(result).toEqual(new BigNumber(1800)); // 2000 - 500 + 300
1054
+ });
1055
+
1056
+ it("should ignore transfers for other accounts", async () => {
1057
+ const mockTransactions = [
1058
+ {
1059
+ consensus_timestamp: "1762202065.000000000",
1060
+ transfers: [
1061
+ { account: "0.0.98", amount: 5000 },
1062
+ { account: "0.0.99", amount: -5000 },
1063
+ ],
1064
+ },
1065
+ {
1066
+ consensus_timestamp: "1762202070.000000000",
1067
+ transfers: [
1068
+ { account: mockAddress, amount: 1000 },
1069
+ { account: "0.0.100", amount: -1000 },
1070
+ ],
1071
+ },
1072
+ ] as HederaMirrorTransaction[];
1073
+
1074
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce(
1075
+ mockTransactions,
1076
+ );
1077
+
1078
+ const result = await calculateUncommittedBalanceChange({
1079
+ address: mockAddress,
1080
+ startTimestamp: mockStartTimestamp,
1081
+ endTimestamp: mockEndTimestamp,
1082
+ });
1083
+
1084
+ expect(result).toEqual(new BigNumber(1000));
1085
+ });
1086
+
1087
+ it("should return 0 when timestamps are equal or invalid", async () => {
1088
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
1089
+
1090
+ const [resultEqual, resultInvalid] = await Promise.all([
1091
+ calculateUncommittedBalanceChange({
1092
+ address: mockAddress,
1093
+ startTimestamp: mockStartTimestamp,
1094
+ endTimestamp: mockStartTimestamp,
1095
+ }),
1096
+ calculateUncommittedBalanceChange({
1097
+ address: mockAddress,
1098
+ startTimestamp: mockEndTimestamp,
1099
+ endTimestamp: mockStartTimestamp,
1100
+ }),
1101
+ ]);
1102
+
1103
+ expect(resultEqual).toEqual(new BigNumber(0));
1104
+ expect(resultInvalid).toEqual(new BigNumber(0));
1105
+ });
1106
+ });
1107
+
973
1108
  describe("analyzeStakingOperation", () => {
974
1109
  const mockAddress = "0.0.12345";
975
1110
  const mockTimestamp = "1762202064.065172388";
@@ -979,13 +1114,14 @@ describe("logic utils", () => {
979
1114
  } as HederaMirrorTransaction;
980
1115
 
981
1116
  beforeEach(() => {
982
- jest.clearAllMocks();
1117
+ jest.resetAllMocks();
983
1118
  });
984
1119
 
985
1120
  it("detects DELEGATE operation when staking starts", async () => {
986
1121
  const accountBefore = getMockedMirrorAccount({ staked_node_id: null });
987
1122
  const accountAfter = getMockedMirrorAccount({ staked_node_id: 5 });
988
1123
 
1124
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
989
1125
  (apiClient.getAccount as jest.Mock)
990
1126
  .mockResolvedValueOnce(accountBefore)
991
1127
  .mockResolvedValueOnce(accountAfter);
@@ -998,6 +1134,7 @@ describe("logic utils", () => {
998
1134
  targetStakingNodeId: 5,
999
1135
  stakedAmount: BigInt(1000),
1000
1136
  });
1137
+ expect(apiClient.getAccount).toHaveBeenCalledTimes(2);
1001
1138
  expect(apiClient.getAccount).toHaveBeenCalledWith(mockAddress, `lt:${mockTimestamp}`);
1002
1139
  expect(apiClient.getAccount).toHaveBeenCalledWith(mockAddress, `eq:${mockTimestamp}`);
1003
1140
  });
@@ -1006,6 +1143,7 @@ describe("logic utils", () => {
1006
1143
  const accountBefore = getMockedMirrorAccount({ staked_node_id: 5 });
1007
1144
  const accountAfter = getMockedMirrorAccount({ staked_node_id: null });
1008
1145
 
1146
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
1009
1147
  (apiClient.getAccount as jest.Mock)
1010
1148
  .mockResolvedValueOnce(accountBefore)
1011
1149
  .mockResolvedValueOnce(accountAfter);
@@ -1024,6 +1162,7 @@ describe("logic utils", () => {
1024
1162
  const accountBefore = getMockedMirrorAccount({ staked_node_id: 3 });
1025
1163
  const accountAfter = getMockedMirrorAccount({ staked_node_id: 10 });
1026
1164
 
1165
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
1027
1166
  (apiClient.getAccount as jest.Mock)
1028
1167
  .mockResolvedValueOnce(accountBefore)
1029
1168
  .mockResolvedValueOnce(accountAfter);
@@ -1038,6 +1177,51 @@ describe("logic utils", () => {
1038
1177
  });
1039
1178
  });
1040
1179
 
1180
+ it("calculates correct staked amount with uncommitted transactions", async () => {
1181
+ const mockBalance = { balance: 1000000, timestamp: "1762202060.000000000", tokens: [] };
1182
+ const mockAccountBefore = getMockedMirrorAccount({
1183
+ account: mockAddress,
1184
+ staked_node_id: null,
1185
+ balance: mockBalance,
1186
+ });
1187
+ const mockAccountAfter = getMockedMirrorAccount({
1188
+ account: mockAddress,
1189
+ staked_node_id: 5,
1190
+ balance: mockBalance,
1191
+ });
1192
+ const mockTransactionsMissingInBalance = [
1193
+ {
1194
+ consensus_timestamp: `${Math.floor(Number(mockBalance.timestamp)) + 5}.000000000`,
1195
+ transfers: [
1196
+ { account: mockAddress, amount: -100000 },
1197
+ { account: "0.0.98", amount: 100000 },
1198
+ ],
1199
+ },
1200
+ ] as HederaMirrorTransaction[];
1201
+
1202
+ (apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce(
1203
+ mockTransactionsMissingInBalance,
1204
+ );
1205
+ (apiClient.getAccount as jest.Mock)
1206
+ .mockResolvedValueOnce(mockAccountBefore)
1207
+ .mockResolvedValueOnce(mockAccountAfter);
1208
+
1209
+ const result = await analyzeStakingOperation(mockAddress, mockTx);
1210
+
1211
+ expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledTimes(1);
1212
+ expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledWith({
1213
+ address: mockAddress,
1214
+ startTimestamp: `gt:${mockAccountBefore.balance.timestamp}`,
1215
+ endTimestamp: `lte:${mockTimestamp}`,
1216
+ });
1217
+ expect(result).toEqual({
1218
+ operationType: "DELEGATE",
1219
+ previousStakingNodeId: null,
1220
+ targetStakingNodeId: 5,
1221
+ stakedAmount: BigInt(900000),
1222
+ });
1223
+ });
1224
+
1041
1225
  it("returns null for regular account update (both null)", async () => {
1042
1226
  const accountBefore = getMockedMirrorAccount({ staked_node_id: null });
1043
1227
  const accountAfter = getMockedMirrorAccount({ staked_node_id: null });
@@ -22,6 +22,7 @@ import {
22
22
  HEDERA_TRANSACTION_MODES,
23
23
  SYNTHETIC_BLOCK_WINDOW_SECONDS,
24
24
  TINYBAR_SCALE,
25
+ OP_TYPES_EXCLUDING_FEES,
25
26
  } from "../constants";
26
27
  import { apiClient } from "../network/api";
27
28
  import type {
@@ -69,7 +70,7 @@ export const getOperationValue = ({
69
70
  return BigInt(0);
70
71
  }
71
72
 
72
- if (asset.type === "native" && operation.type === "OUT") {
73
+ if (asset.type === "native" && OP_TYPES_EXCLUDING_FEES.includes(operation.type)) {
73
74
  return BigInt(operation.value.toFixed(0)) - BigInt(operation.fee.toFixed(0));
74
75
  }
75
76
 
@@ -364,16 +365,17 @@ export const formatTransactionId = (transactionId: TransactionId): string => {
364
365
  };
365
366
 
366
367
  /**
367
- * Converts a Hedera account ID (e.g. "0.0.1234") into its corresponding EVM address in hexadecimal format.
368
- * If the conversion fails, it returns null.
368
+ * Fetches EVM address for given Hedera account ID (e.g. "0.0.1234").
369
+ * It returns null if the fetch fails.
369
370
  *
370
371
  * @param address - Hedera account ID in the format `shard.realm.num`
371
- * @returns the long-zero EVM address (`0x...`) or null if conversion fails
372
+ * @returns EVM address (`0x...`) or null if fetch fails
372
373
  */
373
- export const toEVMAddress = (accountId: string) => {
374
+ export const toEVMAddress = async (accountId: string): Promise<string | null> => {
374
375
  try {
375
- const evmAddress = "0x" + AccountId.fromString(accountId).toEvmAddress();
376
- return evmAddress;
376
+ const account = await apiClient.getAccount(accountId);
377
+
378
+ return account.evm_address;
377
379
  } catch {
378
380
  return null;
379
381
  }
@@ -504,6 +506,48 @@ export const calculateAPY = (rewardRateStart: number): number => {
504
506
  return annualRate;
505
507
  };
506
508
 
509
+ /**
510
+ * Calculates the uncommitted balance change for an account between two timestamps.
511
+ *
512
+ * This function handles the timing mismatch between Mirror Node balance snapshots and actual transactions.
513
+ * Balance snapshots are taken at regular intervals, not at every transaction, so querying by exact timestamp
514
+ * may return a snapshot from before moment you need.
515
+ *
516
+ * @param address - Hedera account ID (e.g., "0.0.12345")
517
+ * @param startTimestamp - Start of the time range (exclusive, format: "1234567890.123456789")
518
+ * @param endTimestamp - End of the time range (inclusive, format: "1234567890.123456789")
519
+ * @returns The net balance change as BigInt (sum of all transfers to/from the account)
520
+ */
521
+ export const calculateUncommittedBalanceChange = async ({
522
+ address,
523
+ startTimestamp,
524
+ endTimestamp,
525
+ }: {
526
+ address: string;
527
+ startTimestamp: string;
528
+ endTimestamp: string;
529
+ }): Promise<BigNumber> => {
530
+ if (Number(startTimestamp) >= Number(endTimestamp)) {
531
+ return new BigNumber(0);
532
+ }
533
+
534
+ const uncommittedTransactions = await apiClient.getTransactionsByTimestampRange({
535
+ address,
536
+ startTimestamp: `gt:${startTimestamp}`,
537
+ endTimestamp: `lte:${endTimestamp}`,
538
+ });
539
+
540
+ // Sum all balance changes from transfers related to this account
541
+ const uncommittedBalanceChange = uncommittedTransactions.reduce((total, tx) => {
542
+ const transfers = tx.transfers ?? [];
543
+ const relevantTransfers = transfers.filter(t => t.account === address);
544
+ const netChange = relevantTransfers.reduce((sum, t) => sum.plus(t.amount), new BigNumber(0));
545
+ return total.plus(netChange);
546
+ }, new BigNumber(0));
547
+
548
+ return uncommittedBalanceChange;
549
+ };
550
+
507
551
  /**
508
552
  * Hedera uses the AccountUpdateTransaction for multiple purposes, including staking operations.
509
553
  * Mirror node classifies all such transactions under the same name: "CRYPTOUPDATEACCOUNT".
@@ -517,6 +561,16 @@ export const calculateAPY = (rewardRateStart: number): number => {
517
561
  * 1. Fetching the account state BEFORE the transaction (using lt: timestamp filter)
518
562
  * 2. Fetching the account state AFTER the transaction (using eq: timestamp filter)
519
563
  * 3. Comparing the staked_node_id field to determine what changed
564
+ * 4. Calculating the actual staked amount by replaying uncommitted transactions between
565
+ * the latest balance snapshot and the staking operation to handle snapshot timing mismatches
566
+ *
567
+ * @performance
568
+ * Makes 3 API calls per operation:
569
+ * - account state before
570
+ * - account state after
571
+ * - transaction history based on latest balance snapshot
572
+ *
573
+ * Batching would complicate code for minimal gain given low staking op frequency.
520
574
  */
521
575
  export const analyzeStakingOperation = async (
522
576
  address: string,
@@ -552,10 +606,19 @@ export const analyzeStakingOperation = async (
552
606
  return null;
553
607
  }
554
608
 
609
+ // calculate uncommitted balance changes between the last snapshot and the staking tx
610
+ const uncommittedBalanceChange = await calculateUncommittedBalanceChange({
611
+ address,
612
+ startTimestamp: accountAfter.balance.timestamp,
613
+ endTimestamp: mirrorTx.consensus_timestamp,
614
+ });
615
+
616
+ const actualStakedAmount = uncommittedBalanceChange.plus(accountAfter.balance.balance);
617
+
555
618
  return {
556
619
  operationType,
557
620
  previousStakingNodeId,
558
621
  targetStakingNodeId,
559
- stakedAmount: BigInt(accountAfter.balance.balance), // always entire balance on Hedera (fully liquid)
622
+ stakedAmount: BigInt(actualStakedAmount.toString()), // always entire balance on Hedera (fully liquid)
560
623
  };
561
624
  };
@@ -425,14 +425,33 @@ describe("getTransactionsByTimestampRange", () => {
425
425
  jest.resetAllMocks();
426
426
  });
427
427
 
428
+ it("should include account.id query param if address is provided", async () => {
429
+ mockedNetwork.mockResolvedValueOnce(
430
+ getMockResponse({ transactions: [], links: { next: null } }),
431
+ );
432
+
433
+ await apiClient.getTransactionsByTimestampRange({
434
+ address: "0.0.1234",
435
+ startTimestamp: "gte:1000.000000000",
436
+ endTimestamp: "lt:2000.000000000",
437
+ });
438
+
439
+ const requestUrl = mockedNetwork.mock.calls[0][0].url;
440
+ expect(requestUrl).toContain("account.id=0.0.1234");
441
+ });
442
+
428
443
  it("should include correct query params with timestamp range", async () => {
429
444
  mockedNetwork.mockResolvedValueOnce(
430
445
  getMockResponse({ transactions: [], links: { next: null } }),
431
446
  );
432
447
 
433
- await apiClient.getTransactionsByTimestampRange("1000.000000000", "2000.000000000");
448
+ await apiClient.getTransactionsByTimestampRange({
449
+ startTimestamp: "gte:1000.000000000",
450
+ endTimestamp: "lt:2000.000000000",
451
+ });
434
452
 
435
453
  const requestUrl = mockedNetwork.mock.calls[0][0].url;
454
+ expect(requestUrl).not.toContain("account.id=");
436
455
  expect(requestUrl).toContain("timestamp=gte%3A1000.000000000");
437
456
  expect(requestUrl).toContain("timestamp=lt%3A2000.000000000");
438
457
  expect(requestUrl).toContain("limit=100");
@@ -444,10 +463,10 @@ describe("getTransactionsByTimestampRange", () => {
444
463
  getMockResponse({ transactions: [], links: { next: null } }),
445
464
  );
446
465
 
447
- const result = await apiClient.getTransactionsByTimestampRange(
448
- "1000.000000000",
449
- "2000.000000000",
450
- );
466
+ const result = await apiClient.getTransactionsByTimestampRange({
467
+ startTimestamp: "gte:1000.000000000",
468
+ endTimestamp: "lt:2000.000000000",
469
+ });
451
470
 
452
471
  expect(result).toEqual([]);
453
472
  expect(mockedNetwork).toHaveBeenCalledTimes(1);
@@ -464,10 +483,10 @@ describe("getTransactionsByTimestampRange", () => {
464
483
  }),
465
484
  );
466
485
 
467
- const result = await apiClient.getTransactionsByTimestampRange(
468
- "1000.000000000",
469
- "2000.000000000",
470
- );
486
+ const result = await apiClient.getTransactionsByTimestampRange({
487
+ startTimestamp: "gte:1000.000000000",
488
+ endTimestamp: "lt:2000.000000000",
489
+ });
471
490
 
472
491
  expect(result.map(tx => tx.consensus_timestamp)).toEqual(["1500.123456789", "1750.987654321"]);
473
492
  expect(mockedNetwork).toHaveBeenCalledTimes(1);
@@ -494,10 +513,10 @@ describe("getTransactionsByTimestampRange", () => {
494
513
  }),
495
514
  );
496
515
 
497
- const result = await apiClient.getTransactionsByTimestampRange(
498
- "1000.000000000",
499
- "2000.000000000",
500
- );
516
+ const result = await apiClient.getTransactionsByTimestampRange({
517
+ startTimestamp: "gte:1000.000000000",
518
+ endTimestamp: "lt:2000.000000000",
519
+ });
501
520
 
502
521
  expect(result.map(tx => tx.consensus_timestamp)).toEqual([
503
522
  "1100.000000000",
@@ -528,10 +547,10 @@ describe("getTransactionsByTimestampRange", () => {
528
547
  }),
529
548
  );
530
549
 
531
- const result = await apiClient.getTransactionsByTimestampRange(
532
- "1000.000000000",
533
- "2000.000000000",
534
- );
550
+ const result = await apiClient.getTransactionsByTimestampRange({
551
+ startTimestamp: "gte:1000.000000000",
552
+ endTimestamp: "lt:2000.000000000",
553
+ });
535
554
 
536
555
  expect(result.map(tx => tx.consensus_timestamp)).toEqual(["1100.000000000", "1300.000000000"]);
537
556
  expect(mockedNetwork).toHaveBeenCalledTimes(3);
@@ -262,18 +262,24 @@ async function estimateContractCallGas(
262
262
  return new BigNumber(res.data.result);
263
263
  }
264
264
 
265
- async function getTransactionsByTimestampRange(
266
- startTimestamp: string,
267
- endTimestamp: string,
268
- ): Promise<HederaMirrorTransaction[]> {
265
+ async function getTransactionsByTimestampRange({
266
+ address,
267
+ startTimestamp,
268
+ endTimestamp,
269
+ }: {
270
+ address?: string;
271
+ startTimestamp: `${string}:${string}`;
272
+ endTimestamp: `${string}:${string}`;
273
+ }): Promise<HederaMirrorTransaction[]> {
269
274
  const transactions: HederaMirrorTransaction[] = [];
270
275
  const params = new URLSearchParams({
271
276
  limit: "100",
272
277
  order: "desc",
278
+ ...(address && { "account.id": address }),
273
279
  });
274
280
 
275
- params.append("timestamp", `gte:${startTimestamp}`);
276
- params.append("timestamp", `lt:${endTimestamp}`);
281
+ params.append("timestamp", startTimestamp);
282
+ params.append("timestamp", endTimestamp);
277
283
 
278
284
  let nextPath: string | null = `/api/v1/transactions?${params.toString()}`;
279
285
 
@@ -1,10 +1,13 @@
1
1
  import { pad } from "viem";
2
2
  import network from "@ledgerhq/live-network";
3
+ import { apiClient } from "./api";
3
4
  import { getMockedThirdwebTransaction } from "../test/fixtures/thirdweb.fixture";
4
5
  import { getMockResponse } from "../test/fixtures/common.fixture";
5
6
  import { thirdwebClient } from "./thirdweb";
6
7
 
7
8
  jest.mock("@ledgerhq/live-network");
9
+ jest.mock("./api");
10
+
8
11
  const mockedNetwork = jest.mocked(network);
9
12
 
10
13
  const mockedERC20Transaction = getMockedThirdwebTransaction();
@@ -130,6 +133,11 @@ describe("fetchERC20Transactions", () => {
130
133
  describe("getERC20TransactionsForAccount", () => {
131
134
  beforeEach(() => {
132
135
  jest.clearAllMocks();
136
+
137
+ (apiClient.getAccount as jest.Mock).mockImplementation(address => ({
138
+ address,
139
+ evm_address: "0x0000000000000000000000000000000000012345",
140
+ }));
133
141
  });
134
142
 
135
143
  it("should return empty array without balance tokens list", async () => {
@@ -56,7 +56,7 @@ async function getERC20TransactionsForAccount({
56
56
  transactionFetcher?: typeof fetchERC20Transactions; // optional dependency injection for testing
57
57
  }): Promise<HederaThirdwebTransaction[]> {
58
58
  const allTransactions: HederaThirdwebTransaction[] = [];
59
- const evmAddress = toEVMAddress(address);
59
+ const evmAddress = await toEVMAddress(address);
60
60
 
61
61
  if (contractAddresses.length === 0) {
62
62
  return allTransactions;
@@ -140,6 +140,9 @@ export const MAINNET_TEST_ACCOUNTS = {
140
140
  associatedTokenWithoutBalance: "0.0.7243470",
141
141
  notAssociatedToken: "0.0.3176721",
142
142
  },
143
+ withQuickBalanceChanges: {
144
+ accountId: "0.0.10176637",
145
+ },
143
146
  activeStaking: {
144
147
  accountId: "0.0.8835924",
145
148
  publicKey: "34e26415574250721e8869bd33ea2678c2bbccff5fc70bd8b0ec9239295fd2cf",
@@ -23,6 +23,7 @@ export const getMockedMirrorAccount = (
23
23
  ): HederaMirrorAccount => {
24
24
  return {
25
25
  account: "0.0.12345",
26
+ evm_address: "0x0000000000000000000000000000000000012345",
26
27
  balance: {
27
28
  balance: 1000,
28
29
  timestamp: "1764932745.835883000",
@@ -46,6 +46,7 @@ export interface HederaMirrorAccount {
46
46
  max_automatic_token_associations: number;
47
47
  staked_node_id: number | null;
48
48
  pending_reward: number;
49
+ evm_address: string;
49
50
  balance: {
50
51
  balance: number;
51
52
  timestamp: string;