@ledgerhq/coin-hedera 1.16.0-nightly.20251211024123 → 1.16.0-nightly.20251213023821
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -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/bridge/index.d.ts.map +1 -1
- package/lib/bridge/index.js +2 -0
- package/lib/bridge/index.js.map +1 -1
- package/lib/bridge/validateAddress.d.ts +3 -0
- package/lib/bridge/validateAddress.d.ts.map +1 -0
- package/lib/bridge/validateAddress.js +10 -0
- package/lib/bridge/validateAddress.js.map +1 -0
- 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/bridge/index.d.ts.map +1 -1
- package/lib-es/bridge/index.js +2 -0
- package/lib-es/bridge/index.js.map +1 -1
- package/lib-es/bridge/validateAddress.d.ts +3 -0
- package/lib-es/bridge/validateAddress.d.ts.map +1 -0
- package/lib-es/bridge/validateAddress.js +6 -0
- package/lib-es/bridge/validateAddress.js.map +1 -0
- 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/bridge/index.ts +2 -0
- package/src/bridge/validateAddress.test.ts +31 -0
- package/src/bridge/validateAddress.ts +10 -0
- 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
- package/tsconfig.json +17 -12
package/src/logic/getBlock.ts
CHANGED
|
@@ -4,10 +4,19 @@ import type {
|
|
|
4
4
|
BlockOperation,
|
|
5
5
|
BlockTransaction,
|
|
6
6
|
} from "@ledgerhq/coin-framework/api/types";
|
|
7
|
+
import { HEDERA_TRANSACTION_NAMES } from "../constants";
|
|
7
8
|
import { getBlockInfo } from "./getBlockInfo";
|
|
8
9
|
import { apiClient } from "../network/api";
|
|
9
|
-
import type {
|
|
10
|
-
|
|
10
|
+
import type {
|
|
11
|
+
HederaMirrorCoinTransfer,
|
|
12
|
+
HederaMirrorTokenTransfer,
|
|
13
|
+
HederaMirrorTransaction,
|
|
14
|
+
} from "../types";
|
|
15
|
+
import {
|
|
16
|
+
getMemoFromBase64,
|
|
17
|
+
analyzeStakingOperation,
|
|
18
|
+
getTimestampRangeFromBlockHeight,
|
|
19
|
+
} from "./utils";
|
|
11
20
|
|
|
12
21
|
function toHederaAsset(
|
|
13
22
|
mirrorTransfer: HederaMirrorCoinTransfer | HederaMirrorTokenTransfer,
|
|
@@ -45,18 +54,60 @@ function toBlockOperation(
|
|
|
45
54
|
};
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
function createStakingRewardOperations(tx: HederaMirrorTransaction): BlockOperation[] {
|
|
58
|
+
return tx.staking_reward_transfers.map(rewardTransfer => ({
|
|
59
|
+
type: "transfer",
|
|
60
|
+
address: rewardTransfer.account,
|
|
61
|
+
asset: { type: "native" },
|
|
62
|
+
amount: BigInt(rewardTransfer.amount),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
48
66
|
export async function getBlock(height: number): Promise<Block> {
|
|
49
67
|
const { start, end } = getTimestampRangeFromBlockHeight(height);
|
|
50
68
|
const blockInfo = await getBlockInfo(height);
|
|
51
69
|
const transactions = await apiClient.getTransactionsByTimestampRange(start, end);
|
|
52
70
|
|
|
71
|
+
// analyze CRYPTOUPDATEACCOUNT transactions to distinguish staking operations from regular account updates.
|
|
72
|
+
// this creates a map of transaction_hash -> StakingAnalysis to avoid repeated lookups.
|
|
73
|
+
const stakingAnalyses = await Promise.all(
|
|
74
|
+
transactions
|
|
75
|
+
.filter(tx => tx.name === HEDERA_TRANSACTION_NAMES.UpdateAccount)
|
|
76
|
+
.map(async tx => {
|
|
77
|
+
const payerAccount = tx.transaction_id.split("-")[0];
|
|
78
|
+
const analysis = await analyzeStakingOperation(payerAccount, tx);
|
|
79
|
+
|
|
80
|
+
return [tx.transaction_hash, analysis] as const;
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
const stakingAnalysisMap = new Map(stakingAnalyses);
|
|
84
|
+
|
|
53
85
|
const blockTransactions: BlockTransaction[] = transactions.map(tx => {
|
|
54
86
|
const payerAccount = tx.transaction_id.split("-")[0];
|
|
55
|
-
const
|
|
87
|
+
const stakingAnalysis = stakingAnalysisMap.get(tx.transaction_hash);
|
|
88
|
+
|
|
89
|
+
let operations: BlockOperation[];
|
|
90
|
+
|
|
91
|
+
if (stakingAnalysis) {
|
|
92
|
+
operations = [
|
|
93
|
+
{
|
|
94
|
+
type: "other",
|
|
95
|
+
operationType: stakingAnalysis.operationType,
|
|
96
|
+
stakedNodeId: stakingAnalysis.targetStakingNodeId,
|
|
97
|
+
previousStakedNodeId: stakingAnalysis.previousStakingNodeId,
|
|
98
|
+
stakedAmount: stakingAnalysis.stakedAmount,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
} else {
|
|
102
|
+
const allTransfers = [...tx.transfers, ...tx.token_transfers];
|
|
103
|
+
operations = allTransfers.map(transfer =>
|
|
104
|
+
toBlockOperation(payerAccount, tx.charged_tx_fee, transfer),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
56
107
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
);
|
|
108
|
+
// add staking reward operations if present (can occur on any transaction type)
|
|
109
|
+
const rewardOperations = createStakingRewardOperations(tx);
|
|
110
|
+
operations.push(...rewardOperations);
|
|
60
111
|
|
|
61
112
|
return {
|
|
62
113
|
hash: tx.transaction_hash,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getValidators } from "./getValidators";
|
|
2
|
+
import { apiClient } from "../network/api";
|
|
3
|
+
|
|
4
|
+
jest.mock("../network/api");
|
|
5
|
+
|
|
6
|
+
describe("getValidators", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
jest.clearAllMocks();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("should return formatted validators with APY", async () => {
|
|
12
|
+
(apiClient.getNodes as jest.Mock).mockResolvedValue({
|
|
13
|
+
nodes: [
|
|
14
|
+
{
|
|
15
|
+
node_id: 1,
|
|
16
|
+
node_account_id: "0.0.3",
|
|
17
|
+
description: "Hosted by Ledger | Paris, France",
|
|
18
|
+
stake: 1000000,
|
|
19
|
+
reward_rate_start: 3538,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
nextCursor: null,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const result = await getValidators();
|
|
26
|
+
|
|
27
|
+
expect(result.items).toHaveLength(1);
|
|
28
|
+
expect(result.items[0]).toMatchObject({
|
|
29
|
+
address: "0.0.3",
|
|
30
|
+
nodeId: "1",
|
|
31
|
+
name: "Ledger",
|
|
32
|
+
description: "Hosted by Ledger | Paris, France",
|
|
33
|
+
balance: BigInt(1000000),
|
|
34
|
+
apy: expect.any(Number),
|
|
35
|
+
});
|
|
36
|
+
expect(result.items[0].apy).toBeCloseTo(0.01291, 5);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should handle pagination cursor", async () => {
|
|
40
|
+
(apiClient.getNodes as jest.Mock).mockResolvedValue({
|
|
41
|
+
nodes: [],
|
|
42
|
+
nextCursor: "123",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const result = await getValidators("100");
|
|
46
|
+
|
|
47
|
+
expect(apiClient.getNodes).toHaveBeenCalledWith({ cursor: "100", fetchAllPages: false });
|
|
48
|
+
expect(result.next).toBe("123");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Cursor, Page, Validator } from "@ledgerhq/coin-framework/api/types";
|
|
2
|
+
import { apiClient } from "../network/api";
|
|
3
|
+
import { calculateAPY, extractCompanyFromNodeDescription } from "./utils";
|
|
4
|
+
|
|
5
|
+
export async function getValidators(cursor?: Cursor): Promise<Page<Validator>> {
|
|
6
|
+
const res = await apiClient.getNodes({
|
|
7
|
+
fetchAllPages: false,
|
|
8
|
+
...(cursor && { cursor }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
next: res.nextCursor ?? undefined,
|
|
13
|
+
items: res.nodes.map(node => ({
|
|
14
|
+
address: node.node_account_id,
|
|
15
|
+
nodeId: node.node_id.toString(),
|
|
16
|
+
name: extractCompanyFromNodeDescription(node.description),
|
|
17
|
+
description: node.description,
|
|
18
|
+
balance: BigInt(node.stake),
|
|
19
|
+
apy: calculateAPY(node.reward_rate_start),
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
}
|
package/src/logic/index.ts
CHANGED
|
@@ -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
|
});
|