@ledgerhq/coin-hedera 1.16.0-nightly.20251217023943 → 1.16.0-nightly.20251219024040
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/.turbo/turbo-build.log +1 -1
- package/.unimportedrc.json +2 -1
- package/CHANGELOG.md +38 -10
- 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/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 +27 -0
- package/lib/logic/utils.d.ts.map +1 -1
- package/lib/logic/utils.js +51 -3
- 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/supportedFeatures.d.ts +3 -0
- package/lib/supportedFeatures.d.ts.map +1 -0
- package/lib/supportedFeatures.js +7 -0
- package/lib/supportedFeatures.js.map +1 -0
- 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-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/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 +27 -0
- package/lib-es/logic/utils.d.ts.map +1 -1
- package/lib-es/logic/utils.js +50 -3
- 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/supportedFeatures.d.ts +3 -0
- package/lib-es/supportedFeatures.d.ts.map +1 -0
- package/lib-es/supportedFeatures.js +4 -0
- package/lib-es/supportedFeatures.js.map +1 -0
- 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/package.json +11 -10
- package/src/api/index.integ.test.ts +48 -6
- package/src/constants.ts +12 -0
- package/src/logic/getBlock.test.ts +4 -4
- package/src/logic/getBlock.ts +4 -1
- package/src/logic/utils.test.ts +178 -8
- package/src/logic/utils.ts +64 -2
- package/src/network/api.test.ts +36 -17
- package/src/network/api.ts +12 -6
- package/src/supportedFeatures.ts +5 -0
- package/src/test/fixtures/account.fixture.ts +3 -0
|
@@ -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", () => {
|
|
@@ -970,6 +974,123 @@ describe("logic utils", () => {
|
|
|
970
974
|
});
|
|
971
975
|
});
|
|
972
976
|
|
|
977
|
+
describe("calculateUncommittedBalanceChange", () => {
|
|
978
|
+
const mockAddress = "0.0.12345";
|
|
979
|
+
const mockStartTimestamp = "1762202064.065172388";
|
|
980
|
+
const mockEndTimestamp = "1762202074.065172388";
|
|
981
|
+
|
|
982
|
+
beforeEach(() => {
|
|
983
|
+
jest.clearAllMocks();
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
it("should return 0 when there are no transactions in the time range", async () => {
|
|
987
|
+
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
|
|
988
|
+
|
|
989
|
+
const result = await calculateUncommittedBalanceChange({
|
|
990
|
+
address: mockAddress,
|
|
991
|
+
startTimestamp: mockStartTimestamp,
|
|
992
|
+
endTimestamp: mockEndTimestamp,
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
expect(result).toEqual(new BigNumber(0));
|
|
996
|
+
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledTimes(1);
|
|
997
|
+
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledWith({
|
|
998
|
+
address: mockAddress,
|
|
999
|
+
startTimestamp: `gt:${mockStartTimestamp}`,
|
|
1000
|
+
endTimestamp: `lte:${mockEndTimestamp}`,
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("should calculate balance change with mixed incoming and outgoing transfers", async () => {
|
|
1005
|
+
const mockTransactions = [
|
|
1006
|
+
{
|
|
1007
|
+
consensus_timestamp: "1762202065.000000000",
|
|
1008
|
+
transfers: [
|
|
1009
|
+
{ account: mockAddress, amount: 2000 },
|
|
1010
|
+
{ account: "0.0.98", amount: -2000 },
|
|
1011
|
+
],
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
consensus_timestamp: "1762202070.000000000",
|
|
1015
|
+
transfers: [
|
|
1016
|
+
{ account: mockAddress, amount: -500 },
|
|
1017
|
+
{ account: "0.0.99", amount: 500 },
|
|
1018
|
+
],
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
consensus_timestamp: "1762202072.000000000",
|
|
1022
|
+
transfers: [
|
|
1023
|
+
{ account: mockAddress, amount: 300 },
|
|
1024
|
+
{ account: "0.0.100", amount: -300 },
|
|
1025
|
+
],
|
|
1026
|
+
},
|
|
1027
|
+
] as HederaMirrorTransaction[];
|
|
1028
|
+
|
|
1029
|
+
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce(
|
|
1030
|
+
mockTransactions,
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
const result = await calculateUncommittedBalanceChange({
|
|
1034
|
+
address: mockAddress,
|
|
1035
|
+
startTimestamp: mockStartTimestamp,
|
|
1036
|
+
endTimestamp: mockEndTimestamp,
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
expect(result).toEqual(new BigNumber(1800)); // 2000 - 500 + 300
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it("should ignore transfers for other accounts", async () => {
|
|
1043
|
+
const mockTransactions = [
|
|
1044
|
+
{
|
|
1045
|
+
consensus_timestamp: "1762202065.000000000",
|
|
1046
|
+
transfers: [
|
|
1047
|
+
{ account: "0.0.98", amount: 5000 },
|
|
1048
|
+
{ account: "0.0.99", amount: -5000 },
|
|
1049
|
+
],
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
consensus_timestamp: "1762202070.000000000",
|
|
1053
|
+
transfers: [
|
|
1054
|
+
{ account: mockAddress, amount: 1000 },
|
|
1055
|
+
{ account: "0.0.100", amount: -1000 },
|
|
1056
|
+
],
|
|
1057
|
+
},
|
|
1058
|
+
] as HederaMirrorTransaction[];
|
|
1059
|
+
|
|
1060
|
+
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce(
|
|
1061
|
+
mockTransactions,
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
const result = await calculateUncommittedBalanceChange({
|
|
1065
|
+
address: mockAddress,
|
|
1066
|
+
startTimestamp: mockStartTimestamp,
|
|
1067
|
+
endTimestamp: mockEndTimestamp,
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
expect(result).toEqual(new BigNumber(1000));
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it("should return 0 when timestamps are equal or invalid", async () => {
|
|
1074
|
+
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
|
|
1075
|
+
|
|
1076
|
+
const [resultEqual, resultInvalid] = await Promise.all([
|
|
1077
|
+
calculateUncommittedBalanceChange({
|
|
1078
|
+
address: mockAddress,
|
|
1079
|
+
startTimestamp: mockStartTimestamp,
|
|
1080
|
+
endTimestamp: mockStartTimestamp,
|
|
1081
|
+
}),
|
|
1082
|
+
calculateUncommittedBalanceChange({
|
|
1083
|
+
address: mockAddress,
|
|
1084
|
+
startTimestamp: mockEndTimestamp,
|
|
1085
|
+
endTimestamp: mockStartTimestamp,
|
|
1086
|
+
}),
|
|
1087
|
+
]);
|
|
1088
|
+
|
|
1089
|
+
expect(resultEqual).toEqual(new BigNumber(0));
|
|
1090
|
+
expect(resultInvalid).toEqual(new BigNumber(0));
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
|
|
973
1094
|
describe("analyzeStakingOperation", () => {
|
|
974
1095
|
const mockAddress = "0.0.12345";
|
|
975
1096
|
const mockTimestamp = "1762202064.065172388";
|
|
@@ -979,13 +1100,14 @@ describe("logic utils", () => {
|
|
|
979
1100
|
} as HederaMirrorTransaction;
|
|
980
1101
|
|
|
981
1102
|
beforeEach(() => {
|
|
982
|
-
jest.
|
|
1103
|
+
jest.resetAllMocks();
|
|
983
1104
|
});
|
|
984
1105
|
|
|
985
1106
|
it("detects DELEGATE operation when staking starts", async () => {
|
|
986
1107
|
const accountBefore = getMockedMirrorAccount({ staked_node_id: null });
|
|
987
1108
|
const accountAfter = getMockedMirrorAccount({ staked_node_id: 5 });
|
|
988
1109
|
|
|
1110
|
+
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
|
|
989
1111
|
(apiClient.getAccount as jest.Mock)
|
|
990
1112
|
.mockResolvedValueOnce(accountBefore)
|
|
991
1113
|
.mockResolvedValueOnce(accountAfter);
|
|
@@ -998,6 +1120,7 @@ describe("logic utils", () => {
|
|
|
998
1120
|
targetStakingNodeId: 5,
|
|
999
1121
|
stakedAmount: BigInt(1000),
|
|
1000
1122
|
});
|
|
1123
|
+
expect(apiClient.getAccount).toHaveBeenCalledTimes(2);
|
|
1001
1124
|
expect(apiClient.getAccount).toHaveBeenCalledWith(mockAddress, `lt:${mockTimestamp}`);
|
|
1002
1125
|
expect(apiClient.getAccount).toHaveBeenCalledWith(mockAddress, `eq:${mockTimestamp}`);
|
|
1003
1126
|
});
|
|
@@ -1006,6 +1129,7 @@ describe("logic utils", () => {
|
|
|
1006
1129
|
const accountBefore = getMockedMirrorAccount({ staked_node_id: 5 });
|
|
1007
1130
|
const accountAfter = getMockedMirrorAccount({ staked_node_id: null });
|
|
1008
1131
|
|
|
1132
|
+
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
|
|
1009
1133
|
(apiClient.getAccount as jest.Mock)
|
|
1010
1134
|
.mockResolvedValueOnce(accountBefore)
|
|
1011
1135
|
.mockResolvedValueOnce(accountAfter);
|
|
@@ -1024,6 +1148,7 @@ describe("logic utils", () => {
|
|
|
1024
1148
|
const accountBefore = getMockedMirrorAccount({ staked_node_id: 3 });
|
|
1025
1149
|
const accountAfter = getMockedMirrorAccount({ staked_node_id: 10 });
|
|
1026
1150
|
|
|
1151
|
+
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce([]);
|
|
1027
1152
|
(apiClient.getAccount as jest.Mock)
|
|
1028
1153
|
.mockResolvedValueOnce(accountBefore)
|
|
1029
1154
|
.mockResolvedValueOnce(accountAfter);
|
|
@@ -1038,6 +1163,51 @@ describe("logic utils", () => {
|
|
|
1038
1163
|
});
|
|
1039
1164
|
});
|
|
1040
1165
|
|
|
1166
|
+
it("calculates correct staked amount with uncommitted transactions", async () => {
|
|
1167
|
+
const mockBalance = { balance: 1000000, timestamp: "1762202060.000000000", tokens: [] };
|
|
1168
|
+
const mockAccountBefore = getMockedMirrorAccount({
|
|
1169
|
+
account: mockAddress,
|
|
1170
|
+
staked_node_id: null,
|
|
1171
|
+
balance: mockBalance,
|
|
1172
|
+
});
|
|
1173
|
+
const mockAccountAfter = getMockedMirrorAccount({
|
|
1174
|
+
account: mockAddress,
|
|
1175
|
+
staked_node_id: 5,
|
|
1176
|
+
balance: mockBalance,
|
|
1177
|
+
});
|
|
1178
|
+
const mockTransactionsMissingInBalance = [
|
|
1179
|
+
{
|
|
1180
|
+
consensus_timestamp: `${Math.floor(Number(mockBalance.timestamp)) + 5}.000000000`,
|
|
1181
|
+
transfers: [
|
|
1182
|
+
{ account: mockAddress, amount: -100000 },
|
|
1183
|
+
{ account: "0.0.98", amount: 100000 },
|
|
1184
|
+
],
|
|
1185
|
+
},
|
|
1186
|
+
] as HederaMirrorTransaction[];
|
|
1187
|
+
|
|
1188
|
+
(apiClient.getTransactionsByTimestampRange as jest.Mock).mockResolvedValueOnce(
|
|
1189
|
+
mockTransactionsMissingInBalance,
|
|
1190
|
+
);
|
|
1191
|
+
(apiClient.getAccount as jest.Mock)
|
|
1192
|
+
.mockResolvedValueOnce(mockAccountBefore)
|
|
1193
|
+
.mockResolvedValueOnce(mockAccountAfter);
|
|
1194
|
+
|
|
1195
|
+
const result = await analyzeStakingOperation(mockAddress, mockTx);
|
|
1196
|
+
|
|
1197
|
+
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledTimes(1);
|
|
1198
|
+
expect(apiClient.getTransactionsByTimestampRange).toHaveBeenCalledWith({
|
|
1199
|
+
address: mockAddress,
|
|
1200
|
+
startTimestamp: `gt:${mockAccountBefore.balance.timestamp}`,
|
|
1201
|
+
endTimestamp: `lte:${mockTimestamp}`,
|
|
1202
|
+
});
|
|
1203
|
+
expect(result).toEqual({
|
|
1204
|
+
operationType: "DELEGATE",
|
|
1205
|
+
previousStakingNodeId: null,
|
|
1206
|
+
targetStakingNodeId: 5,
|
|
1207
|
+
stakedAmount: BigInt(900000),
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1041
1211
|
it("returns null for regular account update (both null)", async () => {
|
|
1042
1212
|
const accountBefore = getMockedMirrorAccount({ staked_node_id: null });
|
|
1043
1213
|
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
|
|
|
@@ -504,6 +505,48 @@ export const calculateAPY = (rewardRateStart: number): number => {
|
|
|
504
505
|
return annualRate;
|
|
505
506
|
};
|
|
506
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Calculates the uncommitted balance change for an account between two timestamps.
|
|
510
|
+
*
|
|
511
|
+
* This function handles the timing mismatch between Mirror Node balance snapshots and actual transactions.
|
|
512
|
+
* Balance snapshots are taken at regular intervals, not at every transaction, so querying by exact timestamp
|
|
513
|
+
* may return a snapshot from before moment you need.
|
|
514
|
+
*
|
|
515
|
+
* @param address - Hedera account ID (e.g., "0.0.12345")
|
|
516
|
+
* @param startTimestamp - Start of the time range (exclusive, format: "1234567890.123456789")
|
|
517
|
+
* @param endTimestamp - End of the time range (inclusive, format: "1234567890.123456789")
|
|
518
|
+
* @returns The net balance change as BigInt (sum of all transfers to/from the account)
|
|
519
|
+
*/
|
|
520
|
+
export const calculateUncommittedBalanceChange = async ({
|
|
521
|
+
address,
|
|
522
|
+
startTimestamp,
|
|
523
|
+
endTimestamp,
|
|
524
|
+
}: {
|
|
525
|
+
address: string;
|
|
526
|
+
startTimestamp: string;
|
|
527
|
+
endTimestamp: string;
|
|
528
|
+
}): Promise<BigNumber> => {
|
|
529
|
+
if (Number(startTimestamp) >= Number(endTimestamp)) {
|
|
530
|
+
return new BigNumber(0);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const uncommittedTransactions = await apiClient.getTransactionsByTimestampRange({
|
|
534
|
+
address,
|
|
535
|
+
startTimestamp: `gt:${startTimestamp}`,
|
|
536
|
+
endTimestamp: `lte:${endTimestamp}`,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Sum all balance changes from transfers related to this account
|
|
540
|
+
const uncommittedBalanceChange = uncommittedTransactions.reduce((total, tx) => {
|
|
541
|
+
const transfers = tx.transfers ?? [];
|
|
542
|
+
const relevantTransfers = transfers.filter(t => t.account === address);
|
|
543
|
+
const netChange = relevantTransfers.reduce((sum, t) => sum.plus(t.amount), new BigNumber(0));
|
|
544
|
+
return total.plus(netChange);
|
|
545
|
+
}, new BigNumber(0));
|
|
546
|
+
|
|
547
|
+
return uncommittedBalanceChange;
|
|
548
|
+
};
|
|
549
|
+
|
|
507
550
|
/**
|
|
508
551
|
* Hedera uses the AccountUpdateTransaction for multiple purposes, including staking operations.
|
|
509
552
|
* Mirror node classifies all such transactions under the same name: "CRYPTOUPDATEACCOUNT".
|
|
@@ -517,6 +560,16 @@ export const calculateAPY = (rewardRateStart: number): number => {
|
|
|
517
560
|
* 1. Fetching the account state BEFORE the transaction (using lt: timestamp filter)
|
|
518
561
|
* 2. Fetching the account state AFTER the transaction (using eq: timestamp filter)
|
|
519
562
|
* 3. Comparing the staked_node_id field to determine what changed
|
|
563
|
+
* 4. Calculating the actual staked amount by replaying uncommitted transactions between
|
|
564
|
+
* the latest balance snapshot and the staking operation to handle snapshot timing mismatches
|
|
565
|
+
*
|
|
566
|
+
* @performance
|
|
567
|
+
* Makes 3 API calls per operation:
|
|
568
|
+
* - account state before
|
|
569
|
+
* - account state after
|
|
570
|
+
* - transaction history based on latest balance snapshot
|
|
571
|
+
*
|
|
572
|
+
* Batching would complicate code for minimal gain given low staking op frequency.
|
|
520
573
|
*/
|
|
521
574
|
export const analyzeStakingOperation = async (
|
|
522
575
|
address: string,
|
|
@@ -552,10 +605,19 @@ export const analyzeStakingOperation = async (
|
|
|
552
605
|
return null;
|
|
553
606
|
}
|
|
554
607
|
|
|
608
|
+
// calculate uncommitted balance changes between the last snapshot and the staking tx
|
|
609
|
+
const uncommittedBalanceChange = await calculateUncommittedBalanceChange({
|
|
610
|
+
address,
|
|
611
|
+
startTimestamp: accountAfter.balance.timestamp,
|
|
612
|
+
endTimestamp: mirrorTx.consensus_timestamp,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const actualStakedAmount = uncommittedBalanceChange.plus(accountAfter.balance.balance);
|
|
616
|
+
|
|
555
617
|
return {
|
|
556
618
|
operationType,
|
|
557
619
|
previousStakingNodeId,
|
|
558
620
|
targetStakingNodeId,
|
|
559
|
-
stakedAmount: BigInt(
|
|
621
|
+
stakedAmount: BigInt(actualStakedAmount.toString()), // always entire balance on Hedera (fully liquid)
|
|
560
622
|
};
|
|
561
623
|
};
|
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
|
|
|
@@ -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",
|