@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.
- package/CHANGELOG.md +14 -10
- package/lib/bridge/synchronisation.js +1 -1
- package/lib/bridge/synchronisation.js.map +1 -1
- package/lib/bridge/utils.d.ts.map +1 -1
- package/lib/bridge/utils.js +4 -2
- package/lib/bridge/utils.js.map +1 -1
- package/lib/constants.d.ts +4 -0
- package/lib/constants.d.ts.map +1 -1
- package/lib/constants.js +12 -1
- package/lib/constants.js.map +1 -1
- package/lib/logic/estimateFees.d.ts.map +1 -1
- package/lib/logic/estimateFees.js +4 -2
- package/lib/logic/estimateFees.js.map +1 -1
- package/lib/logic/getBlock.d.ts.map +1 -1
- package/lib/logic/getBlock.js +4 -1
- package/lib/logic/getBlock.js.map +1 -1
- package/lib/logic/utils.d.ts +31 -4
- package/lib/logic/utils.d.ts.map +1 -1
- package/lib/logic/utils.js +57 -9
- package/lib/logic/utils.js.map +1 -1
- package/lib/network/api.d.ts +5 -1
- package/lib/network/api.d.ts.map +1 -1
- package/lib/network/api.js +4 -3
- package/lib/network/api.js.map +1 -1
- package/lib/network/thirdweb.js +1 -1
- package/lib/network/thirdweb.js.map +1 -1
- package/lib/test/fixtures/account.fixture.d.ts +3 -0
- package/lib/test/fixtures/account.fixture.d.ts.map +1 -1
- package/lib/test/fixtures/account.fixture.js +3 -0
- package/lib/test/fixtures/account.fixture.js.map +1 -1
- package/lib/test/fixtures/mirror.fixture.d.ts.map +1 -1
- package/lib/test/fixtures/mirror.fixture.js +1 -0
- package/lib/test/fixtures/mirror.fixture.js.map +1 -1
- package/lib/types/mirror.d.ts +1 -0
- package/lib/types/mirror.d.ts.map +1 -1
- package/lib-es/bridge/synchronisation.js +1 -1
- package/lib-es/bridge/synchronisation.js.map +1 -1
- package/lib-es/bridge/utils.d.ts.map +1 -1
- package/lib-es/bridge/utils.js +4 -2
- package/lib-es/bridge/utils.js.map +1 -1
- package/lib-es/constants.d.ts +4 -0
- package/lib-es/constants.d.ts.map +1 -1
- package/lib-es/constants.js +11 -0
- package/lib-es/constants.js.map +1 -1
- package/lib-es/logic/estimateFees.d.ts.map +1 -1
- package/lib-es/logic/estimateFees.js +4 -2
- package/lib-es/logic/estimateFees.js.map +1 -1
- package/lib-es/logic/getBlock.d.ts.map +1 -1
- package/lib-es/logic/getBlock.js +4 -1
- package/lib-es/logic/getBlock.js.map +1 -1
- package/lib-es/logic/utils.d.ts +31 -4
- package/lib-es/logic/utils.d.ts.map +1 -1
- package/lib-es/logic/utils.js +56 -9
- package/lib-es/logic/utils.js.map +1 -1
- package/lib-es/network/api.d.ts +5 -1
- package/lib-es/network/api.d.ts.map +1 -1
- package/lib-es/network/api.js +4 -3
- package/lib-es/network/api.js.map +1 -1
- package/lib-es/network/thirdweb.js +1 -1
- package/lib-es/network/thirdweb.js.map +1 -1
- package/lib-es/test/fixtures/account.fixture.d.ts +3 -0
- package/lib-es/test/fixtures/account.fixture.d.ts.map +1 -1
- package/lib-es/test/fixtures/account.fixture.js +3 -0
- package/lib-es/test/fixtures/account.fixture.js.map +1 -1
- package/lib-es/test/fixtures/mirror.fixture.d.ts.map +1 -1
- package/lib-es/test/fixtures/mirror.fixture.js +1 -0
- package/lib-es/test/fixtures/mirror.fixture.js.map +1 -1
- package/lib-es/types/mirror.d.ts +1 -0
- package/lib-es/types/mirror.d.ts.map +1 -1
- package/package.json +11 -10
- package/src/api/index.integ.test.ts +48 -6
- package/src/bridge/synchronisation.ts +1 -1
- package/src/bridge/utils.integration.test.ts +1 -2
- package/src/bridge/utils.test.ts +1 -1
- package/src/bridge/utils.ts +4 -2
- package/src/constants.ts +12 -0
- package/src/logic/estimateFees.test.ts +8 -2
- package/src/logic/estimateFees.ts +4 -2
- package/src/logic/getBlock.test.ts +4 -4
- package/src/logic/getBlock.ts +4 -1
- package/src/logic/utils.test.ts +197 -13
- package/src/logic/utils.ts +71 -8
- package/src/network/api.test.ts +36 -17
- package/src/network/api.ts +12 -6
- package/src/network/thirdweb.test.ts +8 -0
- package/src/network/thirdweb.ts +1 -1
- package/src/test/fixtures/account.fixture.ts +3 -0
- package/src/test/fixtures/mirror.fixture.ts +1 -0
- 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 =
|
|
25
|
-
|
|
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 () => {
|
package/src/logic/getBlock.ts
CHANGED
|
@@ -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(
|
|
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.
|
package/src/logic/utils.test.ts
CHANGED
|
@@ -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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
615
|
-
|
|
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.
|
|
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 });
|
package/src/logic/utils.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
368
|
-
*
|
|
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
|
|
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
|
|
376
|
-
|
|
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(
|
|
622
|
+
stakedAmount: BigInt(actualStakedAmount.toString()), // always entire balance on Hedera (fully liquid)
|
|
560
623
|
};
|
|
561
624
|
};
|
package/src/network/api.test.ts
CHANGED
|
@@ -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(
|
|
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);
|
package/src/network/api.ts
CHANGED
|
@@ -262,18 +262,24 @@ async function estimateContractCallGas(
|
|
|
262
262
|
return new BigNumber(res.data.result);
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
async function getTransactionsByTimestampRange(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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",
|
|
276
|
-
params.append("timestamp",
|
|
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 () => {
|
package/src/network/thirdweb.ts
CHANGED
|
@@ -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",
|