@ledgerhq/coin-hedera 1.15.0-nightly.20251127023715 → 1.15.0-nightly.20251127103328

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +10 -8
  2. package/lib/api/index.d.ts.map +1 -1
  3. package/lib/api/index.js +2 -6
  4. package/lib/api/index.js.map +1 -1
  5. package/lib/logic/getBlock.d.ts +3 -0
  6. package/lib/logic/getBlock.d.ts.map +1 -0
  7. package/lib/logic/getBlock.js +54 -0
  8. package/lib/logic/getBlock.js.map +1 -0
  9. package/lib/logic/getBlockInfo.d.ts +10 -0
  10. package/lib/logic/getBlockInfo.d.ts.map +1 -0
  11. package/lib/logic/getBlockInfo.js +24 -0
  12. package/lib/logic/getBlockInfo.js.map +1 -0
  13. package/lib/logic/index.d.ts +2 -0
  14. package/lib/logic/index.d.ts.map +1 -1
  15. package/lib/logic/index.js +5 -1
  16. package/lib/logic/index.js.map +1 -1
  17. package/lib/logic/utils.d.ts +13 -1
  18. package/lib/logic/utils.d.ts.map +1 -1
  19. package/lib/logic/utils.js +22 -2
  20. package/lib/logic/utils.js.map +1 -1
  21. package/lib/network/api.d.ts +2 -0
  22. package/lib/network/api.d.ts.map +1 -1
  23. package/lib/network/api.js +21 -0
  24. package/lib/network/api.js.map +1 -1
  25. package/lib-es/api/index.d.ts.map +1 -1
  26. package/lib-es/api/index.js +3 -7
  27. package/lib-es/api/index.js.map +1 -1
  28. package/lib-es/logic/getBlock.d.ts +3 -0
  29. package/lib-es/logic/getBlock.d.ts.map +1 -0
  30. package/lib-es/logic/getBlock.js +50 -0
  31. package/lib-es/logic/getBlock.js.map +1 -0
  32. package/lib-es/logic/getBlockInfo.d.ts +10 -0
  33. package/lib-es/logic/getBlockInfo.d.ts.map +1 -0
  34. package/lib-es/logic/getBlockInfo.js +20 -0
  35. package/lib-es/logic/getBlockInfo.js.map +1 -0
  36. package/lib-es/logic/index.d.ts +2 -0
  37. package/lib-es/logic/index.d.ts.map +1 -1
  38. package/lib-es/logic/index.js +2 -0
  39. package/lib-es/logic/index.js.map +1 -1
  40. package/lib-es/logic/utils.d.ts +13 -1
  41. package/lib-es/logic/utils.d.ts.map +1 -1
  42. package/lib-es/logic/utils.js +19 -1
  43. package/lib-es/logic/utils.js.map +1 -1
  44. package/lib-es/network/api.d.ts +2 -0
  45. package/lib-es/network/api.d.ts.map +1 -1
  46. package/lib-es/network/api.js +21 -0
  47. package/lib-es/network/api.js.map +1 -1
  48. package/package.json +7 -7
  49. package/src/api/index.integ.test.ts +117 -0
  50. package/src/api/index.ts +4 -8
  51. package/src/logic/getBlock.test.ts +101 -0
  52. package/src/logic/getBlock.ts +74 -0
  53. package/src/logic/getBlockInfo.test.ts +37 -0
  54. package/src/logic/getBlockInfo.ts +25 -0
  55. package/src/logic/index.ts +2 -0
  56. package/src/logic/utils.test.ts +80 -0
  57. package/src/logic/utils.ts +26 -2
  58. package/src/network/api.test.ts +121 -0
  59. package/src/network/api.ts +29 -0
@@ -0,0 +1,74 @@
1
+ import type {
2
+ AssetInfo,
3
+ Block,
4
+ BlockOperation,
5
+ BlockTransaction,
6
+ } from "@ledgerhq/coin-framework/api/types";
7
+ import { getBlockInfo } from "./getBlockInfo";
8
+ import { apiClient } from "../network/api";
9
+ import type { HederaMirrorCoinTransfer, HederaMirrorTokenTransfer } from "../types";
10
+ import { getTimestampRangeFromBlockHeight } from "./utils";
11
+
12
+ function toHederaAsset(
13
+ mirrorTransfer: HederaMirrorCoinTransfer | HederaMirrorTokenTransfer,
14
+ ): AssetInfo {
15
+ if ("token_id" in mirrorTransfer) {
16
+ return {
17
+ type: "hts",
18
+ assetReference: mirrorTransfer.token_id,
19
+ };
20
+ }
21
+
22
+ return { type: "native" };
23
+ }
24
+
25
+ function toBlockOperation(
26
+ payerAccount: string,
27
+ chargedFee: number,
28
+ mirrorTransfer: HederaMirrorCoinTransfer | HederaMirrorTokenTransfer,
29
+ ): BlockOperation {
30
+ const isTokenTransfer = "token_id" in mirrorTransfer;
31
+ const address = mirrorTransfer.account;
32
+ const asset = toHederaAsset(mirrorTransfer);
33
+ let amount = BigInt(mirrorTransfer.amount);
34
+
35
+ // exclude fee from payer's operation amount (fees are accounted for separately, so operations must not represent fees)
36
+ if (payerAccount === address && !isTokenTransfer) {
37
+ amount += BigInt(chargedFee);
38
+ }
39
+
40
+ return {
41
+ type: "transfer",
42
+ address,
43
+ asset,
44
+ amount,
45
+ };
46
+ }
47
+
48
+ export async function getBlock(height: number): Promise<Block> {
49
+ const { start, end } = getTimestampRangeFromBlockHeight(height);
50
+ const blockInfo = await getBlockInfo(height);
51
+ const transactions = await apiClient.getTransactionsByTimestampRange(start, end);
52
+
53
+ const blockTransactions: BlockTransaction[] = transactions.map(tx => {
54
+ const payerAccount = tx.transaction_id.split("-")[0];
55
+ const allTransfers = [...tx.transfers, ...tx.token_transfers];
56
+
57
+ const operations = allTransfers.map(transfer =>
58
+ toBlockOperation(payerAccount, tx.charged_tx_fee, transfer),
59
+ );
60
+
61
+ return {
62
+ hash: tx.transaction_hash,
63
+ failed: tx.result !== "SUCCESS",
64
+ operations,
65
+ fees: BigInt(tx.charged_tx_fee),
66
+ feesPayer: payerAccount,
67
+ };
68
+ });
69
+
70
+ return {
71
+ info: blockInfo,
72
+ transactions: blockTransactions,
73
+ };
74
+ }
@@ -0,0 +1,37 @@
1
+ import { SYNTHETIC_BLOCK_WINDOW_SECONDS } from "../constants";
2
+ import { getBlockInfo } from "./getBlockInfo";
3
+
4
+ describe("getBlockInfo", () => {
5
+ beforeEach(() => {
6
+ jest.clearAllMocks();
7
+ });
8
+
9
+ it("should calculate time correctly based on block height and default window", async () => {
10
+ const blockHeight = 100;
11
+ const expectedSeconds = blockHeight * SYNTHETIC_BLOCK_WINDOW_SECONDS;
12
+ const expectedTime = new Date(expectedSeconds * 1000);
13
+
14
+ const result = await getBlockInfo(blockHeight);
15
+
16
+ expect(result).toMatchObject({
17
+ height: blockHeight,
18
+ hash: expect.any(String),
19
+ time: expectedTime,
20
+ });
21
+ });
22
+
23
+ it("should use custom block window seconds when provided", async () => {
24
+ const blockHeight = 50;
25
+ const customWindow = 120;
26
+ const expectedSeconds = blockHeight * customWindow;
27
+ const expectedTime = new Date(expectedSeconds * 1000);
28
+
29
+ const result = await getBlockInfo(blockHeight, customWindow);
30
+
31
+ expect(result).toMatchObject({
32
+ height: blockHeight,
33
+ hash: expect.any(String),
34
+ time: expectedTime,
35
+ });
36
+ });
37
+ });
@@ -0,0 +1,25 @@
1
+ import type { BlockInfo } from "@ledgerhq/coin-framework/lib-es/api/types";
2
+ import { SYNTHETIC_BLOCK_WINDOW_SECONDS } from "../constants";
3
+ import { getBlockHash } from "./utils";
4
+
5
+ /**
6
+ * Retrieves synthetic block information based on the provided block height.
7
+ *
8
+ * @param blockHeight - The height of the block for which to retrieve information.
9
+ * @param blockWindowSeconds - The duration in seconds that defines the synthetic block window (default is SYNTHETIC_BLOCK_WINDOW_SECONDS).
10
+ * @returns An object containing the block height, block hash, and block time.
11
+ */
12
+ export const getBlockInfo = async (
13
+ blockHeight: number,
14
+ blockWindowSeconds = SYNTHETIC_BLOCK_WINDOW_SECONDS,
15
+ ): Promise<BlockInfo> => {
16
+ const seconds = blockHeight * blockWindowSeconds;
17
+ const hash = getBlockHash(blockHeight);
18
+ const time = new Date(seconds * 1000);
19
+
20
+ return {
21
+ height: blockHeight,
22
+ hash,
23
+ time,
24
+ };
25
+ };
@@ -3,6 +3,8 @@ export { combine } from "./combine";
3
3
  export { craftTransaction } from "./craftTransaction";
4
4
  export { estimateFees } from "./estimateFees";
5
5
  export { getBalance } from "./getBalance";
6
+ export { getBlock } from "./getBlock";
7
+ export { getBlockInfo } from "./getBlockInfo";
6
8
  export { lastBlock } from "./lastBlock";
7
9
  export { listOperations } from "./listOperations";
8
10
  export { getAssetFromToken } from "./getAssetFromToken";
@@ -33,6 +33,8 @@ import {
33
33
  fromEVMAddress,
34
34
  toEVMAddress,
35
35
  formatTransactionId,
36
+ getTimestampRangeFromBlockHeight,
37
+ getBlockHash,
36
38
  } from "./utils";
37
39
 
38
40
  jest.mock("../network/api");
@@ -517,4 +519,82 @@ describe("logic utils", () => {
517
519
  expect(fromEVMAddress(undefined as unknown as string)).toBeNull();
518
520
  });
519
521
  });
522
+
523
+ describe("getBlockHash", () => {
524
+ it("produces consistent 64-character hex hash", () => {
525
+ const hash = getBlockHash(12345);
526
+
527
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
528
+ });
529
+
530
+ it("produces same hash for same block height", () => {
531
+ const hash1 = getBlockHash(100);
532
+ const hash2 = getBlockHash(100);
533
+
534
+ expect(hash1).toBe(hash2);
535
+ });
536
+
537
+ it("produces different hashes for different block heights", () => {
538
+ const hash1 = getBlockHash(100);
539
+ const hash2 = getBlockHash(101);
540
+
541
+ expect(hash1).not.toBe(hash2);
542
+ });
543
+ });
544
+
545
+ describe("getTimestampRangeFromBlockHeight", () => {
546
+ it("calculates consensus timestamp for block height 0 with default window", () => {
547
+ const result = getTimestampRangeFromBlockHeight(0);
548
+
549
+ expect(result).toEqual({
550
+ start: "0.000000000",
551
+ end: "10.000000000",
552
+ });
553
+ });
554
+
555
+ it("calculates consensus timestamp for block height 1 with default window", () => {
556
+ const result = getTimestampRangeFromBlockHeight(1);
557
+
558
+ expect(result).toEqual({
559
+ start: "10.000000000",
560
+ end: "20.000000000",
561
+ });
562
+ });
563
+
564
+ it("calculates consensus timestamp with custom block window of 1 second", () => {
565
+ const result = getTimestampRangeFromBlockHeight(42, 1);
566
+
567
+ expect(result).toEqual({
568
+ start: "42.000000000",
569
+ end: "43.000000000",
570
+ });
571
+ });
572
+
573
+ it("handles large block heights correctly", () => {
574
+ const result = getTimestampRangeFromBlockHeight(1000000);
575
+
576
+ expect(result).toEqual({
577
+ start: "10000000.000000000",
578
+ end: "10000010.000000000",
579
+ });
580
+ });
581
+
582
+ it("ensures start and end timestamps are within the same block window", () => {
583
+ const blockHeight = 50;
584
+ const blockWindowSeconds = 10;
585
+ const result = getTimestampRangeFromBlockHeight(blockHeight, blockWindowSeconds);
586
+
587
+ const startSeconds = parseInt(result.start.split(".")[0]);
588
+ const endSeconds = parseInt(result.end.split(".")[0]);
589
+
590
+ expect(endSeconds - startSeconds).toBe(blockWindowSeconds);
591
+ });
592
+
593
+ it("maintains correct nanosecond precision format", () => {
594
+ const result = getTimestampRangeFromBlockHeight(123);
595
+
596
+ expect(result.start).toMatch(/^\d+\.000000000$/);
597
+ expect(result.end).toMatch(/^\d+\.000000000$/);
598
+ });
599
+ });
520
600
  });
@@ -7,7 +7,7 @@ import {
7
7
  Transaction as HederaSDKTransaction,
8
8
  TransactionId,
9
9
  } from "@hashgraph/sdk";
10
- import { AssetInfo, TransactionIntent } from "@ledgerhq/coin-framework/api/types";
10
+ import type { AssetInfo, TransactionIntent } from "@ledgerhq/coin-framework/api/types";
11
11
  import { findCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies";
12
12
  import { getFiatCurrencyByTicker } from "@ledgerhq/cryptoassets/fiats";
13
13
  import cvsApi from "@ledgerhq/live-countervalues/api/index";
@@ -242,6 +242,10 @@ export const safeParseAccountId = (
242
242
  }
243
243
  };
244
244
 
245
+ export function getBlockHash(blockHeight: number): string {
246
+ return createHash("sha256").update(blockHeight.toString()).digest("hex");
247
+ }
248
+
245
249
  /**
246
250
  * Calculates a synthetic block height based on Hedera consensus timestamp.
247
251
  *
@@ -260,12 +264,32 @@ export function getSyntheticBlock(
260
264
  }
261
265
 
262
266
  const blockHeight = Math.floor(seconds / blockWindowSeconds);
263
- const blockHash = createHash("sha256").update(blockHeight.toString()).digest("hex");
267
+ const blockHash = getBlockHash(blockHeight);
264
268
  const blockTime = new Date(seconds * 1000);
265
269
 
266
270
  return { blockHeight, blockHash, blockTime };
267
271
  }
268
272
 
273
+ /**
274
+ * Calculates the timestamp range based on a synthetic block height.
275
+ *
276
+ * @param blockHeight - The synthetic block height
277
+ * @param blockWindowSeconds - Duration of one synthetic block in seconds (default: 10)
278
+ * @returns Hedera timestamp range as a string
279
+ */
280
+ export function getTimestampRangeFromBlockHeight(
281
+ blockHeight: number,
282
+ blockWindowSeconds = SYNTHETIC_BLOCK_WINDOW_SECONDS,
283
+ ) {
284
+ const startTimestamp = blockHeight * blockWindowSeconds;
285
+ const endTimestamp = (blockHeight + 1) * blockWindowSeconds;
286
+
287
+ return {
288
+ start: `${startTimestamp}.000000000`,
289
+ end: `${endTimestamp}.000000000`,
290
+ };
291
+ }
292
+
269
293
  export const formatTransactionId = (transactionId: TransactionId): string => {
270
294
  const [accountId, timestamp] = transactionId.toString().split("@");
271
295
  const [secs, nanos] = timestamp.split(".");
@@ -400,3 +400,124 @@ describe("estimateContractCallGas", () => {
400
400
  expect(mockedNetwork).toHaveBeenCalledTimes(1);
401
401
  });
402
402
  });
403
+
404
+ describe("getTransactionsByTimestampRange", () => {
405
+ beforeEach(() => {
406
+ jest.resetAllMocks();
407
+ });
408
+
409
+ it("should include correct query params with timestamp range", async () => {
410
+ mockedNetwork.mockResolvedValueOnce(
411
+ getMockResponse({ transactions: [], links: { next: null } }),
412
+ );
413
+
414
+ await apiClient.getTransactionsByTimestampRange("1000.000000000", "2000.000000000");
415
+
416
+ const requestUrl = mockedNetwork.mock.calls[0][0].url;
417
+ expect(requestUrl).toContain("timestamp=gte%3A1000.000000000");
418
+ expect(requestUrl).toContain("timestamp=lt%3A2000.000000000");
419
+ expect(requestUrl).toContain("limit=100");
420
+ expect(requestUrl).toContain("order=desc");
421
+ });
422
+
423
+ it("should return empty array when no transactions found", async () => {
424
+ mockedNetwork.mockResolvedValueOnce(
425
+ getMockResponse({ transactions: [], links: { next: null } }),
426
+ );
427
+
428
+ const result = await apiClient.getTransactionsByTimestampRange(
429
+ "1000.000000000",
430
+ "2000.000000000",
431
+ );
432
+
433
+ expect(result).toEqual([]);
434
+ expect(mockedNetwork).toHaveBeenCalledTimes(1);
435
+ });
436
+
437
+ it("should return all transactions when only one page is needed", async () => {
438
+ mockedNetwork.mockResolvedValueOnce(
439
+ getMockResponse({
440
+ transactions: [
441
+ { consensus_timestamp: "1500.123456789" },
442
+ { consensus_timestamp: "1750.987654321" },
443
+ ],
444
+ links: { next: null },
445
+ }),
446
+ );
447
+
448
+ const result = await apiClient.getTransactionsByTimestampRange(
449
+ "1000.000000000",
450
+ "2000.000000000",
451
+ );
452
+
453
+ expect(result).toHaveLength(2);
454
+ expect(result.map(tx => tx.consensus_timestamp)).toEqual(["1500.123456789", "1750.987654321"]);
455
+ expect(mockedNetwork).toHaveBeenCalledTimes(1);
456
+ });
457
+
458
+ it("should keep fetching all pages when links.next is present", async () => {
459
+ mockedNetwork
460
+ .mockResolvedValueOnce(
461
+ getMockResponse({
462
+ transactions: [{ consensus_timestamp: "1100.000000000" }],
463
+ links: { next: "/next-1" },
464
+ }),
465
+ )
466
+ .mockResolvedValueOnce(
467
+ getMockResponse({
468
+ transactions: [{ consensus_timestamp: "1200.000000000" }],
469
+ links: { next: "/next-2" },
470
+ }),
471
+ )
472
+ .mockResolvedValueOnce(
473
+ getMockResponse({
474
+ transactions: [{ consensus_timestamp: "1300.000000000" }],
475
+ links: { next: null },
476
+ }),
477
+ );
478
+
479
+ const result = await apiClient.getTransactionsByTimestampRange(
480
+ "1000.000000000",
481
+ "2000.000000000",
482
+ );
483
+
484
+ expect(result).toHaveLength(3);
485
+ expect(result.map(tx => tx.consensus_timestamp)).toEqual([
486
+ "1100.000000000",
487
+ "1200.000000000",
488
+ "1300.000000000",
489
+ ]);
490
+ expect(mockedNetwork).toHaveBeenCalledTimes(3);
491
+ });
492
+
493
+ it("should handle empty pages and continue fetching", async () => {
494
+ mockedNetwork
495
+ .mockResolvedValueOnce(
496
+ getMockResponse({
497
+ transactions: [{ consensus_timestamp: "1100.000000000" }],
498
+ links: { next: "/next-1" },
499
+ }),
500
+ )
501
+ .mockResolvedValueOnce(
502
+ getMockResponse({
503
+ transactions: [],
504
+ links: { next: "/next-2" },
505
+ }),
506
+ )
507
+ .mockResolvedValueOnce(
508
+ getMockResponse({
509
+ transactions: [{ consensus_timestamp: "1300.000000000" }],
510
+ links: { next: null },
511
+ }),
512
+ );
513
+
514
+ const result = await apiClient.getTransactionsByTimestampRange(
515
+ "1000.000000000",
516
+ "2000.000000000",
517
+ );
518
+
519
+ expect(result).toHaveLength(2);
520
+ expect(result.map(tx => tx.consensus_timestamp)).toEqual(["1100.000000000", "1300.000000000"]);
521
+ expect(mockedNetwork).toHaveBeenCalledTimes(3);
522
+ });
523
+ });
@@ -239,6 +239,34 @@ async function estimateContractCallGas(
239
239
  return new BigNumber(res.data.result);
240
240
  }
241
241
 
242
+ async function getTransactionsByTimestampRange(
243
+ startTimestamp: string,
244
+ endTimestamp: string,
245
+ ): Promise<HederaMirrorTransaction[]> {
246
+ const transactions: HederaMirrorTransaction[] = [];
247
+ const params = new URLSearchParams({
248
+ limit: "100",
249
+ order: "desc",
250
+ });
251
+
252
+ params.append("timestamp", `gte:${startTimestamp}`);
253
+ params.append("timestamp", `lt:${endTimestamp}`);
254
+
255
+ let nextPath: string | null = `/api/v1/transactions?${params.toString()}`;
256
+
257
+ while (nextPath) {
258
+ const res: LiveNetworkResponse<HederaMirrorTransactionsResponse> = await network({
259
+ method: "GET",
260
+ url: `${API_URL}${nextPath}`,
261
+ });
262
+ const newTransactions = res.data.transactions;
263
+ transactions.push(...newTransactions);
264
+ nextPath = res.data.links.next;
265
+ }
266
+
267
+ return transactions;
268
+ }
269
+
242
270
  export const apiClient = {
243
271
  getAccountsForPublicKey,
244
272
  getAccount,
@@ -250,4 +278,5 @@ export const apiClient = {
250
278
  findTransactionByContractCall,
251
279
  getERC20Balance,
252
280
  estimateContractCallGas,
281
+ getTransactionsByTimestampRange,
253
282
  };