@ledgerhq/coin-hedera 1.16.0-nightly.20251212024049 → 1.16.0-nightly.20251215100948
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -10
- package/lib/api/index.d.ts.map +1 -1
- package/lib/api/index.js +4 -3
- package/lib/api/index.js.map +1 -1
- package/lib/bridge/buildOptimisticOperation.d.ts.map +1 -1
- package/lib/bridge/buildOptimisticOperation.js +14 -43
- package/lib/bridge/buildOptimisticOperation.js.map +1 -1
- package/lib/constants.d.ts +11 -6
- package/lib/constants.d.ts.map +1 -1
- package/lib/constants.js +20 -1
- package/lib/constants.js.map +1 -1
- package/lib/deviceTransactionConfig.d.ts.map +1 -1
- package/lib/deviceTransactionConfig.js +1 -3
- package/lib/deviceTransactionConfig.js.map +1 -1
- package/lib/logic/getBalance.d.ts.map +1 -1
- package/lib/logic/getBalance.js +21 -4
- package/lib/logic/getBalance.js.map +1 -1
- package/lib/logic/getBlock.d.ts.map +1 -1
- package/lib/logic/getBlock.js +39 -2
- package/lib/logic/getBlock.js.map +1 -1
- package/lib/logic/getValidators.d.ts +3 -0
- package/lib/logic/getValidators.d.ts.map +1 -0
- package/lib/logic/getValidators.js +24 -0
- package/lib/logic/getValidators.js.map +1 -0
- package/lib/logic/index.d.ts +1 -0
- package/lib/logic/index.d.ts.map +1 -1
- package/lib/logic/index.js +3 -1
- package/lib/logic/index.js.map +1 -1
- package/lib/logic/listOperations.d.ts.map +1 -1
- package/lib/logic/listOperations.js +16 -2
- package/lib/logic/listOperations.js.map +1 -1
- package/lib/logic/utils.d.ts +17 -1
- package/lib/logic/utils.d.ts.map +1 -1
- package/lib/logic/utils.js +54 -1
- package/lib/logic/utils.js.map +1 -1
- package/lib/network/api.d.ts +22 -2
- package/lib/network/api.d.ts.map +1 -1
- package/lib/network/api.js +49 -14
- package/lib/network/api.js.map +1 -1
- package/lib/preload.js +2 -2
- package/lib/preload.js.map +1 -1
- package/lib/test/fixtures/account.fixture.d.ts +8 -0
- package/lib/test/fixtures/account.fixture.d.ts.map +1 -1
- package/lib/test/fixtures/account.fixture.js +8 -0
- package/lib/test/fixtures/account.fixture.js.map +1 -1
- package/lib/test/fixtures/mirror.fixture.d.ts +2 -1
- package/lib/test/fixtures/mirror.fixture.d.ts.map +1 -1
- package/lib/test/fixtures/mirror.fixture.js +16 -1
- package/lib/test/fixtures/mirror.fixture.js.map +1 -1
- package/lib/types/bridge.d.ts +1 -0
- package/lib/types/bridge.d.ts.map +1 -1
- package/lib/types/logic.d.ts +6 -0
- package/lib/types/logic.d.ts.map +1 -1
- package/lib-es/api/index.d.ts.map +1 -1
- package/lib-es/api/index.js +5 -4
- package/lib-es/api/index.js.map +1 -1
- package/lib-es/bridge/buildOptimisticOperation.d.ts.map +1 -1
- package/lib-es/bridge/buildOptimisticOperation.js +15 -44
- package/lib-es/bridge/buildOptimisticOperation.js.map +1 -1
- package/lib-es/constants.d.ts +11 -6
- package/lib-es/constants.d.ts.map +1 -1
- package/lib-es/constants.js +19 -0
- package/lib-es/constants.js.map +1 -1
- package/lib-es/deviceTransactionConfig.d.ts.map +1 -1
- package/lib-es/deviceTransactionConfig.js +2 -4
- package/lib-es/deviceTransactionConfig.js.map +1 -1
- package/lib-es/logic/getBalance.d.ts.map +1 -1
- package/lib-es/logic/getBalance.js +21 -4
- package/lib-es/logic/getBalance.js.map +1 -1
- package/lib-es/logic/getBlock.d.ts.map +1 -1
- package/lib-es/logic/getBlock.js +40 -3
- package/lib-es/logic/getBlock.js.map +1 -1
- package/lib-es/logic/getValidators.d.ts +3 -0
- package/lib-es/logic/getValidators.d.ts.map +1 -0
- package/lib-es/logic/getValidators.js +20 -0
- package/lib-es/logic/getValidators.js.map +1 -0
- package/lib-es/logic/index.d.ts +1 -0
- package/lib-es/logic/index.d.ts.map +1 -1
- package/lib-es/logic/index.js +1 -0
- package/lib-es/logic/index.js.map +1 -1
- package/lib-es/logic/listOperations.d.ts.map +1 -1
- package/lib-es/logic/listOperations.js +17 -3
- package/lib-es/logic/listOperations.js.map +1 -1
- package/lib-es/logic/utils.d.ts +17 -1
- package/lib-es/logic/utils.d.ts.map +1 -1
- package/lib-es/logic/utils.js +52 -1
- package/lib-es/logic/utils.js.map +1 -1
- package/lib-es/network/api.d.ts +22 -2
- package/lib-es/network/api.d.ts.map +1 -1
- package/lib-es/network/api.js +49 -14
- package/lib-es/network/api.js.map +1 -1
- package/lib-es/preload.js +2 -2
- package/lib-es/preload.js.map +1 -1
- package/lib-es/test/fixtures/account.fixture.d.ts +8 -0
- package/lib-es/test/fixtures/account.fixture.d.ts.map +1 -1
- package/lib-es/test/fixtures/account.fixture.js +8 -0
- package/lib-es/test/fixtures/account.fixture.js.map +1 -1
- package/lib-es/test/fixtures/mirror.fixture.d.ts +2 -1
- package/lib-es/test/fixtures/mirror.fixture.d.ts.map +1 -1
- package/lib-es/test/fixtures/mirror.fixture.js +14 -0
- package/lib-es/test/fixtures/mirror.fixture.js.map +1 -1
- package/lib-es/types/bridge.d.ts +1 -0
- package/lib-es/types/bridge.d.ts.map +1 -1
- package/lib-es/types/logic.d.ts +6 -0
- package/lib-es/types/logic.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/api/index.integ.test.ts +226 -1
- package/src/api/index.test.ts +5 -2
- package/src/api/index.ts +5 -5
- package/src/bridge/{buildOptimisticOperation.integration.test.ts → buildOptimisticOperation.test.ts} +23 -68
- package/src/bridge/buildOptimisticOperation.ts +16 -45
- package/src/constants.ts +23 -1
- package/src/deviceTransactionConfig.test.ts +59 -43
- package/src/deviceTransactionConfig.ts +2 -5
- package/src/logic/getBalance.test.ts +50 -0
- package/src/logic/getBalance.ts +21 -4
- package/src/logic/getBlock.test.ts +283 -1
- package/src/logic/getBlock.ts +57 -6
- package/src/logic/getValidators.test.ts +50 -0
- package/src/logic/getValidators.ts +22 -0
- package/src/logic/index.ts +1 -0
- package/src/logic/listOperations.ts +33 -3
- package/src/logic/utils.test.ts +113 -0
- package/src/logic/utils.ts +67 -1
- package/src/network/api.test.ts +55 -9
- package/src/network/api.ts +66 -14
- package/src/preload.ts +2 -2
- package/src/test/fixtures/account.fixture.ts +8 -0
- package/src/test/fixtures/mirror.fixture.ts +18 -0
- package/src/types/bridge.ts +1 -0
- 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 {
|
|
12
|
-
|
|
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
|
-
|
|
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);
|
package/src/logic/utils.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/logic/utils.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/network/api.test.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
146
|
+
const result = await apiClient.getAccount(mockAddress);
|
|
145
147
|
const requestUrl = mockedNetwork.mock.calls[0][0].url;
|
|
146
148
|
|
|
147
|
-
expect(result.account).toEqual(
|
|
148
|
-
expect(requestUrl).toContain(
|
|
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×tamp=${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
|
|
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
|
});
|
package/src/network/api.ts
CHANGED
|
@@ -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
|
-
|
|
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", `${
|
|
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
|
|
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(
|
|
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
|
|
276
|
-
limit:
|
|
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
|
|
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,
|
package/src/types/bridge.ts
CHANGED
package/src/types/logic.ts
CHANGED
|
@@ -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
|
+
}
|